Skip to main content

tenferro_ops/
broadcast.rs

1use tenferro_tensor::{ShapeMismatch, ShapeVec, ValidationError};
2use thiserror::Error;
3
4/// Lowering plan for broadcasting one input to an output shape.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct BroadcastInputPlan {
7    /// Shape to use before `BroadcastInDim`.
8    pub source_shape: Vec<usize>,
9    /// Source axes retained in `source_shape` and their output-axis positions.
10    pub dims: Vec<usize>,
11}
12
13/// Error returned when NumPy-style broadcast planning fails.
14#[derive(Debug, Clone, PartialEq, Eq, Error)]
15pub enum BroadcastError {
16    /// Two shapes cannot be broadcast together.
17    #[error("cannot broadcast shapes {lhs:?} and {rhs:?}")]
18    IncompatibleBinary { lhs: Vec<usize>, rhs: Vec<usize> },
19    /// One input cannot be broadcast to the requested output shape.
20    #[error("cannot broadcast shape {input:?} to {output:?}")]
21    IncompatibleInput {
22        input: Vec<usize>,
23        output: Vec<usize>,
24    },
25    /// A higher-rank input cannot broadcast to a lower-rank output.
26    #[error("cannot broadcast higher-rank shape {input:?} to {output:?}")]
27    RankTooLarge {
28        input: Vec<usize>,
29        output: Vec<usize>,
30    },
31}
32
33/// Convert a broadcast-planning failure to the shared validation vocabulary.
34///
35/// Every eager, traced, and direct broadcast surface uses this mapping so the
36/// same invalid shapes have the same machine-readable payload regardless of
37/// where the check is discovered.
38///
39/// # Examples
40///
41/// ```
42/// use tenferro_ops::broadcast::{broadcast_error_to_validation, BroadcastError};
43/// use tenferro_tensor::{ShapeMismatch, ValidationError};
44///
45/// let error = broadcast_error_to_validation(BroadcastError::IncompatibleInput {
46///     input: vec![2, 3],
47///     output: vec![2, 4],
48/// });
49/// assert!(matches!(
50///     error,
51///     ValidationError::ShapeMismatch(source)
52///         if matches!(source.as_ref(), ShapeMismatch::ExpectedActual { expected, actual }
53///             if expected.as_slice() == [2, 4] && actual.as_slice() == [2, 3])
54/// ));
55/// ```
56pub fn broadcast_error_to_validation(error: BroadcastError) -> ValidationError {
57    match error {
58        BroadcastError::IncompatibleBinary { lhs, rhs } => ShapeMismatch::IncompatibleShapes {
59            lhs: lhs.into_iter().collect(),
60            rhs: rhs.into_iter().collect(),
61        }
62        .into(),
63        BroadcastError::IncompatibleInput { input, output } => ShapeMismatch::ExpectedActual {
64            expected: output.into_iter().collect(),
65            actual: input.into_iter().collect(),
66        }
67        .into(),
68        BroadcastError::RankTooLarge { input, output } => ValidationError::RankMismatch {
69            expected: output.len(),
70            actual: input.len(),
71        },
72    }
73}
74
75/// Find a broadcast extent failure for an already structurally validated
76/// `BroadcastInDim` mapping.
77///
78/// Structural mapping failures (wrong dimension count, an out-of-range axis,
79/// or a duplicate axis) remain the caller's axis/rank validation errors. This
80/// helper owns only the shared rank/extent cases represented by
81/// [`BroadcastError`], so eager and traced callers can feed the result through
82/// [`broadcast_error_to_validation`].
83///
84/// # Examples
85///
86/// ```
87/// use tenferro_ops::broadcast::{broadcast_in_dim_extent_error, BroadcastError};
88///
89/// let error = broadcast_in_dim_extent_error(&[2, 3], &[2, 4], &[0, 1]);
90/// assert!(matches!(error, Some(BroadcastError::IncompatibleInput { .. })));
91/// ```
92pub fn broadcast_in_dim_extent_error(
93    input: &[usize],
94    output: &[usize],
95    dims: &[usize],
96) -> Option<BroadcastError> {
97    if input.len() > output.len() {
98        return Some(BroadcastError::RankTooLarge {
99            input: input.to_vec(),
100            output: output.to_vec(),
101        });
102    }
103    if dims.len() != input.len()
104        || dims.iter().any(|&axis| axis >= output.len())
105        || dims
106            .iter()
107            .enumerate()
108            .any(|(index, &axis)| dims[..index].contains(&axis))
109    {
110        return None;
111    }
112    input
113        .iter()
114        .zip(dims)
115        .find_map(|(&input_dim, &output_axis)| {
116            let output_dim = output[output_axis];
117            (input_dim != output_dim && input_dim != 1).then(|| BroadcastError::IncompatibleInput {
118                input: input.to_vec(),
119                output: output.to_vec(),
120            })
121        })
122}
123
124/// Compute the NumPy-style broadcast shape for two concrete shapes.
125///
126/// # Examples
127///
128/// ```
129/// use tenferro_ops::broadcast::broadcast_shape;
130///
131/// assert_eq!(broadcast_shape(&[3, 1], &[1, 4]).unwrap().as_slice(), &[3, 4]);
132/// assert_eq!(broadcast_shape(&[], &[2, 3]).unwrap().as_slice(), &[2, 3]);
133/// ```
134///
135/// # Errors
136///
137/// Returns [`BroadcastError::IncompatibleBinary`] when a pair of aligned
138/// dimensions is incompatible.
139pub fn broadcast_shape(lhs: &[usize], rhs: &[usize]) -> Result<ShapeVec, BroadcastError> {
140    let rank = lhs.len().max(rhs.len());
141    // Inline for common ranks: the eager binary entry calls this on every op.
142    let mut out = ShapeVec::with_capacity(rank);
143    for axis in 0..rank {
144        let lhs_dim = aligned_dim(lhs, rank, axis);
145        let rhs_dim = aligned_dim(rhs, rank, axis);
146        if lhs_dim == rhs_dim {
147            out.push(lhs_dim);
148        } else if lhs_dim == 1 {
149            out.push(rhs_dim);
150        } else if rhs_dim == 1 {
151            out.push(lhs_dim);
152        } else {
153            return Err(BroadcastError::IncompatibleBinary {
154                lhs: lhs.to_vec(),
155                rhs: rhs.to_vec(),
156            });
157        }
158    }
159    Ok(out)
160}
161
162/// Compute the common NumPy-style broadcast shape for zero or more shapes.
163///
164/// # Examples
165///
166/// ```
167/// use tenferro_ops::broadcast::broadcast_shapes;
168///
169/// let shape = broadcast_shapes([&[3, 1][..], &[1, 4][..], &[3, 4][..]]).unwrap();
170/// assert_eq!(shape.as_slice(), &[3, 4]);
171/// ```
172///
173/// # Errors
174///
175/// Returns [`BroadcastError::IncompatibleBinary`] when any pair of input
176/// shapes cannot be broadcast together.
177pub fn broadcast_shapes<'a>(
178    shapes: impl IntoIterator<Item = &'a [usize]>,
179) -> Result<ShapeVec, BroadcastError> {
180    let mut iter = shapes.into_iter();
181    let Some(first) = iter.next() else {
182        return Ok(ShapeVec::new());
183    };
184    let mut out = ShapeVec::from(first);
185    for shape in iter {
186        out = broadcast_shape(&out, shape)?;
187    }
188    Ok(out)
189}
190
191/// Plan how one input should lower to `BroadcastInDim`.
192///
193/// Expanding singleton axes are omitted from `source_shape` so downstream VJP
194/// rules reduce those axes explicitly.
195///
196/// # Examples
197///
198/// ```
199/// use tenferro_ops::broadcast::broadcast_input_plan;
200///
201/// let plan = broadcast_input_plan(&[3, 1], &[3, 4]).unwrap();
202/// assert_eq!(plan.source_shape, vec![3]);
203/// assert_eq!(plan.dims, vec![0]);
204/// ```
205///
206/// # Errors
207///
208/// Returns [`BroadcastError::RankTooLarge`] when `input` has higher rank than
209/// `output`, or [`BroadcastError::IncompatibleInput`] for incompatible axes.
210pub fn broadcast_input_plan(
211    input: &[usize],
212    output: &[usize],
213) -> Result<BroadcastInputPlan, BroadcastError> {
214    if input.len() > output.len() {
215        return Err(BroadcastError::RankTooLarge {
216            input: input.to_vec(),
217            output: output.to_vec(),
218        });
219    }
220    let rank_diff = output.len() - input.len();
221    let mut source_shape = Vec::with_capacity(input.len());
222    let mut dims = Vec::with_capacity(input.len());
223    for (src_axis, &src_dim) in input.iter().enumerate() {
224        let dst_axis = src_axis + rank_diff;
225        let dst_dim = output[dst_axis];
226        if src_dim != dst_dim && src_dim != 1 {
227            return Err(BroadcastError::IncompatibleInput {
228                input: input.to_vec(),
229                output: output.to_vec(),
230            });
231        }
232        if src_dim == 1 && dst_dim != 1 {
233            continue;
234        }
235        source_shape.push(src_dim);
236        dims.push(dst_axis);
237    }
238    Ok(BroadcastInputPlan { source_shape, dims })
239}
240
241fn aligned_dim(shape: &[usize], output_rank: usize, output_axis: usize) -> usize {
242    if output_axis < output_rank - shape.len() {
243        1
244    } else {
245        shape[output_axis - (output_rank - shape.len())]
246    }
247}