Skip to main content

ipfrs_tensorlogic/
shape_inference.rs

1//! Static shape inference for tensor operation graphs.
2//!
3//! Provides [`TensorShapeInference`] which computes output shapes for common
4//! tensor operations (broadcast, matmul, reshape, transpose, concat, slice)
5//! following NumPy-compatible broadcasting rules.
6//!
7//! # Examples
8//!
9//! ```
10//! use ipfrs_tensorlogic::shape_inference::{TensorShapeInference, TensorShape, ShapeOp, InferenceRule};
11//! use std::collections::HashMap;
12//!
13//! let mut engine = TensorShapeInference::new();
14//!
15//! // Matrix multiply (3,4) x (4,5) -> (3,5)
16//! let rule = InferenceRule {
17//!     op: ShapeOp::MatMul,
18//!     input_shapes: vec![
19//!         TensorShape { dims: vec![3, 4] },
20//!         TensorShape { dims: vec![4, 5] },
21//!     ],
22//!     params: HashMap::new(),
23//! };
24//! let result = engine.infer(&rule).expect("example: should succeed in docs");
25//! assert_eq!(result.dims, vec![3, 5]);
26//! ```
27
28use std::collections::HashMap;
29
30/// Shape descriptor for a tensor.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TensorShape {
33    /// Dimension sizes (e.g. `[3, 4, 5]` for a rank-3 tensor).
34    pub dims: Vec<usize>,
35}
36
37impl TensorShape {
38    /// Create a new shape from dimension sizes.
39    pub fn new(dims: Vec<usize>) -> Self {
40        Self { dims }
41    }
42
43    /// Rank (number of dimensions).
44    pub fn rank(&self) -> usize {
45        self.dims.len()
46    }
47}
48
49/// Supported tensor operations for shape inference.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ShapeOp {
52    /// Element-wise addition (or any binary broadcast-compatible op).
53    Add,
54    /// Matrix multiplication: (M,K) x (K,N) -> (M,N).
55    MatMul,
56    /// Reshape to new dimensions (total elements must match).
57    Reshape,
58    /// Reverse all dimensions.
59    Transpose,
60    /// Concatenate along an axis.
61    Concat,
62    /// Slice along an axis.
63    Slice,
64    /// Broadcast (expand) to a target shape.
65    Broadcast,
66}
67
68/// An inference rule: an operation together with its input shapes and
69/// optional parameters.
70#[derive(Debug, Clone)]
71pub struct InferenceRule {
72    /// The tensor operation.
73    pub op: ShapeOp,
74    /// Input tensor shapes.
75    pub input_shapes: Vec<TensorShape>,
76    /// Operation-specific parameters.
77    ///
78    /// * `Reshape`: keys `"dim0"`, `"dim1"`, ... for target dimensions, plus
79    ///   `"ndims"` for the number of target dimensions.
80    /// * `Concat`: key `"axis"`.
81    /// * `Slice`: keys `"axis"`, `"start"`, `"end"`.
82    /// * `Broadcast`: keys `"dim0"`, `"dim1"`, ... for the target shape, plus
83    ///   `"ndims"`.
84    pub params: HashMap<String, usize>,
85}
86
87/// Statistics collected by [`TensorShapeInference`].
88#[derive(Debug, Clone)]
89pub struct ShapeInferenceStats {
90    /// Number of rules successfully applied.
91    pub rules_applied: u64,
92    /// Number of inference errors encountered.
93    pub errors: u64,
94}
95
96/// Static shape inference engine for tensor operation graphs.
97///
98/// Tracks how many rules have been applied and how many errors occurred.
99pub struct TensorShapeInference {
100    rules_applied: u64,
101    errors: u64,
102}
103
104impl TensorShapeInference {
105    /// Create a new inference engine.
106    pub fn new() -> Self {
107        Self {
108            rules_applied: 0,
109            errors: 0,
110        }
111    }
112
113    /// Infer the output shape for a given inference rule.
114    pub fn infer(&mut self, rule: &InferenceRule) -> Result<TensorShape, String> {
115        let result = match rule.op {
116            ShapeOp::Add => {
117                if rule.input_shapes.len() < 2 {
118                    return Err(self.record_error("Add requires at least 2 inputs".to_string()));
119                }
120                Self::broadcast_shape(&rule.input_shapes[0], &rule.input_shapes[1])
121            }
122            ShapeOp::MatMul => {
123                if rule.input_shapes.len() < 2 {
124                    return Err(self.record_error("MatMul requires 2 inputs".to_string()));
125                }
126                Self::matmul_shape(&rule.input_shapes[0], &rule.input_shapes[1])
127            }
128            ShapeOp::Reshape => {
129                if rule.input_shapes.is_empty() {
130                    return Err(self.record_error("Reshape requires 1 input".to_string()));
131                }
132                let new_dims = Self::extract_dims(&rule.params)?;
133                Self::reshape_shape(&rule.input_shapes[0], &new_dims)
134            }
135            ShapeOp::Transpose => {
136                if rule.input_shapes.is_empty() {
137                    return Err(self.record_error("Transpose requires 1 input".to_string()));
138                }
139                Ok(Self::transpose_shape(&rule.input_shapes[0]))
140            }
141            ShapeOp::Concat => {
142                if rule.input_shapes.is_empty() {
143                    return Err(self.record_error("Concat requires at least 1 input".to_string()));
144                }
145                let axis = *rule
146                    .params
147                    .get("axis")
148                    .ok_or_else(|| "Concat requires 'axis' parameter".to_string())?;
149                Self::concat_shape(&rule.input_shapes, axis)
150            }
151            ShapeOp::Slice => {
152                if rule.input_shapes.is_empty() {
153                    return Err(self.record_error("Slice requires 1 input".to_string()));
154                }
155                let axis = *rule
156                    .params
157                    .get("axis")
158                    .ok_or_else(|| "Slice requires 'axis' parameter".to_string())?;
159                let start = *rule
160                    .params
161                    .get("start")
162                    .ok_or_else(|| "Slice requires 'start' parameter".to_string())?;
163                let end = *rule
164                    .params
165                    .get("end")
166                    .ok_or_else(|| "Slice requires 'end' parameter".to_string())?;
167                Self::slice_shape(&rule.input_shapes[0], axis, start, end)
168            }
169            ShapeOp::Broadcast => {
170                if rule.input_shapes.is_empty() {
171                    return Err(self.record_error("Broadcast requires 1 input".to_string()));
172                }
173                let target_dims = Self::extract_dims(&rule.params)?;
174                let target = TensorShape::new(target_dims);
175                Self::broadcast_shape(&rule.input_shapes[0], &target)
176            }
177        };
178
179        match result {
180            Ok(shape) => {
181                self.rules_applied += 1;
182                Ok(shape)
183            }
184            Err(e) => Err(self.record_error(e)),
185        }
186    }
187
188    /// Compute the broadcast-compatible output shape of two tensors following
189    /// NumPy broadcasting rules.
190    ///
191    /// Rules:
192    /// 1. Shapes are right-aligned.
193    /// 2. Dimensions are compatible when they are equal, or one of them is 1.
194    /// 3. The output dimension is the maximum of the two.
195    pub fn broadcast_shape(a: &TensorShape, b: &TensorShape) -> Result<TensorShape, String> {
196        let max_rank = a.rank().max(b.rank());
197        let mut result_dims = Vec::with_capacity(max_rank);
198
199        for i in 0..max_rank {
200            // Right-align: index from the end.
201            let da = if i < a.rank() {
202                a.dims[a.rank() - 1 - i]
203            } else {
204                1
205            };
206            let db = if i < b.rank() {
207                b.dims[b.rank() - 1 - i]
208            } else {
209                1
210            };
211
212            if da == db {
213                result_dims.push(da);
214            } else if da == 1 {
215                result_dims.push(db);
216            } else if db == 1 {
217                result_dims.push(da);
218            } else {
219                return Err(format!(
220                    "Shapes are not broadcast-compatible: {:?} vs {:?} (dimension {} from right: {} vs {})",
221                    a.dims, b.dims, i, da, db
222                ));
223            }
224        }
225
226        result_dims.reverse();
227        Ok(TensorShape::new(result_dims))
228    }
229
230    /// Compute the output shape of a matrix multiplication.
231    ///
232    /// For 2-D inputs `(M, K)` and `(K, N)`, the output is `(M, N)`.
233    /// For higher-rank inputs the batch dimensions must be broadcast-compatible
234    /// and the last two dimensions follow the matrix multiplication rule.
235    pub fn matmul_shape(a: &TensorShape, b: &TensorShape) -> Result<TensorShape, String> {
236        if a.rank() < 2 || b.rank() < 2 {
237            return Err(format!(
238                "MatMul requires at least 2-D tensors, got ranks {} and {}",
239                a.rank(),
240                b.rank()
241            ));
242        }
243
244        let a_rows = a.dims[a.rank() - 2];
245        let a_cols = a.dims[a.rank() - 1];
246        let b_rows = b.dims[b.rank() - 2];
247        let b_cols = b.dims[b.rank() - 1];
248
249        if a_cols != b_rows {
250            return Err(format!(
251                "MatMul inner dimensions mismatch: {} vs {}",
252                a_cols, b_rows
253            ));
254        }
255
256        // Broadcast batch dimensions.
257        let a_batch = TensorShape::new(a.dims[..a.rank() - 2].to_vec());
258        let b_batch = TensorShape::new(b.dims[..b.rank() - 2].to_vec());
259        let batch = Self::broadcast_shape(&a_batch, &b_batch)?;
260
261        let mut result_dims = batch.dims;
262        result_dims.push(a_rows);
263        result_dims.push(b_cols);
264        Ok(TensorShape::new(result_dims))
265    }
266
267    /// Verify that `new_dims` has the same total number of elements as `input`
268    /// and return the reshaped tensor shape.
269    pub fn reshape_shape(input: &TensorShape, new_dims: &[usize]) -> Result<TensorShape, String> {
270        let input_elems = Self::total_elements(input);
271        let output_elems: usize = new_dims.iter().product();
272
273        if input_elems != output_elems {
274            return Err(format!(
275                "Reshape: total elements mismatch ({} vs {})",
276                input_elems, output_elems
277            ));
278        }
279
280        Ok(TensorShape::new(new_dims.to_vec()))
281    }
282
283    /// Return the shape with dimensions reversed (general transpose).
284    pub fn transpose_shape(input: &TensorShape) -> TensorShape {
285        let mut dims = input.dims.clone();
286        dims.reverse();
287        TensorShape::new(dims)
288    }
289
290    /// Concatenate `inputs` along `axis`. All dimensions except `axis` must
291    /// match across all inputs.
292    pub fn concat_shape(inputs: &[TensorShape], axis: usize) -> Result<TensorShape, String> {
293        if inputs.is_empty() {
294            return Err("Concat requires at least 1 input".to_string());
295        }
296
297        let rank = inputs[0].rank();
298        if axis >= rank {
299            return Err(format!(
300                "Concat axis {} is out of bounds for rank {}",
301                axis, rank
302            ));
303        }
304
305        let mut concat_dim = 0usize;
306        for (i, shape) in inputs.iter().enumerate() {
307            if shape.rank() != rank {
308                return Err(format!(
309                    "Concat: all inputs must have the same rank, input 0 has rank {} but input {} has rank {}",
310                    rank, i, shape.rank()
311                ));
312            }
313            for d in 0..rank {
314                if d != axis && shape.dims[d] != inputs[0].dims[d] {
315                    return Err(format!(
316                        "Concat: dimension {} mismatch between input 0 ({}) and input {} ({})",
317                        d, inputs[0].dims[d], i, shape.dims[d]
318                    ));
319                }
320            }
321            concat_dim = concat_dim
322                .checked_add(shape.dims[axis])
323                .ok_or_else(|| "Concat: dimension overflow".to_string())?;
324        }
325
326        let mut result_dims = inputs[0].dims.clone();
327        result_dims[axis] = concat_dim;
328        Ok(TensorShape::new(result_dims))
329    }
330
331    /// Slice `input` along `axis` from `start` (inclusive) to `end`
332    /// (exclusive).
333    pub fn slice_shape(
334        input: &TensorShape,
335        axis: usize,
336        start: usize,
337        end: usize,
338    ) -> Result<TensorShape, String> {
339        if axis >= input.rank() {
340            return Err(format!(
341                "Slice axis {} is out of bounds for rank {}",
342                axis,
343                input.rank()
344            ));
345        }
346
347        if start > end {
348            return Err(format!(
349                "Slice: start ({}) must not exceed end ({})",
350                start, end
351            ));
352        }
353
354        if end > input.dims[axis] {
355            return Err(format!(
356                "Slice: end ({}) exceeds dimension size ({}) on axis {}",
357                end, input.dims[axis], axis
358            ));
359        }
360
361        let mut result_dims = input.dims.clone();
362        result_dims[axis] = end - start;
363        Ok(TensorShape::new(result_dims))
364    }
365
366    /// Total number of elements in a shape (product of dimensions).
367    /// Returns 1 for a scalar (empty dims).
368    pub fn total_elements(shape: &TensorShape) -> usize {
369        if shape.dims.is_empty() {
370            return 1;
371        }
372        shape.dims.iter().product()
373    }
374
375    /// Whether the shape represents a scalar: either empty dims or every
376    /// dimension is 1.
377    pub fn is_scalar(shape: &TensorShape) -> bool {
378        shape.dims.is_empty() || shape.dims.iter().all(|&d| d == 1)
379    }
380
381    /// Return collected statistics.
382    pub fn stats(&self) -> ShapeInferenceStats {
383        ShapeInferenceStats {
384            rules_applied: self.rules_applied,
385            errors: self.errors,
386        }
387    }
388
389    // ---- helpers ----
390
391    /// Record an error, bump the counter, and return the message.
392    fn record_error(&mut self, msg: String) -> String {
393        self.errors += 1;
394        msg
395    }
396
397    /// Extract ordered dimension list from params map.
398    /// Expects keys `"ndims"` and `"dim0"`, `"dim1"`, ...
399    fn extract_dims(params: &HashMap<String, usize>) -> Result<Vec<usize>, String> {
400        let ndims = *params
401            .get("ndims")
402            .ok_or_else(|| "Missing 'ndims' parameter".to_string())?;
403        let mut dims = Vec::with_capacity(ndims);
404        for i in 0..ndims {
405            let key = format!("dim{}", i);
406            let d = *params
407                .get(&key)
408                .ok_or_else(|| format!("Missing '{}' parameter", key))?;
409            dims.push(d);
410        }
411        Ok(dims)
412    }
413}
414
415impl Default for TensorShapeInference {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Tests
423// ---------------------------------------------------------------------------
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn shape(dims: &[usize]) -> TensorShape {
429        TensorShape::new(dims.to_vec())
430    }
431
432    fn make_params(entries: &[(&str, usize)]) -> HashMap<String, usize> {
433        entries.iter().map(|(k, v)| (k.to_string(), *v)).collect()
434    }
435
436    // ---- broadcast tests ----
437
438    #[test]
439    fn broadcast_same_shape() {
440        let a = shape(&[3, 4, 5]);
441        let b = shape(&[3, 4, 5]);
442        let r = TensorShapeInference::broadcast_shape(&a, &b);
443        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![3, 4, 5]));
444    }
445
446    #[test]
447    fn broadcast_scalar_and_tensor() {
448        let scalar = shape(&[]);
449        let tensor = shape(&[2, 3]);
450        let r = TensorShapeInference::broadcast_shape(&scalar, &tensor);
451        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![2, 3]));
452    }
453
454    #[test]
455    fn broadcast_scalar_one_and_tensor() {
456        let scalar = shape(&[1]);
457        let tensor = shape(&[5, 3]);
458        let r = TensorShapeInference::broadcast_shape(&scalar, &tensor);
459        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![5, 3]));
460    }
461
462    #[test]
463    fn broadcast_different_ranks() {
464        let a = shape(&[3, 1]);
465        let b = shape(&[2, 3, 4]);
466        let r = TensorShapeInference::broadcast_shape(&a, &b);
467        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![2, 3, 4]));
468    }
469
470    #[test]
471    fn broadcast_incompatible() {
472        let a = shape(&[3]);
473        let b = shape(&[4]);
474        let r = TensorShapeInference::broadcast_shape(&a, &b);
475        assert!(r.is_err());
476    }
477
478    #[test]
479    fn broadcast_ones_expansion() {
480        let a = shape(&[1, 4]);
481        let b = shape(&[3, 1]);
482        let r = TensorShapeInference::broadcast_shape(&a, &b);
483        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![3, 4]));
484    }
485
486    #[test]
487    fn broadcast_high_rank() {
488        let a = shape(&[1, 1, 5]);
489        let b = shape(&[8, 1, 1]);
490        let r = TensorShapeInference::broadcast_shape(&a, &b);
491        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![8, 1, 5]));
492    }
493
494    // ---- matmul tests ----
495
496    #[test]
497    fn matmul_valid_2d() {
498        let a = shape(&[3, 4]);
499        let b = shape(&[4, 5]);
500        let r = TensorShapeInference::matmul_shape(&a, &b);
501        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![3, 5]));
502    }
503
504    #[test]
505    fn matmul_inner_dim_mismatch() {
506        let a = shape(&[3, 4]);
507        let b = shape(&[5, 6]);
508        let r = TensorShapeInference::matmul_shape(&a, &b);
509        assert!(r.is_err());
510    }
511
512    #[test]
513    fn matmul_1d_rejected() {
514        let a = shape(&[4]);
515        let b = shape(&[4, 3]);
516        let r = TensorShapeInference::matmul_shape(&a, &b);
517        assert!(r.is_err());
518    }
519
520    #[test]
521    fn matmul_batched() {
522        let a = shape(&[2, 3, 4]);
523        let b = shape(&[2, 4, 5]);
524        let r = TensorShapeInference::matmul_shape(&a, &b);
525        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![2, 3, 5]));
526    }
527
528    #[test]
529    fn matmul_batch_broadcast() {
530        let a = shape(&[1, 3, 4]);
531        let b = shape(&[5, 4, 2]);
532        let r = TensorShapeInference::matmul_shape(&a, &b);
533        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![5, 3, 2]));
534    }
535
536    // ---- reshape tests ----
537
538    #[test]
539    fn reshape_valid() {
540        let input = shape(&[2, 3, 4]);
541        let r = TensorShapeInference::reshape_shape(&input, &[6, 4]);
542        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![6, 4]));
543    }
544
545    #[test]
546    fn reshape_element_mismatch() {
547        let input = shape(&[2, 3]);
548        let r = TensorShapeInference::reshape_shape(&input, &[7]);
549        assert!(r.is_err());
550    }
551
552    #[test]
553    fn reshape_to_flat() {
554        let input = shape(&[3, 4, 5]);
555        let r = TensorShapeInference::reshape_shape(&input, &[60]);
556        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![60]));
557    }
558
559    // ---- transpose tests ----
560
561    #[test]
562    fn transpose_2d() {
563        let input = shape(&[3, 4]);
564        let r = TensorShapeInference::transpose_shape(&input);
565        assert_eq!(r.dims, vec![4, 3]);
566    }
567
568    #[test]
569    fn transpose_3d() {
570        let input = shape(&[2, 3, 4]);
571        let r = TensorShapeInference::transpose_shape(&input);
572        assert_eq!(r.dims, vec![4, 3, 2]);
573    }
574
575    #[test]
576    fn transpose_scalar() {
577        let input = shape(&[]);
578        let r = TensorShapeInference::transpose_shape(&input);
579        assert_eq!(r.dims, Vec::<usize>::new());
580    }
581
582    // ---- concat tests ----
583
584    #[test]
585    fn concat_axis0() {
586        let a = shape(&[2, 3]);
587        let b = shape(&[4, 3]);
588        let r = TensorShapeInference::concat_shape(&[a, b], 0);
589        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![6, 3]));
590    }
591
592    #[test]
593    fn concat_axis1() {
594        let a = shape(&[2, 3]);
595        let b = shape(&[2, 5]);
596        let r = TensorShapeInference::concat_shape(&[a, b], 1);
597        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![2, 8]));
598    }
599
600    #[test]
601    fn concat_three_inputs() {
602        let a = shape(&[1, 4]);
603        let b = shape(&[2, 4]);
604        let c = shape(&[3, 4]);
605        let r = TensorShapeInference::concat_shape(&[a, b, c], 0);
606        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![6, 4]));
607    }
608
609    #[test]
610    fn concat_dim_mismatch() {
611        let a = shape(&[2, 3]);
612        let b = shape(&[2, 4]);
613        let r = TensorShapeInference::concat_shape(&[a, b], 0);
614        assert!(r.is_err());
615    }
616
617    #[test]
618    fn concat_axis_out_of_bounds() {
619        let a = shape(&[2, 3]);
620        let r = TensorShapeInference::concat_shape(&[a], 5);
621        assert!(r.is_err());
622    }
623
624    // ---- slice tests ----
625
626    #[test]
627    fn slice_basic() {
628        let input = shape(&[10, 5]);
629        let r = TensorShapeInference::slice_shape(&input, 0, 2, 7);
630        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![5, 5]));
631    }
632
633    #[test]
634    fn slice_axis1() {
635        let input = shape(&[4, 8]);
636        let r = TensorShapeInference::slice_shape(&input, 1, 1, 5);
637        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![4, 4]));
638    }
639
640    #[test]
641    fn slice_out_of_bounds() {
642        let input = shape(&[5, 3]);
643        let r = TensorShapeInference::slice_shape(&input, 0, 2, 10);
644        assert!(r.is_err());
645    }
646
647    #[test]
648    fn slice_start_exceeds_end() {
649        let input = shape(&[5, 3]);
650        let r = TensorShapeInference::slice_shape(&input, 0, 4, 2);
651        assert!(r.is_err());
652    }
653
654    #[test]
655    fn slice_empty_result() {
656        let input = shape(&[5, 3]);
657        let r = TensorShapeInference::slice_shape(&input, 0, 3, 3);
658        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![0, 3]));
659    }
660
661    // ---- total_elements tests ----
662
663    #[test]
664    fn total_elements_normal() {
665        assert_eq!(TensorShapeInference::total_elements(&shape(&[2, 3, 4])), 24);
666    }
667
668    #[test]
669    fn total_elements_scalar() {
670        assert_eq!(TensorShapeInference::total_elements(&shape(&[])), 1);
671    }
672
673    #[test]
674    fn total_elements_with_one() {
675        assert_eq!(TensorShapeInference::total_elements(&shape(&[1, 1, 1])), 1);
676    }
677
678    // ---- is_scalar tests ----
679
680    #[test]
681    fn is_scalar_empty() {
682        assert!(TensorShapeInference::is_scalar(&shape(&[])));
683    }
684
685    #[test]
686    fn is_scalar_all_ones() {
687        assert!(TensorShapeInference::is_scalar(&shape(&[1, 1, 1])));
688    }
689
690    #[test]
691    fn is_scalar_not() {
692        assert!(!TensorShapeInference::is_scalar(&shape(&[2, 1])));
693    }
694
695    // ---- infer (dispatch) tests ----
696
697    #[test]
698    fn infer_add() {
699        let mut engine = TensorShapeInference::new();
700        let rule = InferenceRule {
701            op: ShapeOp::Add,
702            input_shapes: vec![shape(&[3, 1]), shape(&[1, 4])],
703            params: HashMap::new(),
704        };
705        let r = engine.infer(&rule);
706        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![3, 4]));
707        assert_eq!(engine.stats().rules_applied, 1);
708    }
709
710    #[test]
711    fn infer_reshape() {
712        let mut engine = TensorShapeInference::new();
713        let rule = InferenceRule {
714            op: ShapeOp::Reshape,
715            input_shapes: vec![shape(&[2, 6])],
716            params: make_params(&[("ndims", 3), ("dim0", 3), ("dim1", 2), ("dim2", 2)]),
717        };
718        let r = engine.infer(&rule);
719        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![3, 2, 2]));
720    }
721
722    #[test]
723    fn infer_concat_via_rule() {
724        let mut engine = TensorShapeInference::new();
725        let rule = InferenceRule {
726            op: ShapeOp::Concat,
727            input_shapes: vec![shape(&[2, 3]), shape(&[2, 4])],
728            params: make_params(&[("axis", 1)]),
729        };
730        let r = engine.infer(&rule);
731        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![2, 7]));
732    }
733
734    #[test]
735    fn infer_slice_via_rule() {
736        let mut engine = TensorShapeInference::new();
737        let rule = InferenceRule {
738            op: ShapeOp::Slice,
739            input_shapes: vec![shape(&[8, 3])],
740            params: make_params(&[("axis", 0), ("start", 1), ("end", 5)]),
741        };
742        let r = engine.infer(&rule);
743        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![4, 3]));
744    }
745
746    #[test]
747    fn infer_error_tracking() {
748        let mut engine = TensorShapeInference::new();
749        let rule = InferenceRule {
750            op: ShapeOp::Add,
751            input_shapes: vec![shape(&[3]), shape(&[4])],
752            params: HashMap::new(),
753        };
754        assert!(engine.infer(&rule).is_err());
755        assert_eq!(engine.stats().errors, 1);
756        assert_eq!(engine.stats().rules_applied, 0);
757    }
758
759    // ---- chain of operations ----
760
761    #[test]
762    fn chain_matmul_then_transpose() {
763        let mut engine = TensorShapeInference::new();
764        let matmul_rule = InferenceRule {
765            op: ShapeOp::MatMul,
766            input_shapes: vec![shape(&[3, 4]), shape(&[4, 5])],
767            params: HashMap::new(),
768        };
769        let intermediate = engine.infer(&matmul_rule).expect("matmul should succeed");
770        assert_eq!(intermediate.dims, vec![3, 5]);
771
772        let transpose_rule = InferenceRule {
773            op: ShapeOp::Transpose,
774            input_shapes: vec![intermediate],
775            params: HashMap::new(),
776        };
777        let result = engine
778            .infer(&transpose_rule)
779            .expect("transpose should succeed");
780        assert_eq!(result.dims, vec![5, 3]);
781        assert_eq!(engine.stats().rules_applied, 2);
782    }
783
784    #[test]
785    fn chain_concat_then_reshape() {
786        let mut engine = TensorShapeInference::new();
787
788        // Concat two (2,3) along axis 0 -> (4,3)
789        let concat_rule = InferenceRule {
790            op: ShapeOp::Concat,
791            input_shapes: vec![shape(&[2, 3]), shape(&[2, 3])],
792            params: make_params(&[("axis", 0)]),
793        };
794        let after_concat = engine.infer(&concat_rule).expect("concat should succeed");
795        assert_eq!(after_concat.dims, vec![4, 3]);
796
797        // Reshape (4,3) -> (2,6)
798        let reshape_rule = InferenceRule {
799            op: ShapeOp::Reshape,
800            input_shapes: vec![after_concat],
801            params: make_params(&[("ndims", 2), ("dim0", 2), ("dim1", 6)]),
802        };
803        let result = engine.infer(&reshape_rule).expect("reshape should succeed");
804        assert_eq!(result.dims, vec![2, 6]);
805        assert_eq!(engine.stats().rules_applied, 2);
806    }
807
808    #[test]
809    fn infer_broadcast_via_rule() {
810        let mut engine = TensorShapeInference::new();
811        let rule = InferenceRule {
812            op: ShapeOp::Broadcast,
813            input_shapes: vec![shape(&[1, 3])],
814            params: make_params(&[("ndims", 2), ("dim0", 4), ("dim1", 3)]),
815        };
816        let r = engine.infer(&rule);
817        assert_eq!(r.as_ref().map(|s| &s.dims), Ok(&vec![4, 3]));
818    }
819}