Skip to main content

kopitiam_core/
shape.rs

1use std::fmt;
2
3use crate::Error;
4
5/// The dimensions of a tensor, outermost first.
6///
7/// A rank-0 shape (no dimensions) is a scalar and has exactly one element —
8/// not zero. That is the convention every tensor library converges on, and
9/// getting it wrong makes reductions (which produce scalars) special-cased
10/// everywhere.
11///
12/// `Shape` owns its dimensions in a `Vec`. Inference shapes are small (rank
13/// 4 at most, in practice) and created once per tensor, not per element, so
14/// the allocation is not on any hot path — and a fixed-size inline array
15/// would impose an arbitrary maximum rank for no measurable gain.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
17pub struct Shape(Vec<usize>);
18
19impl Shape {
20    pub fn new(dims: impl Into<Vec<usize>>) -> Self {
21        Self(dims.into())
22    }
23
24    /// The scalar shape: rank 0, one element.
25    pub fn scalar() -> Self {
26        Self(Vec::new())
27    }
28
29    pub fn dims(&self) -> &[usize] {
30        &self.0
31    }
32
33    /// Number of dimensions. Zero for a scalar.
34    pub fn rank(&self) -> usize {
35        self.0.len()
36    }
37
38    /// Total number of elements: the product of all dimensions.
39    ///
40    /// An empty product is 1, so a scalar correctly reports one element. A
41    /// shape containing a zero dimension correctly reports zero.
42    pub fn elem_count(&self) -> usize {
43        self.0.iter().product()
44    }
45
46    /// Row-major (C-order) strides, in elements, for this shape.
47    ///
48    /// Row-major means the last dimension is contiguous — walking it steps
49    /// one element at a time. This is the layout GGUF and SafeTensors both
50    /// store weights in, so choosing it as the default avoids transposing
51    /// every tensor at load time.
52    pub fn strides(&self) -> Vec<usize> {
53        let mut strides = vec![1; self.rank()];
54        for i in (0..self.rank().saturating_sub(1)).rev() {
55            strides[i] = strides[i + 1] * self.0[i + 1];
56        }
57        strides
58    }
59
60    /// Reinterprets this shape as `dims`, which must describe the same number
61    /// of elements.
62    ///
63    /// This is the shape-level half of a reshape; it says nothing about
64    /// whether the underlying storage is contiguous enough to allow the
65    /// reinterpretation without copying. That check belongs to the tensor.
66    pub fn reshape(&self, dims: impl Into<Vec<usize>>) -> Result<Self, Error> {
67        let candidate = Self::new(dims);
68        if candidate.elem_count() != self.elem_count() {
69            return Err(Error::ShapeMismatch {
70                expected: self.clone(),
71                actual: candidate,
72            });
73        }
74        Ok(candidate)
75    }
76
77    /// The shape resulting from broadcasting `self` against `other`, or an
78    /// error if the two are not broadcast-compatible.
79    ///
80    /// Follows the standard NumPy rule: align shapes from the right; two
81    /// dimensions are compatible when they are equal or one of them is 1;
82    /// the result takes the larger of each pair. Missing leading dimensions
83    /// on the shorter shape are treated as 1.
84    pub fn broadcast(&self, other: &Shape) -> Result<Shape, Error> {
85        let rank = self.rank().max(other.rank());
86        let mut dims = vec![0usize; rank];
87
88        for i in 0..rank {
89            // Align from the right: index `i` from the end of each shape.
90            let a = self.dim_from_end(i).unwrap_or(1);
91            let b = other.dim_from_end(i).unwrap_or(1);
92
93            dims[rank - 1 - i] = match (a, b) {
94                (a, b) if a == b => a,
95                (1, b) => b,
96                (a, 1) => a,
97                _ => {
98                    return Err(Error::NotBroadcastable {
99                        left: self.clone(),
100                        right: other.clone(),
101                    });
102                }
103            };
104        }
105
106        Ok(Shape::new(dims))
107    }
108
109    /// The `i`th dimension counting from the last (0 = last dimension).
110    fn dim_from_end(&self, i: usize) -> Option<usize> {
111        (i < self.rank()).then(|| self.0[self.rank() - 1 - i])
112    }
113}
114
115impl From<Vec<usize>> for Shape {
116    fn from(dims: Vec<usize>) -> Self {
117        Self(dims)
118    }
119}
120
121impl<const N: usize> From<[usize; N]> for Shape {
122    fn from(dims: [usize; N]) -> Self {
123        Self(dims.to_vec())
124    }
125}
126
127impl From<&[usize]> for Shape {
128    fn from(dims: &[usize]) -> Self {
129        Self(dims.to_vec())
130    }
131}
132
133impl fmt::Display for Shape {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str("[")?;
136        for (i, dim) in self.0.iter().enumerate() {
137            if i > 0 {
138                f.write_str(", ")?;
139            }
140            write!(f, "{dim}")?;
141        }
142        f.write_str("]")
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn a_scalar_has_rank_zero_and_exactly_one_element() {
152        let s = Shape::scalar();
153        assert_eq!(s.rank(), 0);
154        assert_eq!(s.elem_count(), 1);
155    }
156
157    #[test]
158    fn elem_count_is_the_product_of_dims() {
159        assert_eq!(Shape::from([2, 3, 4]).elem_count(), 24);
160        assert_eq!(Shape::from([5]).elem_count(), 5);
161    }
162
163    #[test]
164    fn a_zero_dimension_means_zero_elements() {
165        assert_eq!(Shape::from([3, 0, 4]).elem_count(), 0);
166    }
167
168    #[test]
169    fn strides_are_row_major_with_the_last_dim_contiguous() {
170        assert_eq!(Shape::from([2, 3, 4]).strides(), vec![12, 4, 1]);
171        assert_eq!(Shape::from([5]).strides(), vec![1]);
172        assert!(Shape::scalar().strides().is_empty());
173    }
174
175    #[test]
176    fn reshape_preserves_element_count_or_errors() {
177        let s = Shape::from([2, 6]);
178        assert_eq!(s.reshape([3, 4]).unwrap(), Shape::from([3, 4]));
179        assert_eq!(s.reshape([12]).unwrap(), Shape::from([12]));
180        assert!(s.reshape([5, 5]).is_err());
181    }
182
183    #[test]
184    fn broadcast_follows_the_numpy_right_aligned_rule() {
185        // Equal ranks, a 1 that stretches.
186        assert_eq!(
187            Shape::from([3, 1]).broadcast(&Shape::from([3, 4])).unwrap(),
188            Shape::from([3, 4])
189        );
190        // Different ranks: missing leading dims are treated as 1.
191        assert_eq!(
192            Shape::from([4]).broadcast(&Shape::from([3, 4])).unwrap(),
193            Shape::from([3, 4])
194        );
195        // Both stretch, in different dimensions.
196        assert_eq!(
197            Shape::from([3, 1]).broadcast(&Shape::from([1, 4])).unwrap(),
198            Shape::from([3, 4])
199        );
200    }
201
202    #[test]
203    fn broadcast_rejects_incompatible_dims() {
204        assert!(Shape::from([3, 2]).broadcast(&Shape::from([3, 4])).is_err());
205    }
206
207    #[test]
208    fn broadcasting_against_a_scalar_is_the_identity() {
209        let s = Shape::from([2, 3]);
210        assert_eq!(s.broadcast(&Shape::scalar()).unwrap(), s);
211        assert_eq!(Shape::scalar().broadcast(&s).unwrap(), s);
212    }
213}