Skip to main content

tract_core/ops/array/
broadcast.rs

1use tract_data::itertools::izip;
2
3use crate::broadcast::multi_broadcast;
4use crate::internal::*;
5use crate::ops::binary::TypedBinOp;
6
7#[derive(Debug, Clone, new, Hash, PartialEq, Eq)]
8pub struct MultiBroadcastTo {
9    pub shape: ShapeFact,
10}
11
12impl Op for MultiBroadcastTo {
13    fn name(&self) -> StaticName {
14        "MultiBroadcastTo".into()
15    }
16
17    op_as_typed_op!();
18}
19
20impl EvalOp for MultiBroadcastTo {
21    op_out_of_plan!();
22
23    fn eval(&self, ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
24        let shape = self.shape.eval_to_usize(ctx.symbols)?;
25        Ok(tvec!(inputs[0].broadcast_to_shape(&shape)?.into_tvalue()))
26    }
27}
28
29impl TypedOp for MultiBroadcastTo {
30    fn axes_mapping(
31        &self,
32        inputs: &[&TypedFact],
33        outputs: &[&TypedFact],
34    ) -> TractResult<AxesMapping> {
35        // ONNX-style broadcasting right-aligns input over output, so when
36        // output_rank > input_rank the leading output axes are pure
37        // broadcast axes with no input correspondence. natural_for_rank's
38        // square shape would skip them and trip the optimizer's axes-mapping
39        // check (caught under paranoid_assertions).
40        let in_rank = inputs[0].rank();
41        let out_rank = outputs[0].rank();
42        let leading = out_rank.saturating_sub(in_rank);
43        let mut axes = tvec!();
44        let mut alphabet = 'a'..;
45        for o in 0..leading {
46            axes.push(
47                Axis::new(alphabet.next().unwrap(), inputs.len(), outputs.len()).output(0, o),
48            );
49        }
50        for i in 0..in_rank.min(out_rank) {
51            axes.push(
52                Axis::new(alphabet.next().unwrap(), inputs.len(), outputs.len())
53                    .input(0, i)
54                    .output(0, leading + i),
55            );
56        }
57        AxesMapping::new(inputs.len(), outputs.len(), axes)
58    }
59
60    fn change_axes(
61        &self,
62        model: &TypedModel,
63        node: &TypedNode,
64        _io: InOut,
65        change: &AxisOp,
66    ) -> TractResult<Option<AxisChangeConsequence>> {
67        // The output takes the change: the broadcast absorbs it in the target
68        // shape. The input takes it only when every touched axis has the same
69        // dim in input and target, which is a real correspondence only at
70        // equal ranks (right-aligned broadcasting pairs by index then).
71        let input_shape = &model.outlet_fact(node.inputs[0])?.shape;
72        let canonical = change.canonical();
73        let touched: TVec<usize> = match canonical.as_ref() {
74            AxisOp::Add(ix) | AxisOp::Rm(ix) => tvec![*ix],
75            AxisOp::Move(from, to) => {
76                rule_if!(input_shape.rank() == self.shape.rank());
77                tvec![*from, *to]
78            }
79            _ => return Ok(None),
80        };
81        let passthrough = touched.iter().all(|&ix| {
82            ix < input_shape.rank() && ix < self.shape.rank() && input_shape[ix] == self.shape[ix]
83        });
84        // A Move must propagate, never absorb: transpose-of-broadcast is not
85        // broadcast-into-transposed-target ([1,7] into a transposed [7,7]
86        // target yields the transposed tensor).
87        if matches!(canonical.as_ref(), AxisOp::Move(..)) && !passthrough {
88            return Ok(None);
89        }
90
91        let mut shape = self.shape.clone();
92        if change.change_shape(&mut shape, false).is_ok() {
93            let mut wire_changes: TVec<(InOut, AxisOp)> = tvec![(InOut::Out(0), change.clone())];
94            if passthrough {
95                wire_changes.push((InOut::In(0), change.clone()));
96            } else {
97                // Absorbed changes must not re-pair the input's axes. The
98                // input binds to the right-aligned window [offset, offset+r)
99                // of the target; a change inside the window shifts input axes
100                // onto neighbouring target axes (a silent permutation when
101                // adjacent dims are equal, e.g. [5,1] into [5,5,1] with Rm(2)
102                // absorbed), and a change of the dim-1 axes can leave the
103                // input un-broadcastable into the new target ([5,1] into
104                // [9,5,1] must not become [9,5]).
105                let offset = self.shape.rank() as isize - input_shape.rank() as isize;
106                let k = touched[0] as isize - offset;
107                // the window is right-anchored: any rank change moves it, so
108                // only a change at or before its start (k <= 0 for Add, which
109                // shifts every partner uniformly, k < 0 for Rm, which must not
110                // eat a partner) keeps the pairing. Otherwise the displaced
111                // input axes must all be 1.
112                let displaced_all_ones =
113                    (0..k.max(0) as usize).all(|i| input_shape[i] == 1.to_dim());
114                let window_safe = match canonical.as_ref() {
115                    AxisOp::Add(_) => k <= 0,
116                    _ => k < 0,
117                } || displaced_all_ones;
118                let broadcastable = multi_broadcast(&[input_shape, &shape])
119                    .is_ok_and(|b| b.as_slice() == shape.as_ref());
120                if !(window_safe && broadcastable) {
121                    return Ok(None);
122                }
123            }
124            return Ok(Some(AxisChangeConsequence {
125                wire_changes,
126                substitute_op: Some(Box::new(MultiBroadcastTo { shape })),
127            }));
128        }
129        Ok(None)
130    }
131
132    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
133        ensure!(inputs.len() == 1);
134        let mut fact = inputs[0].datum_type.fact(self.shape.clone());
135        fact.uniform.clone_from(&inputs[0].uniform);
136        fact.uniform_tdim = inputs[0].uniform_tdim.clone();
137        Ok(tvec!(fact))
138    }
139
140    fn input_roi(
141        &self,
142        model: &TypedModel,
143        node: &TypedNode,
144    ) -> TractResult<Option<TVec<Option<TDim>>>> {
145        crate::optim::propagate_roi::bubble_roi(model, node)
146    }
147
148    fn set_symbols(
149        &self,
150        _source: &TypedModel,
151        node: &TypedNode,
152        target: &mut TypedModel,
153        mapping: &HashMap<OutletId, OutletId>,
154        subs: &HashMap<Symbol, TDim>,
155    ) -> TractResult<TVec<OutletId>> {
156        let input = mapping[&node.inputs[0]];
157        let shape: TVec<_> =
158            self.shape.iter().map(|d| d.substitute_all(subs)).collect::<TractResult<_>>()?;
159        let op = Self { shape: shape.into() };
160        target.wire_node(&node.name, op, &[input])
161    }
162
163    fn declutter(
164        &self,
165        model: &TypedModel,
166        node: &TypedNode,
167    ) -> TractResult<Option<TypedModelPatch>> {
168        let input_fact = model.outlet_fact(node.inputs[0])?;
169        if input_fact.shape == self.shape {
170            return TypedModelPatch::shunt_one_op(model, node);
171        }
172        // Swap with an AxisOp successor: `Broadcast(x, S) → AxisOp` becomes
173        // `AxisOp(x) → Broadcast(σ(S))` whenever the AxisOp transforms every
174        // axis the broadcast actually expanded.  Fires per-successor, so this
175        // works under fan-out (the original broadcast stays in place for
176        // siblings; only the matched AxisOp branch is rerouted).
177        for succ in &*node.outputs[0].successors {
178            let succ = model.node(succ.node);
179            let Some(op) = succ.op_as::<AxisOp>() else { continue };
180            // The AxisOp's indices refer to the broadcast output; they are only
181            // meaningful on the input if the broadcast did not add leading axes.
182            if input_fact.rank() != self.shape.rank() {
183                continue;
184            }
185            let mut shape = self.shape.clone();
186            if izip!(0.., &*input_fact.shape, &*self.shape)
187                .filter(|(_, l, r)| l != r)
188                .all(|(axis, _, _)| op.transform_axis(axis).is_some())
189                && op.change_shape(&mut shape, false).is_ok()
190            {
191                let mut patch = TypedModelPatch::default();
192                let mut wire = patch.tap_model(model, node.inputs[0])?;
193                wire = patch.wire_node(&succ.name, op.clone(), &[wire])?[0];
194                wire = patch.wire_node(&node.name, MultiBroadcastTo { shape }, &[wire])?[0];
195                patch.shunt_outside(model, succ.id.into(), wire)?;
196                return Ok(Some(patch));
197            }
198        }
199        if let [succ] = &*node.outputs[0].successors {
200            let succ = model.node(succ.node);
201            if succ.op_is::<TypedBinOp>() {
202                let our_slot = node.outputs[0].successors[0].slot;
203                let other_slot = 1 - our_slot;
204                let other_operand = succ.inputs[other_slot];
205                let other_fact = model.outlet_fact(other_operand)?;
206                let output_fact = model.outlet_fact(succ.id.into())?;
207                if input_fact.rank() == other_fact.rank()
208                    && multi_broadcast(&[&input_fact.shape, &other_fact.shape])
209                        .is_ok_and(|s| *s == *output_fact.shape)
210                {
211                    let mut operands = tvec!(node.inputs[0], other_operand);
212                    if our_slot == 1 {
213                        operands.swap(0, 1);
214                    }
215                    return TypedModelPatch::rewire(
216                        model,
217                        &operands,
218                        &[succ.id.into()],
219                        &|p, inputs| p.wire_node(&succ.name, succ.op.clone(), inputs),
220                    )
221                    .map(Some);
222                }
223            }
224        }
225        Ok(None)
226    }
227
228    as_op!();
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::ops::change_axes::AxisOp;
235    use crate::ops::change_axes::{AxisChange, InOut};
236    use crate::ops::logic::And;
237    use proptest::prelude::*;
238
239    /// `Broadcast → Move` with the broadcast feeding a SINGLE successor.
240    /// Pre-existing path: the swap rewrite kicks in.
241    #[test]
242    fn broadcast_move_single_successor_swaps() -> TractResult<()> {
243        let mut model = TypedModel::default();
244        let t = model.symbols.sym("T");
245        let pad = model.add_source("pad", bool::fact(&[t.to_dim()]))?;
246        let unsq = model.wire_node("unsq", AxisOp::Add(0), &[pad])?[0];
247        let bcast = model.wire_node(
248            "bcast",
249            MultiBroadcastTo { shape: ShapeFact::from_dims([t.to_dim(), t.to_dim()]) },
250            &[unsq],
251        )?[0];
252        let mv = model.wire_node("move", AxisOp::Move(0, 1), &[bcast])?[0];
253        model.select_output_outlets(&[mv])?;
254
255        let model = model.into_decluttered()?;
256
257        let move_count = model
258            .nodes()
259            .iter()
260            .filter(|n| matches!(n.op_as::<AxisOp>(), Some(AxisOp::Move(0, 1))))
261            .count();
262        assert_eq!(move_count, 0, "Move should have been pushed through Broadcast and absorbed");
263        Ok(())
264    }
265
266    /// `Broadcast → {Move, And-direct}` — the encoder-style pad-mask outer-AND
267    /// pattern.  Pre-fix: declutter bailed because broadcast had > 1 successor;
268    /// the Move stayed.  Post-fix: the Move-branch gets its own swapped
269    /// chain, the direct-AND branch still consumes the original broadcast.
270    #[test]
271    fn broadcast_move_fanout_pushes_through_one_branch() -> TractResult<()> {
272        let mut model = TypedModel::default();
273        let t = model.symbols.sym("T");
274        let pad = model.add_source("pad", bool::fact(&[t.to_dim()]))?;
275        let unsq = model.wire_node("unsq", AxisOp::Add(0), &[pad])?[0];
276        let bcast = model.wire_node(
277            "bcast",
278            MultiBroadcastTo { shape: ShapeFact::from_dims([t.to_dim(), t.to_dim()]) },
279            &[unsq],
280        )?[0];
281        let mv = model.wire_node("move", AxisOp::Move(0, 1), &[bcast])?[0];
282        let and = model.wire_node("and", TypedBinOp(Box::new(And), None), &[bcast, mv])?[0];
283        model.select_output_outlets(&[and])?;
284
285        let model = model.into_decluttered()?;
286
287        // Expected: fan-out swap-through fires on the Move branch, then the
288        // existing Broadcast→TypedBinOp rule fires on each (now single-
289        // successor) broadcast, eliminating both — the AND ends up
290        // broadcasting [1, T] and [T, 1] implicitly.
291        let bcast_count = model.nodes().iter().filter(|n| n.op_is::<MultiBroadcastTo>()).count();
292        assert_eq!(
293            bcast_count, 0,
294            "Both broadcasts should be subsumed into AND's implicit broadcasting"
295        );
296
297        let and_node =
298            model.nodes().iter().find(|n| n.op_is::<TypedBinOp>()).expect("AND should survive");
299        assert_eq!(and_node.inputs.len(), 2);
300        let and_input_shapes: Vec<_> = and_node
301            .inputs
302            .iter()
303            .map(|i| model.outlet_fact(*i).unwrap().shape.to_tvec())
304            .collect();
305        let expected_a = tvec![1.to_dim(), t.to_dim()];
306        let expected_b = tvec![t.to_dim(), 1.to_dim()];
307        let (a, b) = (&and_input_shapes[0], &and_input_shapes[1]);
308        assert!(
309            (a == &expected_a && b == &expected_b) || (a == &expected_b && b == &expected_a),
310            "AND should receive [1, T] and [T, 1]; got {a:?} and {b:?}"
311        );
312        Ok(())
313    }
314
315    /// `Broadcast → AxisOp` where the broadcast adds a leading axis (input
316    /// rank < output rank).  The AxisOp's indices refer to the output shape
317    /// and are meaningless on the input; the swap must not fire.  Pre-fix,
318    /// the guard izip truncated to the shorter rank and wiring the AxisOp
319    /// onto the input panicked in AxisOp::change_shape.
320    #[test]
321    fn broadcast_adding_leading_axis_does_not_swap_with_axis_op() -> TractResult<()> {
322        let mut model = TypedModel::default();
323        let src = model.add_source("src", f32::fact([512, 1]))?;
324        let bcast = model.wire_node(
325            "bcast",
326            MultiBroadcastTo {
327                shape: ShapeFact::from_dims([1.to_dim(), 512.to_dim(), 16.to_dim()]),
328            },
329            &[src],
330        )?[0];
331        let unsq = model.wire_node("unsq", AxisOp::Add(3), &[bcast])?[0];
332        model.select_output_outlets(&[unsq])?;
333
334        let model = model.into_decluttered()?;
335        assert_eq!(
336            model.output_fact(0)?.shape.to_tvec(),
337            tvec![1.to_dim(), 512.to_dim(), 16.to_dim(), 1.to_dim()]
338        );
339        Ok(())
340    }
341
342    /// An axis change touching an axis the input does not have must be
343    /// absorbed in the target shape, not propagated to the input wire: a
344    /// rank-1 [1] input cannot express it.
345    #[test]
346    fn broadcast_axis_change_absorbed_not_propagated_to_input() -> TractResult<()> {
347        let mut model = TypedModel::default();
348        let c = model.add_const("c", tensor1(&[0f32]))?;
349        let s = model.symbols.sym("S");
350        let y = model.wire_node(
351            "y",
352            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![s.to_dim(), 1usize.to_dim()])),
353            &[c],
354        )?[0];
355        let node = model.node(y.node);
356        let consequence = node
357            .op
358            .change_axes(&model, node, InOut::Out(0), &AxisOp::Add(2))?
359            .context("expected the broadcast to absorb the change")?;
360        assert_eq!(consequence.wire_changes, tvec![(InOut::Out(0), AxisOp::Add(2))]);
361        Ok(())
362    }
363
364    /// Passthrough axis changes (input and output agree on the axis) still
365    /// reach the input.
366    #[test]
367    fn passthrough_axis_change_propagates_to_input() -> TractResult<()> {
368        let mut model = TypedModel::default();
369        let s = model.symbols.sym("S");
370        let src = model.add_source("src", f32::fact([s.to_dim(), 1usize.into()]))?;
371        let y = model.wire_node(
372            "y",
373            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![s.to_dim(), 1usize.to_dim()])),
374            &[src],
375        )?[0];
376        let node = model.node(y.node);
377        let consequence = node
378            .op
379            .change_axes(&model, node, InOut::Out(0), &AxisOp::Add(1))?
380            .context("expected propagation")?;
381        assert_eq!(
382            consequence.wire_changes,
383            tvec![(InOut::Out(0), AxisOp::Add(1)), (InOut::In(0), AxisOp::Add(1))]
384        );
385        Ok(())
386    }
387
388    /// End-to-end through the ChangeAxes search: an axis change crossing a
389    /// broadcast fed by a volume-1 constant must produce an applicable patch
390    /// without any const rewiring.
391    #[test]
392    fn axis_change_through_broadcast_with_volume_one_const() -> TractResult<()> {
393        let mut model = TypedModel::default();
394        let s = model.symbols.sym("S");
395        let definer = model.add_source("definer", f32::fact([s.to_dim()]))?;
396        let c = model.add_const("c", tensor1(&[0f32]))?;
397        let y = model.wire_node(
398            "y",
399            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![
400                s.to_dim(),
401                1usize.to_dim(),
402                1usize.to_dim()
403            ])),
404            &[c],
405        )?;
406        let out = model.wire_node("out", AxisOp::Add(0), &[y[0]])?;
407        model.select_output_outlets(&[out[0], definer])?;
408        let change = AxisChange { outlet: out[0], op: AxisOp::Add(3) };
409        let mut explored = Default::default();
410        let (patch, _) =
411            crate::optim::change_axes::change_axes(&model, &change, &[], &[], &mut explored)?
412                .context("axis change through a broadcast should apply")?;
413        patch.apply(&mut model)?;
414        model.compact()?;
415        let found = crate::internal::TypedSimplePlan::new(model)?
416            .run(tvec!(tensor1(&[0f32; 2]).into_tvalue()))?;
417        assert_eq!(found[0].shape(), &[1, 2, 1, 1, 1]);
418        Ok(())
419    }
420
421    /// Removing an output axis on which the input is broadcast is absorbed:
422    /// the right-aligned pairing is unaffected.
423    #[test]
424    fn rm_of_broadcast_axis_absorbed() -> TractResult<()> {
425        let mut model = TypedModel::default();
426        let src = model.add_source("src", f32::fact([5usize, 7usize]))?;
427        let y = model.wire_node(
428            "y",
429            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![
430                1usize.to_dim(),
431                5usize.to_dim(),
432                7usize.to_dim()
433            ])),
434            &[src],
435        )?[0];
436        let node = model.node(y.node);
437        let consequence = node
438            .op
439            .change_axes(&model, node, InOut::Out(0), &AxisOp::Rm(0))?
440            .context("expected the broadcast to absorb the removal")?;
441        assert_eq!(consequence.wire_changes, tvec![(InOut::Out(0), AxisOp::Rm(0))]);
442        let substitute = consequence
443            .substitute_op
444            .as_deref()
445            .and_then(|op| op.as_op().downcast_ref::<MultiBroadcastTo>())
446            .context("expected a MultiBroadcastTo substitute")?;
447        assert_eq!(substitute.shape.to_tvec(), tvec![5.to_dim(), 7.to_dim()]);
448        Ok(())
449    }
450
451    /// A Move touching an axis on which the input is broadcast must block:
452    /// transpose-of-broadcast is not broadcast-into-transposed-target.
453    #[test]
454    fn move_over_broadcast_axis_blocked() -> TractResult<()> {
455        let mut model = TypedModel::default();
456        let src = model.add_source("src", f32::fact([5usize, 1usize]))?;
457        let y = model.wire_node(
458            "y",
459            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![5usize.to_dim(), 7usize.to_dim()])),
460            &[src],
461        )?[0];
462        let node = model.node(y.node);
463        let blocked = node
464            .op
465            .change_axes(&model, node, InOut::Out(0), &AxisOp::Move(0, 1))
466            .map(|r| r.is_none())
467            .unwrap_or(false);
468        assert!(blocked, "expected the broadcast to block the move");
469        Ok(())
470    }
471
472    /// A passthrough Move still propagates to the input.
473    #[test]
474    fn passthrough_move_propagates_to_input() -> TractResult<()> {
475        let mut model = TypedModel::default();
476        let src = model.add_source("src", f32::fact([5usize, 7usize]))?;
477        let y = model.wire_node(
478            "y",
479            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![5usize.to_dim(), 7usize.to_dim()])),
480            &[src],
481        )?[0];
482        let node = model.node(y.node);
483        let consequence = node
484            .op
485            .change_axes(&model, node, InOut::Out(0), &AxisOp::Move(0, 1))?
486            .context("expected propagation")?;
487        assert_eq!(
488            consequence.wire_changes,
489            tvec![(InOut::Out(0), AxisOp::Move(0, 1)), (InOut::In(0), AxisOp::Move(0, 1))]
490        );
491        Ok(())
492    }
493
494    /// The ChangeAxes pass proactively proposes removing any dim-1 output
495    /// axis; the absorb must keep the input broadcastable into the new target
496    /// ([5,1] into [9,5,1] must not become [9,5]).
497    #[test]
498    fn absorbed_change_keeps_input_broadcastable() -> TractResult<()> {
499        let mut model = TypedModel::default();
500        let src = model.add_source("src", f32::fact([5usize, 1usize]))?;
501        let y = model.wire_node(
502            "y",
503            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![
504                9usize.to_dim(),
505                5usize.to_dim(),
506                1usize.to_dim()
507            ])),
508            &[src],
509        )?[0];
510        let squeezed = model.wire_node("squeezed", AxisOp::Rm(2), &[y])?[0];
511        model.select_output_outlets(&[squeezed])?;
512        let optimized = model.into_optimized()?;
513        let found = crate::internal::TypedSimplePlan::new(optimized)?
514            .run(tvec!(tensor2(&[[0f32; 1]; 5]).into_tvalue()))?;
515        assert_eq!(found[0].shape(), &[9, 5]);
516        Ok(())
517    }
518
519    /// An absorbed change falling inside the input-paired window would
520    /// re-pair the input's axes onto neighbouring target axes: with adjacent
521    /// equal dims the result still evaluates, with permuted values. The
522    /// optimized model must keep the unoptimized values.
523    #[test]
524    fn absorbed_change_does_not_repair_input_axes() -> TractResult<()> {
525        let mut model = TypedModel::default();
526        let src = model.add_source("src", f32::fact([5usize, 1usize]))?;
527        let y = model.wire_node(
528            "y",
529            MultiBroadcastTo::new(ShapeFact::from_dims(tvec![
530                5usize.to_dim(),
531                5usize.to_dim(),
532                1usize.to_dim()
533            ])),
534            &[src],
535        )?[0];
536        let squeezed = model.wire_node("squeezed", AxisOp::Rm(2), &[y])?[0];
537        model.select_output_outlets(&[squeezed])?;
538        let input: Vec<f32> = (0..5).map(|i| i as f32).collect();
539        let input = Tensor::from_shape(&[5, 1], &input)?.into_tvalue();
540        let raw =
541            crate::internal::TypedSimplePlan::new(model.clone())?.run(tvec!(input.clone()))?;
542        let optimized = model.into_optimized()?;
543        let optimized_values =
544            crate::internal::TypedSimplePlan::new(optimized)?.run(tvec!(input))?;
545        assert_eq!(*raw[0], *optimized_values[0]);
546        Ok(())
547    }
548
549    proptest! {
550        /// The optimizer must preserve what the unoptimized model computes,
551        /// whatever broadcast target and axis change it picks.
552        #[test]
553        fn prop_axis_changes_preserve_values(
554            input in proptest::collection::vec(1usize..4, 1usize..4),
555            leading in proptest::collection::vec(1usize..4, 0usize..3),
556            bumps in proptest::collection::vec(0usize..4, 0usize..6),
557            selector in 0usize..64,
558            ix in 0usize..8,
559            move_to in 0usize..8,
560        ) {
561            let mut bumps = bumps.into_iter();
562            let mut target: TVec<usize> = leading.clone().into();
563            for dim in &input {
564                let dim = if *dim == 1 { bumps.next().unwrap_or(0).max(1) } else { *dim };
565                target.push(dim);
566            }
567            let op = match selector % 3 {
568                0 => AxisOp::Add(ix.min(target.len())),
569                1 => {
570                    if ix < target.len() && target[ix] == 1 {
571                        AxisOp::Rm(ix)
572                    } else {
573                        AxisOp::Add(ix.min(target.len()))
574                    }
575                }
576                _ => {
577                    let a = ix.min(target.len().saturating_sub(1));
578                    let b = (move_to.min(target.len().saturating_sub(1)), a);
579                    let b = if b.0 == a { a.saturating_sub(1) } else { b.0 };
580                    AxisOp::Move(a, b)
581                }
582            };
583            let mut model = TypedModel::default();
584            let src = model.add_source("src", i32::datum_type().fact(&*input)).unwrap();
585            let bcast = model.wire_node(
586                "bcast",
587                MultiBroadcastTo::new(ShapeFact::from_dims(
588                    target.iter().map(|d| d.to_dim()).collect::<TVec<_>>(),
589                )),
590                &[src],
591            ).unwrap()[0];
592            let out = model.wire_node("out", op, &[bcast]).unwrap()[0];
593            model.select_output_outlets(&[out]).unwrap();
594            let volume: usize = input.iter().product();
595            let data: Vec<i32> = (0..volume as i32).collect();
596            let wire = Tensor::from_shape(&input, &data).unwrap().into_tvalue();
597            let raw = crate::internal::TypedSimplePlan::new(model.clone())
598                .unwrap()
599                .run(tvec!(wire.clone()))
600                .unwrap();
601            let optimized = model.into_optimized().unwrap();
602            let optimized_values = crate::internal::TypedSimplePlan::new(optimized)
603                .unwrap()
604                .run(tvec!(wire))
605                .unwrap();
606            prop_assert_eq!(&*raw[0], &*optimized_values[0]);
607        }
608    }
609}