Skip to main content

teeny_core/graph/
mod.rs

1/*
2 * Copyright (c) 2026 Teenygrad.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *   http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use alloc::{collections::BTreeMap, rc::Rc, string::String, sync::Arc, vec, vec::Vec};
18use core::{any::Any, cell::RefCell};
19
20use crate::{
21    dtype::{Dtype, Float, RankedTensor, Tensor},
22    nn::{
23        Layer,
24        activation::{
25            elu::{Celu, Elu, Selu},
26            gelu::{Gelu, Mish},
27            hard::{Hardshrink, Hardsigmoid, Hardswish, Hardtanh, Relu6},
28            misc::{LeakyRelu, Softplus, Softshrink, Softsign, Threshold},
29            relu::Relu,
30            sigmoid::{Logsigmoid, Sigmoid, Silu},
31            softmax::Softmax,
32            tanh::{Tanh, Tanhshrink},
33        },
34        batchnorm::{BatchNorm1d, BatchNorm2d, BatchNorm3d},
35        conv1d::Conv1d,
36        conv2d::Conv2d,
37        conv3d::Conv3d,
38        flatten::Flatten,
39        groupnorm::GroupNorm,
40        instancenorm::{InstanceNorm1d, InstanceNorm2d, InstanceNorm3d},
41        layernorm::LayerNorm,
42        linear::Linear,
43        pad::{
44            CircularPad1d, CircularPad2d, CircularPad3d, ConstantPad1d, ConstantPad2d,
45            ConstantPad3d, ReflectionPad1d, ReflectionPad2d, ReflectionPad3d, ReplicationPad1d,
46            ReplicationPad2d, ReplicationPad3d,
47        },
48        pool::{
49            AvgPool1d, AvgPool2d, AvgPool3d, LpPool1d, LpPool2d, LpPool3d, MaxPool1d, MaxPool2d,
50            MaxPool3d,
51        },
52        rmsnorm::RmsNorm,
53    },
54};
55
56/// Graph-to-FXGraph lowering.
57pub mod compiler;
58
59// ---------------------------------------------------------------------------
60// Shape — dynamic tensor shape used throughout the graph IR
61// ---------------------------------------------------------------------------
62
63/// A dynamic shape vector. Each element is either a known size (`Some(n)`) or a
64/// dynamic/unknown dimension (`None`), e.g. a batch axis whose size is determined
65/// at runtime.
66pub type Shape = Vec<Option<usize>>;
67
68// ---------------------------------------------------------------------------
69// Runtime dtype tag — used in the graph since D is erased at the node level
70// ---------------------------------------------------------------------------
71
72/// Runtime dtype tag: the graph-level (type-erased) representation of a tensor's dtype,
73/// mirroring `dtype::Dtype`'s implementors.
74#[derive(Copy, Clone, Debug, PartialEq, Eq)]
75pub enum DtypeRepr {
76    /// `bool`.
77    Bool,
78    /// Signed 8-bit integer.
79    I8,
80    /// Signed 16-bit integer.
81    I16,
82    /// Signed 32-bit integer.
83    I32,
84    /// Signed 64-bit integer.
85    I64,
86    /// Unsigned 8-bit integer.
87    U8,
88    /// Unsigned 16-bit integer.
89    U16,
90    /// Unsigned 32-bit integer.
91    U32,
92    /// Unsigned 64-bit integer.
93    U64,
94    /// 16-bit float.
95    F16,
96    /// `bfloat16`.
97    BF16,
98    /// 32-bit float.
99    F32,
100    /// 64-bit float.
101    F64,
102}
103
104// ---------------------------------------------------------------------------
105// Graph IR
106// ---------------------------------------------------------------------------
107
108/// Trait implemented by user-defined ops.
109pub trait CustomOp: Any + Send + Sync {
110    /// Identifier used in error messages and debug output.
111    fn name(&self) -> &str;
112
113    /// Compute the output shape given the shapes of all input tensors in order.
114    fn infer_output_shape(&self, input_shapes: &[&Shape]) -> Shape;
115
116    /// Expose `self` as `&dyn Any` so the custom lowering can downcast to the
117    /// concrete op type.  Implement as `fn as_any(&self) -> &dyn Any { self }`.
118    fn as_any(&self) -> &dyn Any;
119
120    /// Return kernel lowering info so `TritonLowering` can compile this op
121    /// without a project-specific middleware.  Return `None` to keep the
122    /// existing middleware / error behaviour.
123    ///
124    /// Tuple layout: `(name, kernel_source, entry_point_name, runtime_op)`.
125    /// `entry_point_name` is the PTX symbol name, conventionally `"{name}_entry_point"`.
126    fn lower(&self) -> Option<(String, String, String, Arc<dyn crate::model::RuntimeOp>)> {
127        None
128    }
129
130    /// Return the backward kernel source for this op (used in training mode).
131    /// Return an empty string if this op has no backward pass.
132    fn lower_backward_source(&self) -> String {
133        String::new()
134    }
135}
136
137/// Wrapper around `Arc<dyn CustomOp>` that implements `Debug` for [`Op`].
138#[derive(Clone)]
139pub struct CustomData(pub Arc<dyn CustomOp>);
140
141impl CustomData {
142    /// Wraps a [`CustomOp`] implementation.
143    pub fn new<T: CustomOp>(op: T) -> Self {
144        Self(Arc::new(op))
145    }
146
147    /// The wrapped op's name.
148    pub fn name(&self) -> &str {
149        self.0.name()
150    }
151
152    /// The wrapped op's inferred output shape.
153    pub fn infer_output_shape(&self, input_shapes: &[&Shape]) -> Shape {
154        self.0.infer_output_shape(input_shapes)
155    }
156
157    /// Downcast to a concrete op type via [`CustomOp::as_any`].
158    pub fn downcast_ref<T: Any>(&self) -> Option<&T> {
159        self.0.as_any().downcast_ref::<T>()
160    }
161}
162
163impl core::fmt::Debug for CustomData {
164    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
165        write!(f, "Custom({})", self.0.name())
166    }
167}
168
169/// A single computational-graph operation. Each variant corresponds to one `nn` layer or
170/// primitive op; variant fields are that op's configuration (mirroring the corresponding
171/// `nn::*` layer struct's fields).
172#[derive(Debug, Clone)]
173pub enum Op {
174    /// Model input placeholder.
175    Input,
176
177    // --- Linear / MLP ---
178    /// Fully-connected layer (see `nn::linear::Linear`).
179    Linear {
180        /// Size of the last input dimension.
181        in_features: usize,
182        /// Size of the last output dimension.
183        out_features: usize,
184        /// Whether a learned bias is added.
185        has_bias: bool,
186    },
187    /// Flattens all spatial dimensions into a single feature vector (see `nn::flatten::Flatten`).
188    Flatten,
189
190    // --- Normalisation ---
191    /// 1-D batch normalization (see `nn::batchnorm::BatchNorm1d`).
192    BatchNorm1d {
193        /// Number of channels/features.
194        num_features: usize,
195        /// Numerical stability constant.
196        eps: f64,
197        /// Running-stats exponential moving average weight.
198        momentum: f64,
199        /// Whether to learn per-channel scale/shift parameters.
200        affine: bool,
201        /// Whether to maintain running mean/variance across batches.
202        track_running_stats: bool,
203    },
204    /// 2-D batch normalization (see `nn::batchnorm::BatchNorm2d`).
205    BatchNorm2d {
206        /// Number of channels/features.
207        num_features: usize,
208        /// Numerical stability constant.
209        eps: f64,
210        /// Running-stats exponential moving average weight.
211        momentum: f64,
212        /// Whether to learn per-channel scale/shift parameters.
213        affine: bool,
214        /// Whether to maintain running mean/variance across batches.
215        track_running_stats: bool,
216    },
217    /// 3-D batch normalization (see `nn::batchnorm::BatchNorm3d`).
218    BatchNorm3d {
219        /// Number of channels/features.
220        num_features: usize,
221        /// Numerical stability constant.
222        eps: f64,
223        /// Running-stats exponential moving average weight.
224        momentum: f64,
225        /// Whether to learn per-channel scale/shift parameters.
226        affine: bool,
227        /// Whether to maintain running mean/variance across batches.
228        track_running_stats: bool,
229    },
230    /// Layer normalization (see `nn::layernorm::LayerNorm`).
231    LayerNorm {
232        /// Shape of the trailing axes to normalize over.
233        normalized_shape: alloc::vec::Vec<usize>,
234        /// Numerical stability constant.
235        eps: f64,
236        /// Whether to learn per-channel scale/shift parameters.
237        affine: bool,
238    },
239    /// RMS normalization (see `nn::rmsnorm::RmsNorm`).
240    RmsNorm {
241        /// Shape of the trailing axes to normalize over.
242        normalized_shape: alloc::vec::Vec<usize>,
243        /// Numerical stability constant.
244        eps: f64,
245        /// Whether to learn per-channel scale/shift parameters.
246        affine: bool,
247    },
248    /// Group normalization (see `nn::groupnorm::GroupNorm`).
249    GroupNorm {
250        /// Number of groups.
251        num_groups: usize,
252        /// Number of channels.
253        num_channels: usize,
254        /// Numerical stability constant.
255        eps: f64,
256        /// Whether to learn per-channel scale/shift parameters.
257        affine: bool,
258    },
259    /// 1-D instance normalization (see `nn::instancenorm::InstanceNorm1d`).
260    InstanceNorm1d {
261        /// Number of channels/features.
262        num_features: usize,
263        /// Numerical stability constant.
264        eps: f64,
265        /// Running-stats exponential moving average weight.
266        momentum: f64,
267        /// Whether to learn per-channel scale/shift parameters.
268        affine: bool,
269        /// Whether to maintain running mean/variance across batches.
270        track_running_stats: bool,
271    },
272    /// 2-D instance normalization (see `nn::instancenorm::InstanceNorm2d`).
273    InstanceNorm2d {
274        /// Number of channels/features.
275        num_features: usize,
276        /// Numerical stability constant.
277        eps: f64,
278        /// Running-stats exponential moving average weight.
279        momentum: f64,
280        /// Whether to learn per-channel scale/shift parameters.
281        affine: bool,
282        /// Whether to maintain running mean/variance across batches.
283        track_running_stats: bool,
284    },
285    /// 3-D instance normalization (see `nn::instancenorm::InstanceNorm3d`).
286    InstanceNorm3d {
287        /// Number of channels/features.
288        num_features: usize,
289        /// Numerical stability constant.
290        eps: f64,
291        /// Running-stats exponential moving average weight.
292        momentum: f64,
293        /// Whether to learn per-channel scale/shift parameters.
294        affine: bool,
295        /// Whether to maintain running mean/variance across batches.
296        track_running_stats: bool,
297    },
298
299    // --- Convolution ---
300    /// 1-D convolution (see `nn::conv1d::Conv1d`).
301    Conv1d {
302        /// Number of input channels.
303        in_channels: usize,
304        /// Number of output channels.
305        out_channels: usize,
306        /// Convolution/pooling kernel length.
307        kernel_l: usize,
308        /// Stride between kernel applications.
309        stride: usize,
310        /// Zero-padding applied to the input.
311        padding: usize,
312        /// Whether a learned bias is added.
313        has_bias: bool,
314    },
315    /// 2-D convolution (see `nn::conv2d::Conv2d`).
316    Conv2d {
317        /// Number of input channels.
318        in_channels: usize,
319        /// Number of output channels.
320        out_channels: usize,
321        /// Convolution/pooling kernel height.
322        kernel_h: usize,
323        /// Convolution/pooling kernel width.
324        kernel_w: usize,
325        /// Vertical stride.
326        stride_h: usize,
327        /// Horizontal stride.
328        stride_w: usize,
329        /// Vertical zero-padding.
330        padding_h: usize,
331        /// Horizontal zero-padding.
332        padding_w: usize,
333        /// Number of blocked/grouped connections (1 = standard).
334        groups: usize,
335        /// Whether a learned bias is added.
336        has_bias: bool,
337    },
338    /// 3-D convolution (see `nn::conv3d::Conv3d`).
339    Conv3d {
340        /// Number of input channels.
341        in_channels: usize,
342        /// Number of output channels.
343        out_channels: usize,
344        /// Convolution/pooling kernel depth.
345        kernel_d: usize,
346        /// Convolution/pooling kernel height.
347        kernel_h: usize,
348        /// Convolution/pooling kernel width.
349        kernel_w: usize,
350        /// Stride along the depth dimension.
351        stride_d: usize,
352        /// Vertical stride.
353        stride_h: usize,
354        /// Horizontal stride.
355        stride_w: usize,
356        /// Zero-padding along the depth dimension.
357        padding_d: usize,
358        /// Vertical zero-padding.
359        padding_h: usize,
360        /// Horizontal zero-padding.
361        padding_w: usize,
362        /// Whether a learned bias is added.
363        has_bias: bool,
364    },
365
366    /// Fused Conv2d + BatchNorm2d (inference-only) + SiLU forward.
367    ///
368    /// The BN parameters (scale, shift) are stored as precomputed affine
369    /// constants — not raw mean/var/gamma/beta.  Produced by `Graph::optimise()`
370    /// when it detects the pattern `Conv2d(no bias) → BatchNorm2d → Silu`.
371    ///
372    /// `bn_eps` is carried forward only for reference; the BN affine constants
373    /// are passed at runtime via the `bn_scale` and `bn_shift` parameters.
374    Conv2dBnSilu {
375        /// Number of input channels.
376        in_channels: usize,
377        /// Number of output channels.
378        out_channels: usize,
379        /// Convolution/pooling kernel height.
380        kernel_h: usize,
381        /// Convolution/pooling kernel width.
382        kernel_w: usize,
383        /// Vertical stride.
384        stride_h: usize,
385        /// Horizontal stride.
386        stride_w: usize,
387        /// Vertical zero-padding.
388        padding_h: usize,
389        /// Horizontal zero-padding.
390        padding_w: usize,
391        /// Number of blocked/grouped connections (1 = standard).
392        groups: usize,
393        /// The fused BatchNorm's numerical stability constant (kept for reference only; the
394        /// affine constants are passed at runtime via `bn_scale`/`bn_shift`).
395        bn_eps: f64,
396    },
397
398    /// A run of adjacent, single-input/single-output elementwise ops fused into one
399    /// kernel launch. Produced by `Graph::optimise()` when it finds a chain of nodes
400    /// whose ops are all eligible per [`is_fusable_elementwise`] and whose interior
401    /// nodes each have exactly one consumer (the next member in the chain).
402    ///
403    /// `members` is the chain in execution order; the fused node's single input feeds
404    /// `members[0]`, and each subsequent member consumes the previous member's output.
405    /// Lowering concatenates each member's kernel source into one compilation unit and
406    /// synthesizes a new entry point that calls them in sequence — see
407    /// `teeny-kernels/src/graph/mod.rs`'s `Op::Fused` lowering.
408    Fused {
409        /// The chain's ops, in execution order.
410        members: alloc::vec::Vec<Op>,
411    },
412
413    // --- Pooling ---
414    /// 1-D average pooling (see `nn::pool::AvgPool1d`).
415    AvgPool1d {
416        /// Convolution/pooling kernel length.
417        kernel_l: usize,
418        /// Stride between kernel applications.
419        stride: usize,
420    },
421    /// 2-D average pooling (see `nn::pool::AvgPool2d`).
422    AvgPool2d {
423        /// Convolution/pooling kernel height.
424        kernel_h: usize,
425        /// Convolution/pooling kernel width.
426        kernel_w: usize,
427        /// Vertical stride.
428        stride_h: usize,
429        /// Horizontal stride.
430        stride_w: usize,
431    },
432    /// 3-D average pooling (see `nn::pool::AvgPool3d`).
433    AvgPool3d {
434        /// Convolution/pooling kernel depth.
435        kernel_d: usize,
436        /// Convolution/pooling kernel height.
437        kernel_h: usize,
438        /// Convolution/pooling kernel width.
439        kernel_w: usize,
440        /// Stride along the depth dimension.
441        stride_d: usize,
442        /// Vertical stride.
443        stride_h: usize,
444        /// Horizontal stride.
445        stride_w: usize,
446    },
447    /// 1-D max pooling (see `nn::pool::MaxPool1d`).
448    MaxPool1d {
449        /// Convolution/pooling kernel length.
450        kernel_l: usize,
451        /// Stride between kernel applications.
452        stride: usize,
453    },
454    /// 2-D max pooling (see `nn::pool::MaxPool2d`).
455    MaxPool2d {
456        /// Convolution/pooling kernel height.
457        kernel_h: usize,
458        /// Convolution/pooling kernel width.
459        kernel_w: usize,
460        /// Vertical stride.
461        stride_h: usize,
462        /// Horizontal stride.
463        stride_w: usize,
464        /// Vertical padding.
465        pad_h: usize,
466        /// Horizontal padding.
467        pad_w: usize,
468    },
469    /// 3-D max pooling (see `nn::pool::MaxPool3d`).
470    MaxPool3d {
471        /// Convolution/pooling kernel depth.
472        kernel_d: usize,
473        /// Convolution/pooling kernel height.
474        kernel_h: usize,
475        /// Convolution/pooling kernel width.
476        kernel_w: usize,
477        /// Stride along the depth dimension.
478        stride_d: usize,
479        /// Vertical stride.
480        stride_h: usize,
481        /// Horizontal stride.
482        stride_w: usize,
483    },
484    /// 1-D power-average (Lp) pooling (see `nn::pool::LpPool1d`).
485    LpPool1d {
486        /// Convolution/pooling kernel length.
487        kernel_l: usize,
488        /// Stride between kernel applications.
489        stride: usize,
490        /// The `p` in the p-norm.
491        p: f64,
492    },
493    /// 2-D power-average (Lp) pooling (see `nn::pool::LpPool2d`).
494    LpPool2d {
495        /// Convolution/pooling kernel height.
496        kernel_h: usize,
497        /// Convolution/pooling kernel width.
498        kernel_w: usize,
499        /// Vertical stride.
500        stride_h: usize,
501        /// Horizontal stride.
502        stride_w: usize,
503        /// The `p` in the p-norm.
504        p: f64,
505    },
506    /// 3-D power-average (Lp) pooling (see `nn::pool::LpPool3d`).
507    LpPool3d {
508        /// Convolution/pooling kernel depth.
509        kernel_d: usize,
510        /// Convolution/pooling kernel height.
511        kernel_h: usize,
512        /// Convolution/pooling kernel width.
513        kernel_w: usize,
514        /// Stride along the depth dimension.
515        stride_d: usize,
516        /// Vertical stride.
517        stride_h: usize,
518        /// Horizontal stride.
519        stride_w: usize,
520        /// The `p` in the p-norm.
521        p: f64,
522    },
523
524    // --- Upsample ---
525    /// Nearest-neighbour 2-D upsampling.
526    /// Output shape: `[N, C, H * scale_h, W * scale_w]`.
527    UpsampleNearest2d {
528        /// Vertical upsampling scale factor.
529        scale_h: usize,
530        /// Horizontal upsampling scale factor.
531        scale_w: usize,
532    },
533
534    // --- Padding ---
535    /// 1-D constant padding (see `nn::pad::ConstantPad1d`).
536    ConstantPad1d {
537        /// Left padding.
538        pad_left: usize,
539        /// Right padding.
540        pad_right: usize,
541        /// The constant fill value.
542        value: f64,
543    },
544    /// 2-D constant padding (see `nn::pad::ConstantPad2d`).
545    ConstantPad2d {
546        /// Left padding.
547        pad_l: usize,
548        /// Right padding.
549        pad_r: usize,
550        /// Top padding.
551        pad_t: usize,
552        /// Bottom padding.
553        pad_b: usize,
554        /// The constant fill value.
555        value: f64,
556    },
557    /// 3-D constant padding (see `nn::pad::ConstantPad3d`).
558    ConstantPad3d {
559        /// Padding before the depth dimension.
560        pad_d1: usize,
561        /// Padding after the depth dimension.
562        pad_d2: usize,
563        /// Padding before the height dimension.
564        pad_h1: usize,
565        /// Padding after the height dimension.
566        pad_h2: usize,
567        /// Padding before the width dimension.
568        pad_w1: usize,
569        /// Padding after the width dimension.
570        pad_w2: usize,
571        /// The constant fill value.
572        value: f64,
573    },
574    /// 1-D reflection padding (see `nn::pad::ReflectionPad1d`).
575    ReflectionPad1d {
576        /// Left padding.
577        pad_left: usize,
578        /// Right padding.
579        pad_right: usize,
580    },
581    /// 2-D reflection padding (see `nn::pad::ReflectionPad2d`).
582    ReflectionPad2d {
583        /// Left padding.
584        pad_l: usize,
585        /// Right padding.
586        pad_r: usize,
587        /// Top padding.
588        pad_t: usize,
589        /// Bottom padding.
590        pad_b: usize,
591    },
592    /// 3-D reflection padding (see `nn::pad::ReflectionPad3d`).
593    ReflectionPad3d {
594        /// Padding before the depth dimension.
595        pad_d1: usize,
596        /// Padding after the depth dimension.
597        pad_d2: usize,
598        /// Padding before the height dimension.
599        pad_h1: usize,
600        /// Padding after the height dimension.
601        pad_h2: usize,
602        /// Padding before the width dimension.
603        pad_w1: usize,
604        /// Padding after the width dimension.
605        pad_w2: usize,
606    },
607    /// 1-D replication padding (see `nn::pad::ReplicationPad1d`).
608    ReplicationPad1d {
609        /// Left padding.
610        pad_left: usize,
611        /// Right padding.
612        pad_right: usize,
613    },
614    /// 2-D replication padding (see `nn::pad::ReplicationPad2d`).
615    ReplicationPad2d {
616        /// Left padding.
617        pad_l: usize,
618        /// Right padding.
619        pad_r: usize,
620        /// Top padding.
621        pad_t: usize,
622        /// Bottom padding.
623        pad_b: usize,
624    },
625    /// 3-D replication padding (see `nn::pad::ReplicationPad3d`).
626    ReplicationPad3d {
627        /// Padding before the depth dimension.
628        pad_d1: usize,
629        /// Padding after the depth dimension.
630        pad_d2: usize,
631        /// Padding before the height dimension.
632        pad_h1: usize,
633        /// Padding after the height dimension.
634        pad_h2: usize,
635        /// Padding before the width dimension.
636        pad_w1: usize,
637        /// Padding after the width dimension.
638        pad_w2: usize,
639    },
640    /// 1-D circular padding (see `nn::pad::CircularPad1d`).
641    CircularPad1d {
642        /// Left padding.
643        pad_left: usize,
644        /// Right padding.
645        pad_right: usize,
646    },
647    /// 2-D circular padding (see `nn::pad::CircularPad2d`).
648    CircularPad2d {
649        /// Left padding.
650        pad_l: usize,
651        /// Right padding.
652        pad_r: usize,
653        /// Top padding.
654        pad_t: usize,
655        /// Bottom padding.
656        pad_b: usize,
657    },
658    /// 3-D circular padding (see `nn::pad::CircularPad3d`).
659    CircularPad3d {
660        /// Padding before the depth dimension.
661        pad_d1: usize,
662        /// Padding after the depth dimension.
663        pad_d2: usize,
664        /// Padding before the height dimension.
665        pad_h1: usize,
666        /// Padding after the height dimension.
667        pad_h2: usize,
668        /// Padding before the width dimension.
669        pad_w1: usize,
670        /// Padding after the width dimension.
671        pad_w2: usize,
672    },
673
674    // --- Activation ---
675    /// ReLU activation (see `nn::activation::relu::Relu`).
676    Relu,
677    /// ELU activation (see `nn::activation::elu::Elu`).
678    Elu {
679        /// The `alpha` parameter.
680        alpha: f64,
681    },
682    /// SELU activation (see `nn::activation::elu::Selu`).
683    Selu,
684    /// CELU activation (see `nn::activation::elu::Celu`).
685    Celu {
686        /// The `alpha` parameter.
687        alpha: f64,
688    },
689    /// GELU activation (see `nn::activation::gelu::Gelu`).
690    Gelu,
691    /// Mish activation (see `nn::activation::gelu::Mish`).
692    Mish,
693    /// Hardtanh activation (see `nn::activation::hard::Hardtanh`).
694    Hardtanh {
695        /// The lower clamp bound.
696        min_val: f64,
697        /// The upper clamp bound.
698        max_val: f64,
699    },
700    /// ReLU6 activation (see `nn::activation::hard::Relu6`).
701    Relu6,
702    /// Hard-sigmoid activation (see `nn::activation::hard::Hardsigmoid`).
703    Hardsigmoid,
704    /// Hard-swish activation (see `nn::activation::hard::Hardswish`).
705    Hardswish,
706    /// Hardshrink activation (see `nn::activation::hard::Hardshrink`).
707    Hardshrink {
708        /// The shrinkage threshold.
709        lambda: f64,
710    },
711    /// Leaky ReLU activation (see `nn::activation::misc::LeakyRelu`).
712    LeakyRelu {
713        /// The slope applied to negative inputs.
714        negative_slope: f64,
715    },
716    /// Threshold activation (see `nn::activation::misc::Threshold`).
717    Threshold {
718        /// The threshold value.
719        threshold: f64,
720        /// The constant fill value.
721        value: f64,
722    },
723    /// Softsign activation (see `nn::activation::misc::Softsign`).
724    Softsign,
725    /// Softshrink activation (see `nn::activation::misc::Softshrink`).
726    Softshrink {
727        /// The shrinkage threshold.
728        lambda: f64,
729    },
730    /// Softplus activation (see `nn::activation::misc::Softplus`).
731    Softplus {
732        /// The `beta` parameter.
733        beta: f64,
734        /// The threshold value.
735        threshold: f64,
736    },
737    /// Sigmoid activation (see `nn::activation::sigmoid::Sigmoid`).
738    Sigmoid,
739    /// SiLU/Swish activation (see `nn::activation::sigmoid::Silu`).
740    Silu,
741    /// Log-sigmoid activation (see `nn::activation::sigmoid::Logsigmoid`).
742    Logsigmoid,
743    /// Tanh activation (see `nn::activation::tanh::Tanh`).
744    Tanh,
745    /// Tanhshrink activation (see `nn::activation::tanh::Tanhshrink`).
746    Tanhshrink,
747    /// Softmax activation (see `nn::activation::softmax::Softmax`).
748    Softmax {
749        /// The dimension to operate along.
750        dim: usize,
751    },
752
753    // --- Attention ---
754    /// Multi-head self-attention with Flash Attention 2 and position encoding.
755    /// Represents the full `Attention.forward()` in PSABlock:
756    ///   qkv conv → FA2 → pe depthwise conv → proj conv → residual add.
757    /// Input/output shape: `[N, c, H, W]`.
758    Attention {
759        /// Number of channels.
760        c: usize,
761        /// Number of attention heads.
762        num_heads: usize,
763        /// Per-head key/query dimension.
764        key_dim: usize,
765    },
766
767    // --- Tensor structural ops ---
768    /// Element-wise addition of two tensors with identical shapes.
769    Add,
770    /// Extract one contiguous channel slice from a 4-D NCHW tensor.
771    /// Output shape: `[N, chunk_c, H, W]`.
772    ChannelChunk {
773        /// Total number of channels across all inputs/outputs.
774        c_total: usize,
775        /// Number of channels in this chunk.
776        chunk_c: usize,
777        /// Channel offset of this chunk within the total.
778        chunk_offset: usize,
779    },
780    /// Concatenate N 4-D NCHW tensors along the channel dimension.
781    /// Output shape: `[N, c_total, H, W]`.
782    ChannelCat {
783        /// Total number of channels across all inputs/outputs.
784        c_total: usize,
785    },
786    /// Adds a (C,) bias vector to a (B, C, H, W) feature map — NC layout (N=B*H*W).
787    /// Output shape equals input shape.
788    ChannelBiasAdd {
789        /// Number of channels.
790        c: usize,
791    },
792
793    /// User-defined op.  Shape and dtype must be provided via [`Graph::add_node`]
794    /// or [`SymTensor::record_custom`] — the base system cannot infer them.
795    Custom {
796        /// The wrapped user-defined op.
797        data: CustomData,
798    },
799
800    // -----------------------------------------------------------------------
801    // ONNX-sourced ops — added to let the ONNX loader build a complete graph.
802    // Triton/CPU lowering is not yet implemented for these variants.
803    // -----------------------------------------------------------------------
804
805    // --- Element-wise unary math ---
806    /// Element-wise absolute value (ONNX `Abs`).
807    Abs,
808    /// Element-wise negation (ONNX `Neg`).
809    Neg,
810    /// Element-wise ceiling (ONNX `Ceil`).
811    Ceil,
812    /// Element-wise floor (ONNX `Floor`).
813    Floor,
814    /// Element-wise round-to-nearest-even (ONNX `Round`).
815    Round,
816    /// Element-wise square root (ONNX `Sqrt`).
817    Sqrt,
818    /// Element-wise reciprocal, `1/x` (ONNX `Reciprocal`).
819    Reciprocal,
820    /// Element-wise natural exponential (ONNX `Exp`).
821    Exp,
822    /// Element-wise natural logarithm (ONNX `Log`).
823    Log,
824    /// Element-wise error function (ONNX `Erf`).
825    Erf,
826    /// Element-wise sign (ONNX `Sign`).
827    Sign,
828    /// Element-wise NaN test (ONNX `IsNaN`).
829    IsNaN,
830    /// Element-wise infinity test (ONNX `IsInf`).
831    IsInf {
832        /// Whether to treat negative infinity as infinite.
833        detect_negative: bool,
834        /// Whether to treat positive infinity as infinite.
835        detect_positive: bool,
836    },
837    /// Element-wise logical NOT (ONNX `Not`).
838    Not,
839    /// Element-wise bitwise NOT (ONNX `BitwiseNot`).
840    BitwiseNot,
841    /// Element-wise sine (ONNX `Sin`).
842    Sin,
843    /// Element-wise cosine (ONNX `Cos`).
844    Cos,
845    /// Element-wise tangent (ONNX `Tan`).
846    Tan,
847    /// Element-wise arcsine (ONNX `Asin`).
848    Asin,
849    /// Element-wise arccosine (ONNX `Acos`).
850    Acos,
851    /// Element-wise arctangent (ONNX `Atan`).
852    Atan,
853    /// Element-wise hyperbolic sine (ONNX `Sinh`).
854    Sinh,
855    /// Element-wise hyperbolic cosine (ONNX `Cosh`).
856    Cosh,
857    /// Element-wise inverse hyperbolic sine (ONNX `Asinh`).
858    Asinh,
859    /// Element-wise inverse hyperbolic cosine (ONNX `Acosh`).
860    Acosh,
861    /// Element-wise inverse hyperbolic tangent (ONNX `Atanh`).
862    Atanh,
863
864    // --- Element-wise binary / variadic ---
865    /// Element-wise multiplication (ONNX `Mul`).
866    Mul,
867    /// Element-wise subtraction (ONNX `Sub`).
868    Sub,
869    /// Element-wise division (ONNX `Div`).
870    Div,
871    /// Element-wise exponentiation (ONNX `Pow`).
872    Pow,
873    /// Element-wise modulo (ONNX `Mod`).
874    Mod {
875        /// Whether to use C-style (`fmod`) semantics instead of Python-style modulo.
876        fmod: bool,
877    },
878    /// Element-wise minimum across inputs (ONNX `Min`).
879    ElemMin,
880    /// Element-wise maximum across inputs (ONNX `Max`).
881    ElemMax,
882    /// Element-wise mean across inputs (ONNX `Mean`).
883    ElemMean,
884    /// Element-wise sum across inputs (ONNX `Sum`).
885    ElemSum,
886    /// Element-wise equality (ONNX `Equal`).
887    Equal,
888    /// Element-wise greater-than (ONNX `Greater`).
889    Greater,
890    /// Element-wise greater-than-or-equal (ONNX `GreaterOrEqual`).
891    GreaterOrEqual,
892    /// Element-wise less-than (ONNX `Less`).
893    Less,
894    /// Element-wise less-than-or-equal (ONNX `LessOrEqual`).
895    LessOrEqual,
896    /// Element-wise logical AND (ONNX `And`).
897    And,
898    /// Element-wise logical OR (ONNX `Or`).
899    Or,
900    /// Element-wise logical XOR (ONNX `Xor`).
901    Xor,
902    /// Element-wise bitwise AND (ONNX `BitwiseAnd`).
903    BitwiseAnd,
904    /// Element-wise bitwise OR (ONNX `BitwiseOr`).
905    BitwiseOr,
906    /// Element-wise bitwise XOR (ONNX `BitwiseXor`).
907    BitwiseXor,
908    /// Element-wise bit shift (ONNX `BitShift`).
909    BitShift {
910        /// Direction of iteration/shift (e.g. `"forward"`, `"reverse"`, `"bidirectional"`).
911        direction: alloc::string::String,
912    },
913
914    // --- Tensor structural ---
915    /// Reshapes a tensor without changing its data (ONNX `Reshape`).
916    Reshape,
917    /// Permutes a tensor's dimensions (ONNX `Transpose`).
918    Transpose {
919        /// The output permutation of input dimensions.
920        perm: alloc::vec::Vec<usize>,
921    },
922    /// Removes size-1 dimensions (ONNX `Squeeze`).
923    Squeeze {
924        /// The axes to operate along.
925        axes: alloc::vec::Vec<i64>,
926    },
927    /// Inserts size-1 dimensions (ONNX `Unsqueeze`).
928    Unsqueeze {
929        /// The axes to operate along.
930        axes: alloc::vec::Vec<i64>,
931    },
932    /// Concatenates tensors along an axis (ONNX `Concat`).
933    Concat {
934        /// The axis to operate along.
935        axis: i64,
936    },
937    /// Splits a tensor into multiple outputs along an axis (ONNX `Split`).
938    Split {
939        /// The axis to operate along.
940        axis: i64,
941        /// Number of outputs to split into.
942        num_outputs: usize,
943    },
944    /// Extracts a slice of a tensor (ONNX `Slice`).
945    Slice,
946    /// Gathers slices along an axis using an index tensor (ONNX `Gather`).
947    Gather {
948        /// The axis to operate along.
949        axis: i64,
950    },
951    /// Gathers individual elements along an axis (ONNX `GatherElements`).
952    GatherElements {
953        /// The axis to operate along.
954        axis: i64,
955    },
956    /// Gathers slices using N-D indices (ONNX `GatherND`).
957    GatherND {
958        /// Number of leading batch dimensions.
959        batch_dims: i64,
960    },
961    /// Scatters individual elements along an axis (ONNX `ScatterElements`).
962    ScatterElements {
963        /// The axis to operate along.
964        axis: i64,
965    },
966    /// Scatters slices using N-D indices (ONNX `ScatterND`).
967    ScatterND,
968    /// Tiles a tensor by repeating it (ONNX `Tile`).
969    Tile,
970    /// Broadcasts a tensor to a larger shape (ONNX `Expand`).
971    Expand,
972    /// Returns a tensor's shape as a 1-D tensor (ONNX `Shape`).
973    ShapeOf {
974        /// Start index.
975        start: i64,
976        /// End index.
977        end: i64,
978    },
979    /// Returns the total number of elements (ONNX `Size`).
980    SizeOf,
981    /// Passes the input through unchanged (ONNX `Identity`).
982    Identity,
983    /// Casts a tensor to another dtype (ONNX `Cast`).
984    Cast {
985        /// Target dtype.
986        to: DtypeRepr,
987    },
988    /// Casts a tensor to match another tensor's dtype (ONNX `CastLike`).
989    CastLike,
990    /// Element-wise conditional selection (ONNX `Where`).
991    Where,
992    /// Selects slices along an axis using a boolean mask (ONNX `Compress`).
993    Compress {
994        /// The axis to operate along.
995        axis: i64,
996    },
997    /// Generates a range of values (ONNX `Range`).
998    Range,
999    /// Constant tensor (value embedded in the ONNX model).
1000    Constant {
1001        /// The dtype to use.
1002        dtype: DtypeRepr,
1003        /// The tensor shape.
1004        shape: Shape,
1005    },
1006    /// Creates a constant-filled tensor of a given shape (ONNX `ConstantOfShape`).
1007    ConstantOfShape {
1008        /// The dtype to use.
1009        dtype: DtypeRepr,
1010    },
1011    /// Extracts the upper or lower triangular part of a matrix (ONNX `Trilu`).
1012    Trilu {
1013        /// Whether to keep the upper (vs. lower) triangular part.
1014        upper: bool,
1015    },
1016    /// Reinterprets a tensor's bits as another dtype without conversion.
1017    BitCast {
1018        /// Target dtype.
1019        to: DtypeRepr,
1020    },
1021    /// Generic padding (ONNX `Pad`).
1022    Pad {
1023        /// The mode string selecting op-specific behavior.
1024        mode: alloc::string::String,
1025    },
1026    /// Reverses variable-length sequences along an axis (ONNX `ReverseSequence`).
1027    ReverseSequence {
1028        /// The batch axis.
1029        batch_axis: i64,
1030        /// The time axis.
1031        time_axis: i64,
1032    },
1033    /// Returns the indices of non-zero elements (ONNX `NonZero`).
1034    NonZero,
1035    /// Scatters values along an axis (deprecated ONNX `Scatter`, superseded by `ScatterElements`).
1036    Scatter {
1037        /// The axis to operate along.
1038        axis: i64,
1039    },
1040    /// Scatters an entire tensor into another at given indices.
1041    TensorScatter,
1042
1043    // --- Matrix ---
1044    /// General matrix multiply: `alpha * A @ B + beta * C` (ONNX `Gemm`).
1045    Gemm {
1046        /// The `alpha` parameter.
1047        alpha: f64,
1048        /// The `beta` parameter.
1049        beta: f64,
1050        /// Whether to transpose the first matrix operand.
1051        trans_a: bool,
1052        /// Whether to transpose the second matrix operand.
1053        trans_b: bool,
1054    },
1055    /// Matrix multiplication (ONNX `MatMul`).
1056    MatMul,
1057    /// Integer matrix multiplication (ONNX `MatMulInteger`).
1058    MatMulInteger,
1059    /// Einstein-summation contraction (ONNX `Einsum`).
1060    Einsum {
1061        /// The Einstein-summation equation string.
1062        equation: alloc::string::String,
1063    },
1064    /// Matrix determinant (ONNX `Det`).
1065    Det,
1066    /// Quantized linear matrix multiplication (ONNX `QLinearMatMul`).
1067    QLinearMatMul,
1068
1069    // --- Convolution extras ---
1070    /// Transposed (deconvolution) 2-D convolution (ONNX `ConvTranspose`).
1071    ConvTranspose {
1072        /// Number of input channels.
1073        in_channels: usize,
1074        /// Number of output channels.
1075        out_channels: usize,
1076        /// Convolution/pooling kernel height.
1077        kernel_h: usize,
1078        /// Convolution/pooling kernel width.
1079        kernel_w: usize,
1080        /// Vertical stride.
1081        stride_h: usize,
1082        /// Horizontal stride.
1083        stride_w: usize,
1084        /// Vertical zero-padding.
1085        padding_h: usize,
1086        /// Horizontal zero-padding.
1087        padding_w: usize,
1088        /// Additional vertical padding added to the output (transposed convolution).
1089        output_padding_h: usize,
1090        /// Additional horizontal padding added to the output (transposed convolution).
1091        output_padding_w: usize,
1092        /// Number of blocked/grouped connections (1 = standard).
1093        groups: usize,
1094        /// Whether a learned bias is added.
1095        has_bias: bool,
1096    },
1097    /// Integer convolution (ONNX `ConvInteger`).
1098    ConvInteger {
1099        /// Number of blocked/grouped connections (1 = standard).
1100        groups: usize,
1101    },
1102    /// Deformable convolution (ONNX `DeformConv`).
1103    DeformConv {
1104        /// Number of blocked/grouped connections (1 = standard).
1105        group: usize,
1106        /// Number of groups for deformable-convolution offset channels.
1107        offset_group: usize,
1108    },
1109    /// Quantized linear convolution (ONNX `QLinearConv`).
1110    QLinearConv {
1111        /// Number of blocked/grouped connections (1 = standard).
1112        groups: usize,
1113    },
1114    /// Combines sliding local blocks into a large tensor (ONNX `Col2Im`, inverse of im2col).
1115    Col2Im {
1116        /// Convolution/pooling kernel height.
1117        kernel_h: usize,
1118        /// Convolution/pooling kernel width.
1119        kernel_w: usize,
1120    },
1121    /// Stateful causal 1-D convolution, carrying a sliding-window state between calls (ONNX
1122    /// `CausalConvWithState`).
1123    CausalConvWithState {
1124        /// Name of the activation function applied after the convolution (empty = none).
1125        activation: alloc::string::String,
1126    },
1127
1128    // --- Reductions ---
1129    /// Sum reduction along axes (ONNX `ReduceSum`).
1130    ReduceSum {
1131        /// Whether to retain reduced dimensions with length 1.
1132        keepdims: bool,
1133        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1134        noop_with_empty_axes: bool,
1135    },
1136    /// Mean reduction along axes (ONNX `ReduceMean`).
1137    ReduceMean {
1138        /// Whether to retain reduced dimensions with length 1.
1139        keepdims: bool,
1140        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1141        noop_with_empty_axes: bool,
1142    },
1143    /// Max reduction along axes (ONNX `ReduceMax`).
1144    ReduceMax {
1145        /// Whether to retain reduced dimensions with length 1.
1146        keepdims: bool,
1147        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1148        noop_with_empty_axes: bool,
1149    },
1150    /// Min reduction along axes (ONNX `ReduceMin`).
1151    ReduceMin {
1152        /// Whether to retain reduced dimensions with length 1.
1153        keepdims: bool,
1154        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1155        noop_with_empty_axes: bool,
1156    },
1157    /// Product reduction along axes (ONNX `ReduceProd`).
1158    ReduceProd {
1159        /// Whether to retain reduced dimensions with length 1.
1160        keepdims: bool,
1161        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1162        noop_with_empty_axes: bool,
1163    },
1164    /// L1-norm reduction along axes (ONNX `ReduceL1`).
1165    ReduceL1 {
1166        /// Whether to retain reduced dimensions with length 1.
1167        keepdims: bool,
1168        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1169        noop_with_empty_axes: bool,
1170    },
1171    /// L2-norm reduction along axes (ONNX `ReduceL2`).
1172    ReduceL2 {
1173        /// Whether to retain reduced dimensions with length 1.
1174        keepdims: bool,
1175        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1176        noop_with_empty_axes: bool,
1177    },
1178    /// Log-sum reduction along axes (ONNX `ReduceLogSum`).
1179    ReduceLogSum {
1180        /// Whether to retain reduced dimensions with length 1.
1181        keepdims: bool,
1182        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1183        noop_with_empty_axes: bool,
1184    },
1185    /// Log-sum-exp reduction along axes (ONNX `ReduceLogSumExp`).
1186    ReduceLogSumExp {
1187        /// Whether to retain reduced dimensions with length 1.
1188        keepdims: bool,
1189        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1190        noop_with_empty_axes: bool,
1191    },
1192    /// Sum-of-squares reduction along axes (ONNX `ReduceSumSquare`).
1193    ReduceSumSquare {
1194        /// Whether to retain reduced dimensions with length 1.
1195        keepdims: bool,
1196        /// Whether an empty `axes` list means "no-op" instead of "reduce all".
1197        noop_with_empty_axes: bool,
1198    },
1199    /// Cumulative sum along an axis (ONNX `CumSum`).
1200    CumSum {
1201        /// Whether to exclude the current element from the cumulative result.
1202        exclusive: bool,
1203        /// Whether to accumulate in reverse order.
1204        reverse: bool,
1205    },
1206    /// Cumulative product along an axis (ONNX `CumProd`).
1207    CumProd {
1208        /// Whether to exclude the current element from the cumulative result.
1209        exclusive: bool,
1210        /// Whether to accumulate in reverse order.
1211        reverse: bool,
1212    },
1213    /// Index of the maximum along an axis (ONNX `ArgMax`).
1214    ArgMax {
1215        /// The axis to operate along.
1216        axis: i64,
1217        /// Whether to retain reduced dimensions with length 1.
1218        keepdims: bool,
1219        /// Whether ties select the last (rather than first) matching index.
1220        select_last_index: bool,
1221    },
1222    /// Index of the minimum along an axis (ONNX `ArgMin`).
1223    ArgMin {
1224        /// The axis to operate along.
1225        axis: i64,
1226        /// Whether to retain reduced dimensions with length 1.
1227        keepdims: bool,
1228        /// Whether ties select the last (rather than first) matching index.
1229        select_last_index: bool,
1230    },
1231    /// Average-pools over the entire spatial extent (ONNX `GlobalAveragePool`).
1232    GlobalAvgPool,
1233    /// Max-pools over the entire spatial extent (ONNX `GlobalMaxPool`).
1234    GlobalMaxPool,
1235    /// Lp-norm normalization along an axis (ONNX `LpNormalization`).
1236    LpNormalization {
1237        /// The axis to operate along.
1238        axis: i64,
1239        /// The `p` in the p-norm.
1240        p: i64,
1241    },
1242    /// Mean/variance normalization along axes (ONNX `MeanVarianceNormalization`).
1243    MeanVarianceNormalization {
1244        /// The axes to operate along.
1245        axes: alloc::vec::Vec<i64>,
1246    },
1247
1248    // --- Additional activations ---
1249    /// Log-softmax along an axis (ONNX `LogSoftmax`).
1250    LogSoftmax {
1251        /// The axis to operate along.
1252        axis: i64,
1253    },
1254    /// One-hot of the argmax along an axis (ONNX `Hardmax`).
1255    Hardmax {
1256        /// The axis to operate along.
1257        axis: i64,
1258    },
1259    /// Parametric ReLU, with a learned per-channel slope (ONNX `PRelu`).
1260    PRelu,
1261    /// ReLU that zeroes values at or below `alpha` (ONNX `ThresholdedRelu`).
1262    ThresholdedRelu {
1263        /// The `alpha` parameter.
1264        alpha: f64,
1265    },
1266    /// Shrinks values toward zero by `lambd`, with a `bias` offset (ONNX `Shrink`).
1267    Shrink {
1268        /// The shrinkage threshold.
1269        lambd: f64,
1270        /// A bias/offset value.
1271        bias: f64,
1272    },
1273    /// Clamps values to a `[min, max]` range (ONNX `Clip`).
1274    Clip,
1275    /// Swish/SiLU activation (ONNX `Swish`).
1276    Swish,
1277    /// Multi-head attention (ONNX `MultiHeadAttention`).
1278    MultiHeadAttention {
1279        /// Number of query attention heads.
1280        q_num_heads: usize,
1281        /// Number of key/value attention heads.
1282        kv_num_heads: usize,
1283    },
1284    /// Attention with a user-defined score-modification function (ONNX `FlexAttention`). The
1285    /// score-modification subgraph itself is not captured (consistent with `Loop`/`If`/`Scan`
1286    /// not capturing their subgraph bodies).
1287    FlexAttention {
1288        /// Attention score scale factor, if explicitly specified.
1289        scale: f64,
1290    },
1291    /// Linear-complexity attention (e.g. gated delta rule variants), optionally carrying state
1292    /// between calls (ONNX `LinearAttention`).
1293    LinearAttention {
1294        /// Number of query attention heads.
1295        q_num_heads: usize,
1296        /// Number of key/value attention heads.
1297        kv_num_heads: usize,
1298        /// Name of the state-update rule (e.g. a gated-delta variant).
1299        update_rule: alloc::string::String,
1300        /// Attention score scale factor, if explicitly specified.
1301        scale: f64,
1302    },
1303
1304    // --- Normalisation (generic) ---
1305    /// Local response normalization (ONNX `LRN`).
1306    LRN {
1307        /// The `alpha` parameter.
1308        alpha: f64,
1309        /// The `beta` parameter.
1310        beta: f64,
1311        /// A bias/offset value.
1312        bias: f64,
1313        /// Window/kernel size.
1314        size: usize,
1315    },
1316
1317    // --- Recurrent ---
1318    /// Long short-term memory recurrent layer (ONNX `LSTM`).
1319    Lstm {
1320        /// Size of the hidden state.
1321        hidden_size: usize,
1322        /// Direction of iteration/shift (e.g. `"forward"`, `"reverse"`, `"bidirectional"`).
1323        direction: alloc::string::String,
1324        /// Whether to run the recurrence in both directions.
1325        bidirectional: bool,
1326    },
1327    /// Gated recurrent unit layer (ONNX `GRU`).
1328    Gru {
1329        /// Size of the hidden state.
1330        hidden_size: usize,
1331        /// Direction of iteration/shift (e.g. `"forward"`, `"reverse"`, `"bidirectional"`).
1332        direction: alloc::string::String,
1333        /// Whether to run the recurrence in both directions.
1334        bidirectional: bool,
1335    },
1336    /// Simple recurrent layer (ONNX `RNN`).
1337    Rnn {
1338        /// Size of the hidden state.
1339        hidden_size: usize,
1340        /// Direction of iteration/shift (e.g. `"forward"`, `"reverse"`, `"bidirectional"`).
1341        direction: alloc::string::String,
1342        /// Whether to run the recurrence in both directions.
1343        bidirectional: bool,
1344    },
1345
1346    // --- Resize / spatial ---
1347    /// Resizes a tensor (interpolation) (ONNX `Resize`).
1348    Resize {
1349        /// The mode string selecting op-specific behavior.
1350        mode: alloc::string::String,
1351        /// How resized coordinates map back to the input (ONNX `Resize` mode string).
1352        coordinate_transformation_mode: alloc::string::String,
1353        /// Whether to apply an anti-aliasing filter when downsampling.
1354        antialias: bool,
1355    },
1356    /// Samples a tensor at grid-specified locations (ONNX `GridSample`).
1357    GridSample {
1358        /// The mode string selecting op-specific behavior.
1359        mode: alloc::string::String,
1360        /// How out-of-bounds sample coordinates are handled.
1361        padding_mode: alloc::string::String,
1362        /// Whether corner pixels are aligned (vs. edge-aligned) when sampling/resizing.
1363        align_corners: bool,
1364    },
1365    /// Rearranges spatial blocks into depth/channels (ONNX `SpaceToDepth`).
1366    SpaceToDepth {
1367        /// Block size for the space/depth rearrangement.
1368        blocksize: usize,
1369    },
1370    /// Rearranges depth/channels into spatial blocks (ONNX `DepthToSpace`).
1371    DepthToSpace {
1372        /// Block size for the space/depth rearrangement.
1373        blocksize: usize,
1374        /// The mode string selecting op-specific behavior.
1375        mode: alloc::string::String,
1376    },
1377    /// Region-of-interest pooling with bilinear alignment (ONNX `RoiAlign`).
1378    RoiAlign {
1379        /// Output region height.
1380        output_h: usize,
1381        /// Output region width.
1382        output_w: usize,
1383        /// Number of sampling points per output bin (0 = adaptive).
1384        sampling_ratio: i64,
1385        /// Scale factor mapping ROI coordinates to the input feature map.
1386        spatial_scale: f64,
1387    },
1388    /// Generates a 2-D/3-D sampling grid from an affine matrix (ONNX `AffineGrid`).
1389    AffineGrid {
1390        /// Whether corner pixels are aligned (vs. edge-aligned) when sampling/resizing.
1391        align_corners: bool,
1392    },
1393    /// Inverse of max pooling, using stored indices (ONNX `MaxUnpool`).
1394    MaxUnpool {
1395        /// Convolution/pooling kernel height.
1396        kernel_h: usize,
1397        /// Convolution/pooling kernel width.
1398        kernel_w: usize,
1399        /// Vertical stride.
1400        stride_h: usize,
1401        /// Horizontal stride.
1402        stride_w: usize,
1403    },
1404    /// Crops or pads a tensor to a target shape, centered (ONNX `CenterCropPad`).
1405    CenterCropPad {
1406        /// The axes to operate along.
1407        axes: alloc::vec::Vec<i64>,
1408    },
1409    /// Filters overlapping boxes by score (ONNX `NonMaxSuppression`).
1410    NonMaxSuppression {
1411        /// Whether boxes are given as `(center_x, center_y, width, height)` instead of corners.
1412        center_point_box: bool,
1413    },
1414
1415    // --- Misc ---
1416    /// Returns the top-K values/indices along an axis (ONNX `TopK`).
1417    TopK {
1418        /// The axis to operate along.
1419        axis: i64,
1420        /// Whether to return the largest (vs. smallest) K values.
1421        largest: bool,
1422        /// Whether outputs are sorted.
1423        sorted: bool,
1424    },
1425    /// Returns unique elements (ONNX `Unique`).
1426    Unique {
1427        /// Whether outputs are sorted.
1428        sorted: bool,
1429    },
1430    /// Dropout regularization (ONNX `Dropout`).
1431    Dropout {
1432        /// Whether dropout is active (vs. a no-op at inference).
1433        training_mode: bool,
1434    },
1435    /// Creates an identity-like 2-D tensor (ONNX `EyeLike`).
1436    EyeLike {
1437        /// The dtype to use.
1438        dtype: Option<DtypeRepr>,
1439        /// Diagonal offset.
1440        k: i64,
1441    },
1442    /// One-hot encodes indices along an axis (ONNX `OneHot`).
1443    OneHot {
1444        /// The axis to operate along.
1445        axis: i64,
1446    },
1447    /// Samples from a Bernoulli distribution using input probabilities (ONNX `Bernoulli`).
1448    Bernoulli {
1449        /// The dtype to use.
1450        dtype: Option<DtypeRepr>,
1451    },
1452    /// Samples uniform random values with another tensor's shape (ONNX `RandomUniformLike`).
1453    RandomUniformLike {
1454        /// The dtype to use.
1455        dtype: Option<DtypeRepr>,
1456        /// Upper bound of the sampling range.
1457        high: f64,
1458        /// Lower bound of the sampling range.
1459        low: f64,
1460    },
1461    /// Rotary position embedding (ONNX `RotaryEmbedding`).
1462    RotaryEmbedding,
1463
1464    // --- Quantisation ---
1465    /// Linear quantization to a lower-precision dtype (ONNX `QuantizeLinear`).
1466    QuantizeLinear {
1467        /// The axis to operate along.
1468        axis: i64,
1469        /// Whether to saturate (clamp) out-of-range values instead of wrapping.
1470        saturate: bool,
1471    },
1472    /// Linear dequantization back to a floating-point dtype (ONNX `DequantizeLinear`).
1473    DequantizeLinear {
1474        /// The axis to operate along.
1475        axis: i64,
1476    },
1477    /// Dynamically computes quantization parameters and quantizes (ONNX `DynamicQuantizeLinear`).
1478    DynamicQuantizeLinear,
1479
1480    // --- Signal ---
1481    /// Discrete Fourier transform (ONNX `DFT`).
1482    Dft {
1483        /// Whether to compute the inverse transform.
1484        inverse: bool,
1485        /// Whether to return only the non-redundant half of the spectrum.
1486        onesided: bool,
1487    },
1488    /// Short-time Fourier transform (ONNX `STFT`).
1489    Stft,
1490    /// Generates a mel-scale filterbank matrix (ONNX `MelWeightMatrix`).
1491    MelWeightMatrix,
1492    /// Generates a Hann window (ONNX `HannWindow`).
1493    HannWindow {
1494        /// Whether the window is periodic (vs. symmetric).
1495        periodic: bool,
1496    },
1497    /// Generates a Blackman window (ONNX `BlackmanWindow`).
1498    BlackmanWindow {
1499        /// Whether the window is periodic (vs. symmetric).
1500        periodic: bool,
1501    },
1502    /// Generates a Hamming window (ONNX `HammingWindow`).
1503    HammingWindow {
1504        /// Whether the window is periodic (vs. symmetric).
1505        periodic: bool,
1506    },
1507
1508    // --- Loss ---
1509    /// Negative log-likelihood loss (ONNX `NegativeLogLikelihoodLoss`).
1510    NegativeLogLikelihoodLoss {
1511        /// The reduction mode applied to the per-element loss (e.g. `"mean"`, `"sum"`, `"none"`).
1512        reduction: alloc::string::String,
1513    },
1514    /// Softmax + cross-entropy loss (ONNX `SoftmaxCrossEntropyLoss`).
1515    SoftmaxCrossEntropyLoss {
1516        /// The reduction mode applied to the per-element loss (e.g. `"mean"`, `"sum"`, `"none"`).
1517        reduction: alloc::string::String,
1518    },
1519
1520    // --- Sequences ---
1521    /// Indexes into a sequence (ONNX `SequenceAt`).
1522    SequenceAt,
1523    /// Constructs a sequence from tensors (ONNX `SequenceConstruct`).
1524    SequenceConstruct,
1525    /// Constructs an empty sequence (ONNX `SequenceEmpty`).
1526    SequenceEmpty,
1527    /// Removes an element from a sequence (ONNX `SequenceErase`).
1528    SequenceErase,
1529    /// Inserts an element into a sequence (ONNX `SequenceInsert`).
1530    SequenceInsert,
1531    /// Returns a sequence's length (ONNX `SequenceLength`).
1532    SequenceLength,
1533    /// Applies a subgraph to each element of a sequence (ONNX `SequenceMap`).
1534    SequenceMap,
1535    /// Splits a tensor into a sequence along an axis (ONNX `SplitToSequence`).
1536    SplitToSequence {
1537        /// The axis to operate along.
1538        axis: i64,
1539        /// Whether to retain reduced dimensions with length 1.
1540        keepdims: bool,
1541    },
1542    /// Concatenates a sequence's elements into one tensor (ONNX `ConcatFromSequence`).
1543    ConcatFromSequence {
1544        /// The axis to operate along.
1545        axis: i64,
1546        /// Whether to insert a new axis for the concatenation dimension.
1547        new_axis: bool,
1548    },
1549    /// Extracts the value from an optional (ONNX `OptionalGetElement`).
1550    OptionalGetElement,
1551    /// Tests whether an optional has a value (ONNX `OptionalHasElement`).
1552    OptionalHasElement,
1553
1554    // --- Control flow ---
1555    /// Generic looping construct over a subgraph (ONNX `Loop`).
1556    Loop,
1557    /// Applies a subgraph iteratively over input sequences (ONNX `Scan`).
1558    Scan {
1559        /// Number of inputs treated as scanned sequences.
1560        num_scan_inputs: i64,
1561    },
1562    /// Conditional branch over subgraphs (ONNX `If`).
1563    If,
1564
1565    // --- Optimiser ops ---
1566    /// Adagrad optimizer step (ONNX `Adagrad`).
1567    Adagrad,
1568    /// Adam optimizer step (ONNX `Adam`).
1569    Adam,
1570    /// Momentum optimizer step (ONNX `Momentum`).
1571    Momentum,
1572    /// Computes gradients of a subgraph (ONNX `Gradient`).
1573    Gradient,
1574
1575    // --- String / NLP ---
1576    /// Normalizes strings (case folding, stop-word removal) (ONNX `StringNormalizer`).
1577    StringNormalizer,
1578    /// Tests strings against a regex (ONNX `RegexFullMatch`).
1579    RegexFullMatch {
1580        /// The regular expression pattern.
1581        pattern: alloc::string::String,
1582    },
1583    /// Concatenates strings element-wise (ONNX `StringConcat`).
1584    StringConcat,
1585    /// Splits strings on a delimiter (ONNX `StringSplit`).
1586    StringSplit,
1587    /// Computes TF-IDF n-gram features (ONNX `TfIdfVectorizer`).
1588    TfIdfVectorizer,
1589    /// Maps categorical labels to/from encoded values (ONNX `LabelEncoder`).
1590    LabelEncoder,
1591
1592    // --- Other ML ---
1593    /// Selects elements from a tensor by index (ONNX-ML `ArrayFeatureExtractor`).
1594    ArrayFeatureExtractor,
1595    /// Binarizes values against a threshold (ONNX-ML `Binarizer`).
1596    Binarizer {
1597        /// The threshold value.
1598        threshold: f64,
1599    },
1600    /// Decision-tree ensemble inference (ONNX-ML `TreeEnsemble`).
1601    TreeEnsemble,
1602    /// Decodes an encoded image (e.g. PNG/JPEG) into a tensor (ONNX `ImageDecoder`).
1603    ImageDecoder,
1604}
1605
1606/// One node in a [`Graph`]: an [`Op`] plus its producer indices, dtype, and output shape.
1607#[derive(Debug, Clone)]
1608pub struct GraphNode {
1609    /// This node's operation.
1610    pub op: Op,
1611    /// Indices of producer nodes in `Graph::nodes`; empty for `Input`.
1612    pub inputs: Vec<usize>,
1613    /// This node's output dtype.
1614    pub dtype: DtypeRepr,
1615    /// Output shape of this node. `None` in a slot means a dynamic/unknown
1616    /// dimension (e.g. the batch axis).
1617    pub shape: Shape,
1618}
1619
1620/// The traced computational graph: a list of [`GraphNode`]s plus optional node names.
1621#[derive(Debug, Default, Clone)]
1622pub struct Graph {
1623    /// The graph's nodes, in the order they were recorded.
1624    pub nodes: Vec<GraphNode>,
1625    /// Node index → dotted name captured from [`crate::name_scope`] at recording time.
1626    pub names: BTreeMap<usize, String>,
1627}
1628
1629impl Graph {
1630    /// Creates an empty graph.
1631    pub fn new() -> Self {
1632        Self::default()
1633    }
1634
1635    /// Appends a new node to the graph, returning its index.
1636    pub fn add_node(
1637        &mut self,
1638        op: Op,
1639        inputs: Vec<usize>,
1640        dtype: DtypeRepr,
1641        shape: Shape,
1642    ) -> usize {
1643        let id = self.nodes.len();
1644        self.nodes.push(GraphNode {
1645            op,
1646            inputs,
1647            dtype,
1648            shape,
1649        });
1650        #[cfg(feature = "std")]
1651        if let Some(name) = crate::name_scope::current_scope() {
1652            self.names.insert(id, name);
1653        }
1654        id
1655    }
1656
1657    /// Returns node indices in topological order (producers before consumers)
1658    /// using Kahn's algorithm. Panics if the graph contains a cycle.
1659    pub fn topological_sort(&self) -> Vec<usize> {
1660        let n = self.nodes.len();
1661        let mut in_degree = vec![0usize; n];
1662        let mut dependents: Vec<Vec<usize>> = vec![vec![]; n];
1663
1664        for (id, node) in self.nodes.iter().enumerate() {
1665            for &input in &node.inputs {
1666                in_degree[id] += 1;
1667                dependents[input].push(id);
1668            }
1669        }
1670
1671        let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
1672        let mut order = Vec::with_capacity(n);
1673
1674        while let Some(id) = queue.pop() {
1675            order.push(id);
1676            for &dep in &dependents[id] {
1677                in_degree[dep] -= 1;
1678                if in_degree[dep] == 0 {
1679                    queue.push(dep);
1680                }
1681            }
1682        }
1683
1684        assert_eq!(order.len(), n, "graph contains a cycle");
1685        order
1686    }
1687
1688    /// Rewrite the graph by fusing compatible op sequences into single fused ops.
1689    ///
1690    /// Currently recognises: `Conv2d(no bias) → BatchNorm2d → Silu`
1691    /// and replaces the triple with a single `Conv2dBnSilu` node.
1692    ///
1693    /// Nodes that are absorbed into a fused node are removed from the output
1694    /// graph; remaining node indices are renumbered contiguously.
1695    ///
1696    /// This does *not* include [`Graph::fuse_elementwise_chains`] — that pass
1697    /// produces `Op::Fused` nodes, and folding it in here would silently change
1698    /// what every existing `optimise()` caller lowers to. Call it separately once
1699    /// the target lowering backend actually knows how to compile `Op::Fused`.
1700    pub fn optimise(&self) -> Graph {
1701        let n = self.nodes.len();
1702
1703        // Build per-node consumer counts so we can detect single-use nodes.
1704        let mut n_consumers = vec![0usize; n];
1705        for node in &self.nodes {
1706            for &inp in &node.inputs {
1707                n_consumers[inp] += 1;
1708            }
1709        }
1710
1711        // dead[i] — node i is absorbed into a downstream fused node.
1712        let mut dead = vec![false; n];
1713        // node_override[i] — replacement (op, inputs) for node i.
1714        let mut node_override: Vec<Option<(Op, Vec<usize>)>> = vec![None; n];
1715
1716        // `silu_idx` indexes multiple parallel collections below (self.nodes, n_consumers,
1717        // node_override), not just one -- an iterator/enumerate() rewrite wouldn't be clearer.
1718        #[allow(clippy::needless_range_loop)]
1719        for silu_idx in 0..n {
1720            if !matches!(self.nodes[silu_idx].op, Op::Silu) {
1721                continue;
1722            }
1723            if self.nodes[silu_idx].inputs.len() != 1 {
1724                continue;
1725            }
1726
1727            let bn_idx = self.nodes[silu_idx].inputs[0];
1728            if !matches!(self.nodes[bn_idx].op, Op::BatchNorm2d { .. }) {
1729                continue;
1730            }
1731            if n_consumers[bn_idx] != 1 {
1732                continue;
1733            }
1734            if self.nodes[bn_idx].inputs.len() != 1 {
1735                continue;
1736            }
1737
1738            let conv_idx = self.nodes[bn_idx].inputs[0];
1739            if !matches!(
1740                self.nodes[conv_idx].op,
1741                Op::Conv2d {
1742                    has_bias: false,
1743                    ..
1744                }
1745            ) {
1746                continue;
1747            }
1748            if n_consumers[conv_idx] != 1 {
1749                continue;
1750            }
1751
1752            let (
1753                in_channels,
1754                out_channels,
1755                kernel_h,
1756                kernel_w,
1757                stride_h,
1758                stride_w,
1759                padding_h,
1760                padding_w,
1761                groups,
1762            ) = if let Op::Conv2d {
1763                in_channels,
1764                out_channels,
1765                kernel_h,
1766                kernel_w,
1767                stride_h,
1768                stride_w,
1769                padding_h,
1770                padding_w,
1771                groups,
1772                ..
1773            } = self.nodes[conv_idx].op
1774            {
1775                (
1776                    in_channels,
1777                    out_channels,
1778                    kernel_h,
1779                    kernel_w,
1780                    stride_h,
1781                    stride_w,
1782                    padding_h,
1783                    padding_w,
1784                    groups,
1785                )
1786            } else {
1787                unreachable!()
1788            };
1789
1790            let bn_eps = if let Op::BatchNorm2d { eps, .. } = self.nodes[bn_idx].op {
1791                eps
1792            } else {
1793                unreachable!()
1794            };
1795
1796            dead[conv_idx] = true;
1797            dead[bn_idx] = true;
1798            node_override[silu_idx] = Some((
1799                Op::Conv2dBnSilu {
1800                    in_channels,
1801                    out_channels,
1802                    kernel_h,
1803                    kernel_w,
1804                    stride_h,
1805                    stride_w,
1806                    padding_h,
1807                    padding_w,
1808                    groups,
1809                    bn_eps,
1810                },
1811                self.nodes[conv_idx].inputs.clone(),
1812            ));
1813        }
1814
1815        // Build old → new index mapping (dead nodes are skipped).
1816        let mut old_to_new = vec![0usize; n];
1817        let mut new_count = 0usize;
1818        for i in 0..n {
1819            if !dead[i] {
1820                old_to_new[i] = new_count;
1821                new_count += 1;
1822            }
1823        }
1824
1825        // Emit new graph in topological order (original ordering is already valid).
1826        let mut new_graph = Graph::new();
1827        for old_idx in 0..n {
1828            if dead[old_idx] {
1829                continue;
1830            }
1831            let node = &self.nodes[old_idx];
1832            let (op, inputs) =
1833                if let Some((fused_op, fused_inputs)) = node_override[old_idx].clone() {
1834                    let mapped = fused_inputs.iter().map(|&i| old_to_new[i]).collect();
1835                    (fused_op, mapped)
1836                } else {
1837                    let mapped = node.inputs.iter().map(|&i| old_to_new[i]).collect();
1838                    (node.op.clone(), mapped)
1839                };
1840            let new_idx = new_graph.nodes.len();
1841            new_graph.nodes.push(GraphNode {
1842                op,
1843                inputs,
1844                dtype: node.dtype,
1845                shape: node.shape.clone(),
1846            });
1847            if let Some(name) = self.names.get(&old_idx) {
1848                new_graph.names.insert(new_idx, name.clone());
1849            }
1850        }
1851
1852        new_graph
1853    }
1854
1855    /// Rewrite the graph by fusing adjacent, single-input/single-output elementwise
1856    /// chains (see [`is_fusable_elementwise`]) into `Op::Fused` nodes.
1857    ///
1858    /// Separate from [`Graph::optimise`] on purpose: producing `Op::Fused` only helps
1859    /// once a lowering backend knows how to compile it (concatenate each member's
1860    /// kernel source and synthesize an entry point that runs them in sequence — see
1861    /// the `Op::Fused` doc comment). Call this explicitly once that backend support
1862    /// exists; don't fold it into `optimise()`, which every existing caller already
1863    /// depends on producing today's set of ops.
1864    ///
1865    /// Iterates [`Graph::fuse_elementwise_chain_pass`] to a fixed point: each call
1866    /// only merges one adjacent pair, so a chain longer than two nodes grows by one
1867    /// member per iteration until nothing more merges.
1868    pub fn fuse_elementwise_chains(&self) -> Graph {
1869        let mut graph = self.clone();
1870        loop {
1871            let (next, changed) = graph.fuse_elementwise_chain_pass();
1872            graph = next;
1873            if !changed {
1874                break;
1875            }
1876        }
1877        graph
1878    }
1879
1880    /// One round of adjacent-pair elementwise fusion: finds the first `(parent,
1881    /// child)` pair where `child` is [`is_fusable_elementwise`], `child` has exactly
1882    /// one input (`parent`), and `parent` has exactly one consumer (`child`), and
1883    /// merges them into a single `Op::Fused` node — growing `parent`'s existing
1884    /// `members` list if `parent` is itself already `Op::Fused`, or starting a new
1885    /// one otherwise. Returns the rewritten graph and whether a merge happened;
1886    /// `optimise()` calls this in a loop until it reports no change, so a chain
1887    /// longer than two nodes is grown one pair at a time across repeated calls
1888    /// rather than requiring a single-pass lookahead scan.
1889    fn fuse_elementwise_chain_pass(&self) -> (Graph, bool) {
1890        let n = self.nodes.len();
1891
1892        let mut n_consumers = vec![0usize; n];
1893        for node in &self.nodes {
1894            for &inp in &node.inputs {
1895                n_consumers[inp] += 1;
1896            }
1897        }
1898
1899        let mut dead = vec![false; n];
1900        let mut node_override: Vec<Option<(Op, Vec<usize>)>> = vec![None; n];
1901        let mut changed = false;
1902
1903        // `child_idx` indexes multiple parallel collections below (self.nodes,
1904        // n_consumers, node_override), not just one -- an iterator/enumerate() rewrite
1905        // wouldn't be clearer.
1906        #[allow(clippy::needless_range_loop)]
1907        for child_idx in 0..n {
1908            if changed {
1909                break;
1910            }
1911            if !is_fusable_elementwise(&self.nodes[child_idx].op) {
1912                continue;
1913            }
1914            if self.nodes[child_idx].inputs.len() != 1 {
1915                continue;
1916            }
1917            let parent_idx = self.nodes[child_idx].inputs[0];
1918            if n_consumers[parent_idx] != 1 {
1919                continue;
1920            }
1921            let parent_op = &self.nodes[parent_idx].op;
1922            let mut members = match parent_op {
1923                Op::Fused { members } => members.clone(),
1924                other if is_fusable_elementwise(other) => alloc::vec![other.clone()],
1925                _ => continue,
1926            };
1927            members.push(self.nodes[child_idx].op.clone());
1928
1929            dead[parent_idx] = true;
1930            node_override[child_idx] = Some((
1931                Op::Fused { members },
1932                self.nodes[parent_idx].inputs.clone(),
1933            ));
1934            changed = true;
1935        }
1936
1937        if !changed {
1938            return (self.clone(), false);
1939        }
1940
1941        let mut old_to_new = vec![0usize; n];
1942        let mut new_count = 0usize;
1943        for i in 0..n {
1944            if !dead[i] {
1945                old_to_new[i] = new_count;
1946                new_count += 1;
1947            }
1948        }
1949
1950        let mut new_graph = Graph::new();
1951        for old_idx in 0..n {
1952            if dead[old_idx] {
1953                continue;
1954            }
1955            let node = &self.nodes[old_idx];
1956            let (op, inputs) =
1957                if let Some((fused_op, fused_inputs)) = node_override[old_idx].clone() {
1958                    let mapped = fused_inputs.iter().map(|&i| old_to_new[i]).collect();
1959                    (fused_op, mapped)
1960                } else {
1961                    let mapped = node.inputs.iter().map(|&i| old_to_new[i]).collect();
1962                    (node.op.clone(), mapped)
1963                };
1964            let new_idx = new_graph.nodes.len();
1965            new_graph.nodes.push(GraphNode {
1966                op,
1967                inputs,
1968                dtype: node.dtype,
1969                shape: node.shape.clone(),
1970            });
1971            if let Some(name) = self.names.get(&old_idx) {
1972                new_graph.names.insert(new_idx, name.clone());
1973            }
1974        }
1975
1976        (new_graph, true)
1977    }
1978}
1979
1980/// Whether `op` is eligible to appear inside an `Op::Fused` chain.
1981///
1982/// Deliberately conservative: a fixed allowlist of simple, single-input/single-output
1983/// activation ops that are (a) genuinely shape-preserving and (b) already dispatched
1984/// through a uniform, fixed-block-size elementwise kernel (`BLOCK_SIZE = 1024`) in
1985/// `teeny-kernels`, so every candidate is known to share the same CTA/thread structure
1986/// without needing to consult a `RuntimeOp` (which doesn't exist yet at this point in
1987/// the pipeline — `fuse_elementwise_chains()` runs on the `teeny-core::Op` graph, before
1988/// `teeny-kernels`' lowering constructs any `RuntimeOp`). Excludes normalization ops
1989/// (`BatchNorm*`, `LayerNorm`, ...) even though they're also shape-preserving: several
1990/// of their `RuntimeOp` impls are unimplemented stubs today, and some aren't the plain
1991/// fixed-block-size elementwise shape this allowlist is scoped to.
1992pub fn is_fusable_elementwise(op: &Op) -> bool {
1993    matches!(
1994        op,
1995        Op::Relu | Op::Sigmoid | Op::Silu | Op::Tanh
1996    )
1997}
1998
1999// ---------------------------------------------------------------------------
2000// Shape inference — computes the output shape for each Op given an input shape
2001// ---------------------------------------------------------------------------
2002
2003fn infer_output_shape(op: &Op, inputs: &[&Shape]) -> Shape {
2004    // Constant has no tensor inputs — its shape is embedded in the op itself.
2005    if let Op::Constant { shape, .. } = op {
2006        return shape.clone();
2007    }
2008    // Zero-input ops that produce no tensor output.
2009    if matches!(op, Op::SequenceEmpty | Op::OptionalHasElement) {
2010        return vec![];
2011    }
2012    let input = inputs[0];
2013    match op {
2014        Op::Input => input.clone(),
2015
2016        Op::Fused { members } => members
2017            .iter()
2018            .fold(input.clone(), |shape, member| {
2019                infer_output_shape(member, &[&shape])
2020            }),
2021
2022        // Element-wise / shape-preserving — output shape = input shape
2023        Op::Relu
2024        | Op::Elu { .. }
2025        | Op::Selu
2026        | Op::Celu { .. }
2027        | Op::Gelu
2028        | Op::Mish
2029        | Op::Hardtanh { .. }
2030        | Op::Relu6
2031        | Op::Hardsigmoid
2032        | Op::Hardswish
2033        | Op::Hardshrink { .. }
2034        | Op::LeakyRelu { .. }
2035        | Op::Threshold { .. }
2036        | Op::Softsign
2037        | Op::Softshrink { .. }
2038        | Op::Softplus { .. }
2039        | Op::Sigmoid
2040        | Op::Silu
2041        | Op::Logsigmoid
2042        | Op::Tanh
2043        | Op::Tanhshrink
2044        | Op::Softmax { .. }
2045        | Op::BatchNorm1d { .. }
2046        | Op::BatchNorm2d { .. }
2047        | Op::BatchNorm3d { .. }
2048        | Op::LayerNorm { .. }
2049        | Op::RmsNorm { .. }
2050        | Op::GroupNorm { .. }
2051        | Op::InstanceNorm1d { .. }
2052        | Op::InstanceNorm2d { .. }
2053        | Op::InstanceNorm3d { .. } => input.clone(),
2054
2055        Op::Linear { out_features, .. } => {
2056            // [..., in_features] → [..., out_features]
2057            let mut out = input[..input.len() - 1].to_vec();
2058            out.push(Some(*out_features));
2059            out
2060        }
2061
2062        Op::Flatten => {
2063            // [N, C, H, W, ...] → [N, C*H*W*...]
2064            let rest = &input[1..];
2065            let flat: Option<usize> = rest
2066                .iter()
2067                .try_fold(1usize, |acc, dim| dim.map(|d| acc * d));
2068            vec![input[0], flat]
2069        }
2070
2071        // --- Convolution ---
2072        Op::Conv1d {
2073            out_channels,
2074            kernel_l,
2075            stride,
2076            padding,
2077            ..
2078        } => {
2079            // [N, C_in, L] → [N, C_out, L_out]
2080            let l_out = input[2].map(|l| (l + 2 * padding - kernel_l) / stride + 1);
2081            vec![input[0], Some(*out_channels), l_out]
2082        }
2083
2084        Op::Conv2d {
2085            out_channels,
2086            kernel_h,
2087            kernel_w,
2088            stride_h,
2089            stride_w,
2090            padding_h,
2091            padding_w,
2092            ..
2093        }
2094        | Op::Conv2dBnSilu {
2095            out_channels,
2096            kernel_h,
2097            kernel_w,
2098            stride_h,
2099            stride_w,
2100            padding_h,
2101            padding_w,
2102            ..
2103        } => {
2104            // [N, C_in, H, W] → [N, C_out, H_out, W_out]
2105            let h_out = input[2].map(|h| (h + 2 * padding_h - kernel_h) / stride_h + 1);
2106            let w_out = input[3].map(|w| (w + 2 * padding_w - kernel_w) / stride_w + 1);
2107            vec![input[0], Some(*out_channels), h_out, w_out]
2108        }
2109
2110        Op::Conv3d {
2111            out_channels,
2112            kernel_d,
2113            kernel_h,
2114            kernel_w,
2115            stride_d,
2116            stride_h,
2117            stride_w,
2118            padding_d,
2119            padding_h,
2120            padding_w,
2121            ..
2122        } => {
2123            // [N, C_in, D, H, W] → [N, C_out, D_out, H_out, W_out]
2124            let d_out = input[2].map(|d| (d + 2 * padding_d - kernel_d) / stride_d + 1);
2125            let h_out = input[3].map(|h| (h + 2 * padding_h - kernel_h) / stride_h + 1);
2126            let w_out = input[4].map(|w| (w + 2 * padding_w - kernel_w) / stride_w + 1);
2127            vec![input[0], Some(*out_channels), d_out, h_out, w_out]
2128        }
2129
2130        // --- Pooling ---
2131        Op::AvgPool1d { kernel_l, stride } | Op::MaxPool1d { kernel_l, stride } => {
2132            let l_out = input[2].map(|l| (l - kernel_l) / stride + 1);
2133            vec![input[0], input[1], l_out]
2134        }
2135
2136        Op::LpPool1d {
2137            kernel_l, stride, ..
2138        } => {
2139            let l_out = input[2].map(|l| (l - kernel_l) / stride + 1);
2140            vec![input[0], input[1], l_out]
2141        }
2142
2143        Op::AvgPool2d {
2144            kernel_h,
2145            kernel_w,
2146            stride_h,
2147            stride_w,
2148        } => {
2149            let h_out = input[2].map(|h| (h - kernel_h) / stride_h + 1);
2150            let w_out = input[3].map(|w| (w - kernel_w) / stride_w + 1);
2151            vec![input[0], input[1], h_out, w_out]
2152        }
2153
2154        Op::MaxPool2d {
2155            kernel_h,
2156            kernel_w,
2157            stride_h,
2158            stride_w,
2159            pad_h,
2160            pad_w,
2161        } => {
2162            let h_out = input[2].map(|h| (h + 2 * pad_h - kernel_h) / stride_h + 1);
2163            let w_out = input[3].map(|w| (w + 2 * pad_w - kernel_w) / stride_w + 1);
2164            vec![input[0], input[1], h_out, w_out]
2165        }
2166
2167        Op::LpPool2d {
2168            kernel_h,
2169            kernel_w,
2170            stride_h,
2171            stride_w,
2172            ..
2173        } => {
2174            let h_out = input[2].map(|h| (h - kernel_h) / stride_h + 1);
2175            let w_out = input[3].map(|w| (w - kernel_w) / stride_w + 1);
2176            vec![input[0], input[1], h_out, w_out]
2177        }
2178
2179        Op::AvgPool3d {
2180            kernel_d,
2181            kernel_h,
2182            kernel_w,
2183            stride_d,
2184            stride_h,
2185            stride_w,
2186        }
2187        | Op::MaxPool3d {
2188            kernel_d,
2189            kernel_h,
2190            kernel_w,
2191            stride_d,
2192            stride_h,
2193            stride_w,
2194        } => {
2195            let d_out = input[2].map(|d| (d - kernel_d) / stride_d + 1);
2196            let h_out = input[3].map(|h| (h - kernel_h) / stride_h + 1);
2197            let w_out = input[4].map(|w| (w - kernel_w) / stride_w + 1);
2198            vec![input[0], input[1], d_out, h_out, w_out]
2199        }
2200
2201        Op::LpPool3d {
2202            kernel_d,
2203            kernel_h,
2204            kernel_w,
2205            stride_d,
2206            stride_h,
2207            stride_w,
2208            ..
2209        } => {
2210            let d_out = input[2].map(|d| (d - kernel_d) / stride_d + 1);
2211            let h_out = input[3].map(|h| (h - kernel_h) / stride_h + 1);
2212            let w_out = input[4].map(|w| (w - kernel_w) / stride_w + 1);
2213            vec![input[0], input[1], d_out, h_out, w_out]
2214        }
2215
2216        // --- Upsample ---
2217        Op::UpsampleNearest2d { scale_h, scale_w } => {
2218            // [N, C, H, W] → [N, C, H * scale_h, W * scale_w]
2219            let h_out = input[2].map(|h| h * scale_h);
2220            let w_out = input[3].map(|w| w * scale_w);
2221            vec![input[0], input[1], h_out, w_out]
2222        }
2223
2224        // --- Padding ---
2225        Op::ConstantPad1d {
2226            pad_left,
2227            pad_right,
2228            ..
2229        }
2230        | Op::ReflectionPad1d {
2231            pad_left,
2232            pad_right,
2233        }
2234        | Op::ReplicationPad1d {
2235            pad_left,
2236            pad_right,
2237        }
2238        | Op::CircularPad1d {
2239            pad_left,
2240            pad_right,
2241        } => {
2242            // [N, C, L] → [N, C, L + pad_left + pad_right]
2243            let l_out = input[2].map(|l| l + pad_left + pad_right);
2244            vec![input[0], input[1], l_out]
2245        }
2246
2247        Op::ConstantPad2d {
2248            pad_l,
2249            pad_r,
2250            pad_t,
2251            pad_b,
2252            ..
2253        }
2254        | Op::ReflectionPad2d {
2255            pad_l,
2256            pad_r,
2257            pad_t,
2258            pad_b,
2259        }
2260        | Op::ReplicationPad2d {
2261            pad_l,
2262            pad_r,
2263            pad_t,
2264            pad_b,
2265        }
2266        | Op::CircularPad2d {
2267            pad_l,
2268            pad_r,
2269            pad_t,
2270            pad_b,
2271        } => {
2272            // [N, C, H, W] → [N, C, H + pad_t + pad_b, W + pad_l + pad_r]
2273            let h_out = input[2].map(|h| h + pad_t + pad_b);
2274            let w_out = input[3].map(|w| w + pad_l + pad_r);
2275            vec![input[0], input[1], h_out, w_out]
2276        }
2277
2278        Op::ConstantPad3d {
2279            pad_d1,
2280            pad_d2,
2281            pad_h1,
2282            pad_h2,
2283            pad_w1,
2284            pad_w2,
2285            ..
2286        }
2287        | Op::ReflectionPad3d {
2288            pad_d1,
2289            pad_d2,
2290            pad_h1,
2291            pad_h2,
2292            pad_w1,
2293            pad_w2,
2294        }
2295        | Op::ReplicationPad3d {
2296            pad_d1,
2297            pad_d2,
2298            pad_h1,
2299            pad_h2,
2300            pad_w1,
2301            pad_w2,
2302        }
2303        | Op::CircularPad3d {
2304            pad_d1,
2305            pad_d2,
2306            pad_h1,
2307            pad_h2,
2308            pad_w1,
2309            pad_w2,
2310        } => {
2311            // [N, C, D, H, W] → padded on each spatial dim
2312            let d_out = input[2].map(|d| d + pad_d1 + pad_d2);
2313            let h_out = input[3].map(|h| h + pad_h1 + pad_h2);
2314            let w_out = input[4].map(|w| w + pad_w1 + pad_w2);
2315            vec![input[0], input[1], d_out, h_out, w_out]
2316        }
2317
2318        Op::Attention { .. } => input.clone(),
2319
2320        Op::Add => input.clone(),
2321
2322        Op::ChannelChunk { chunk_c, .. } => {
2323            // [N, c_total, H, W] → [N, chunk_c, H, W]
2324            vec![input[0], Some(*chunk_c), input[2], input[3]]
2325        }
2326
2327        Op::ChannelCat { c_total } => {
2328            // multi-input; c_total encodes the output channel count
2329            vec![input[0], Some(*c_total), input[2], input[3]]
2330        }
2331
2332        Op::ChannelBiasAdd { .. } => input.to_vec(),
2333
2334        Op::Custom { data } => data.infer_output_shape(inputs),
2335
2336        // -------------------------------------------------------------------
2337        // ONNX-sourced ops — shape inference below.
2338        // For ops whose output shape equals the primary input shape (element-
2339        // wise, identity-like, or shape-tracked via ONNX value_info), we just
2340        // clone the input shape.  Ops with genuinely different output shapes
2341        // have explicit arms.
2342        // -------------------------------------------------------------------
2343
2344        // Unary element-wise — output shape = input shape
2345        Op::Abs
2346        | Op::Neg
2347        | Op::Ceil
2348        | Op::Floor
2349        | Op::Round
2350        | Op::Sqrt
2351        | Op::Reciprocal
2352        | Op::Exp
2353        | Op::Log
2354        | Op::Erf
2355        | Op::Sign
2356        | Op::IsNaN
2357        | Op::IsInf { .. }
2358        | Op::Not
2359        | Op::BitwiseNot
2360        | Op::Sin
2361        | Op::Cos
2362        | Op::Tan
2363        | Op::Asin
2364        | Op::Acos
2365        | Op::Atan
2366        | Op::Sinh
2367        | Op::Cosh
2368        | Op::Asinh
2369        | Op::Acosh
2370        | Op::Atanh
2371        | Op::PRelu
2372        | Op::ThresholdedRelu { .. }
2373        | Op::Shrink { .. }
2374        | Op::Clip
2375        | Op::Swish
2376        | Op::LogSoftmax { .. }
2377        | Op::Hardmax { .. }
2378        | Op::Dropout { .. }
2379        | Op::Identity
2380        | Op::LRN { .. }
2381        | Op::MeanVarianceNormalization { .. }
2382        | Op::LpNormalization { .. }
2383        | Op::Pad { .. }
2384        | Op::ReverseSequence { .. }
2385        | Op::Trilu { .. }
2386        | Op::CumSum { .. }
2387        | Op::CumProd { .. }
2388        | Op::QuantizeLinear { .. }
2389        | Op::DequantizeLinear { .. }
2390        | Op::DynamicQuantizeLinear
2391        | Op::Bernoulli { .. }
2392        | Op::RandomUniformLike { .. }
2393        | Op::EyeLike { .. }
2394        | Op::RotaryEmbedding
2395        | Op::MultiHeadAttention { .. }
2396        | Op::FlexAttention { .. }
2397        | Op::LinearAttention { .. }
2398        | Op::CausalConvWithState { .. } => input.clone(),
2399
2400        // Binary / variadic element-wise — approximate as first-input shape
2401        Op::Mul
2402        | Op::Sub
2403        | Op::Div
2404        | Op::Pow
2405        | Op::Mod { .. }
2406        | Op::ElemMin
2407        | Op::ElemMax
2408        | Op::ElemMean
2409        | Op::ElemSum
2410        | Op::Equal
2411        | Op::Greater
2412        | Op::GreaterOrEqual
2413        | Op::Less
2414        | Op::LessOrEqual
2415        | Op::And
2416        | Op::Or
2417        | Op::Xor
2418        | Op::BitwiseAnd
2419        | Op::BitwiseOr
2420        | Op::BitwiseXor
2421        | Op::BitShift { .. }
2422        | Op::Cast { .. }
2423        | Op::CastLike
2424        | Op::BitCast { .. }
2425        | Op::Where => input.clone(),
2426
2427        // Structural ops where output shape = input shape or is unknown at
2428        // static inference time (ONNX value_info carries the true shape).
2429        Op::Reshape
2430        | Op::Squeeze { .. }
2431        | Op::Unsqueeze { .. }
2432        | Op::Slice
2433        | Op::Gather { .. }
2434        | Op::GatherElements { .. }
2435        | Op::GatherND { .. }
2436        | Op::ScatterElements { .. }
2437        | Op::ScatterND
2438        | Op::Tile
2439        | Op::Expand
2440        | Op::Compress { .. }
2441        | Op::Range
2442        | Op::ConstantOfShape { .. }
2443        | Op::NonZero
2444        | Op::Scatter { .. }
2445        | Op::TensorScatter
2446        | Op::Resize { .. }
2447        | Op::GridSample { .. }
2448        | Op::AffineGrid { .. }
2449        | Op::CenterCropPad { .. } => input.clone(),
2450
2451        Op::Transpose { perm } => {
2452            if perm.is_empty() {
2453                input.iter().rev().cloned().collect()
2454            } else {
2455                perm.iter()
2456                    .map(|&i| input.get(i).copied().unwrap_or(None))
2457                    .collect()
2458            }
2459        }
2460
2461        Op::Concat { axis } => {
2462            let rank = input.len();
2463            if rank == 0 {
2464                return input.clone();
2465            }
2466            let ax = axis.rem_euclid(rank as i64) as usize;
2467            let mut out = input.clone();
2468            // Sum the concatenated axis across all inputs.
2469            out[ax] = inputs
2470                .iter()
2471                .try_fold(0usize, |acc, s| {
2472                    s.get(ax).copied().unwrap_or(None).map(|d| acc + d)
2473                })
2474                .map(Some)
2475                .unwrap_or(None);
2476            out
2477        }
2478
2479        Op::Split { axis, num_outputs } => {
2480            let rank = input.len();
2481            if rank == 0 {
2482                return input.clone();
2483            }
2484            let ax = axis.rem_euclid(rank as i64) as usize;
2485            let mut out = input.clone();
2486            out[ax] = input[ax].map(|d| d / num_outputs.max(&1));
2487            out
2488        }
2489
2490        Op::ShapeOf { start, end } => {
2491            let rank = input.len() as i64;
2492            let s = start.rem_euclid(rank.max(1));
2493            let e = end.rem_euclid(rank.max(1));
2494            vec![Some((e - s).max(0) as usize)]
2495        }
2496
2497        Op::SizeOf => vec![Some(1)],
2498
2499        Op::Gemm {
2500            trans_a, trans_b, ..
2501        } => {
2502            let m = if *trans_a {
2503                input.get(1)
2504            } else {
2505                input.first()
2506            }
2507            .copied()
2508            .unwrap_or(None);
2509            let n = if inputs.len() >= 2 {
2510                let b = inputs[1];
2511                if *trans_b { b.first() } else { b.get(1) }
2512                    .copied()
2513                    .unwrap_or(None)
2514            } else {
2515                None
2516            };
2517            vec![m, n]
2518        }
2519
2520        Op::MatMul | Op::MatMulInteger | Op::QLinearMatMul => {
2521            if inputs.len() >= 2 && !input.is_empty() {
2522                let other = inputs[1];
2523                let mut out = input[..input.len() - 1].to_vec();
2524                out.push(other.last().copied().unwrap_or(None));
2525                out
2526            } else {
2527                input.clone()
2528            }
2529        }
2530
2531        Op::Einsum { .. }
2532        | Op::Det
2533        | Op::Col2Im { .. }
2534        | Op::ConvInteger { .. }
2535        | Op::DeformConv { .. }
2536        | Op::QLinearConv { .. } => input.clone(),
2537
2538        Op::ConvTranspose {
2539            out_channels,
2540            kernel_h,
2541            kernel_w,
2542            stride_h,
2543            stride_w,
2544            padding_h,
2545            padding_w,
2546            output_padding_h,
2547            output_padding_w,
2548            ..
2549        } => {
2550            let h_out =
2551                input[2].map(|h| (h - 1) * stride_h - 2 * padding_h + kernel_h + output_padding_h);
2552            let w_out =
2553                input[3].map(|w| (w - 1) * stride_w - 2 * padding_w + kernel_w + output_padding_w);
2554            vec![input[0], Some(*out_channels), h_out, w_out]
2555        }
2556
2557        Op::ReduceSum { keepdims, .. }
2558        | Op::ReduceMean { keepdims, .. }
2559        | Op::ReduceMax { keepdims, .. }
2560        | Op::ReduceMin { keepdims, .. }
2561        | Op::ReduceProd { keepdims, .. }
2562        | Op::ReduceL1 { keepdims, .. }
2563        | Op::ReduceL2 { keepdims, .. }
2564        | Op::ReduceLogSum { keepdims, .. }
2565        | Op::ReduceLogSumExp { keepdims, .. }
2566        | Op::ReduceSumSquare { keepdims, .. } => {
2567            // Without axis info at static-inference time, approximate:
2568            // keepdims=true → same rank, keepdims=false → reduce all → scalar.
2569            if *keepdims {
2570                input.clone()
2571            } else {
2572                vec![Some(1)]
2573            }
2574        }
2575
2576        Op::ArgMax { axis, keepdims, .. } | Op::ArgMin { axis, keepdims, .. } => {
2577            if input.is_empty() {
2578                return vec![];
2579            }
2580            let ax = axis.rem_euclid(input.len() as i64) as usize;
2581            if *keepdims {
2582                let mut out = input.clone();
2583                out[ax] = Some(1);
2584                out
2585            } else {
2586                let mut out = input.clone();
2587                out.remove(ax);
2588                out
2589            }
2590        }
2591
2592        Op::GlobalAvgPool | Op::GlobalMaxPool => {
2593            let mut out = input[..2.min(input.len())].to_vec();
2594            for _ in 2..input.len() {
2595                out.push(Some(1));
2596            }
2597            out
2598        }
2599
2600        Op::Lstm {
2601            hidden_size,
2602            bidirectional,
2603            ..
2604        }
2605        | Op::Gru {
2606            hidden_size,
2607            bidirectional,
2608            ..
2609        }
2610        | Op::Rnn {
2611            hidden_size,
2612            bidirectional,
2613            ..
2614        } => {
2615            let num_dirs: usize = if *bidirectional { 2 } else { 1 };
2616            // [seq_len, num_directions, batch, hidden_size] (approximate)
2617            vec![
2618                input.first().copied().unwrap_or(None),
2619                Some(num_dirs),
2620                input.get(1).copied().unwrap_or(None),
2621                Some(*hidden_size),
2622            ]
2623        }
2624
2625        Op::SpaceToDepth { blocksize } => {
2626            let c_out = input[1].map(|c| c * blocksize * blocksize);
2627            let h_out = input[2].map(|h| h / blocksize);
2628            let w_out = input[3].map(|w| w / blocksize);
2629            vec![input[0], c_out, h_out, w_out]
2630        }
2631
2632        Op::DepthToSpace { blocksize, .. } => {
2633            let c_out = input[1].map(|c| c / (blocksize * blocksize));
2634            let h_out = input[2].map(|h| h * blocksize);
2635            let w_out = input[3].map(|w| w * blocksize);
2636            vec![input[0], c_out, h_out, w_out]
2637        }
2638
2639        Op::RoiAlign {
2640            output_h, output_w, ..
2641        } => {
2642            vec![input[0], input[1], Some(*output_h), Some(*output_w)]
2643        }
2644
2645        Op::MaxUnpool {
2646            kernel_h,
2647            kernel_w,
2648            stride_h,
2649            stride_w,
2650        } => {
2651            let h_out = input[2].map(|h| (h - 1) * stride_h + kernel_h);
2652            let w_out = input[3].map(|w| (w - 1) * stride_w + kernel_w);
2653            vec![input[0], input[1], h_out, w_out]
2654        }
2655
2656        Op::NonMaxSuppression { .. } => vec![None, Some(3)],
2657
2658        Op::TopK { axis, .. } => {
2659            // Second input is k (runtime). Return input shape as approximation.
2660            let _ = axis;
2661            input.clone()
2662        }
2663
2664        Op::Unique { .. } => input.clone(),
2665        Op::OneHot { .. } => input.clone(),
2666
2667        Op::NegativeLogLikelihoodLoss { .. } | Op::SoftmaxCrossEntropyLoss { .. } => {
2668            vec![Some(1)]
2669        }
2670
2671        Op::Dft { onesided, .. } => {
2672            // DFT last dim: full=N, onesided=N/2+1. Approximate.
2673            if *onesided && input.len() >= 2 {
2674                let mut out = input.clone();
2675                *out.last_mut().unwrap() = None;
2676                out
2677            } else {
2678                input.clone()
2679            }
2680        }
2681
2682        Op::Stft
2683        | Op::MelWeightMatrix
2684        | Op::HannWindow { .. }
2685        | Op::BlackmanWindow { .. }
2686        | Op::HammingWindow { .. } => input.clone(),
2687
2688        Op::SequenceAt
2689        | Op::SequenceConstruct
2690        | Op::SequenceErase
2691        | Op::SequenceInsert
2692        | Op::SequenceLength
2693        | Op::SequenceMap
2694        | Op::SplitToSequence { .. }
2695        | Op::ConcatFromSequence { .. }
2696        | Op::OptionalGetElement
2697        | Op::Loop
2698        | Op::Scan { .. }
2699        | Op::If
2700        | Op::Adagrad
2701        | Op::Adam
2702        | Op::Momentum
2703        | Op::Gradient
2704        | Op::StringNormalizer
2705        | Op::RegexFullMatch { .. }
2706        | Op::StringConcat
2707        | Op::StringSplit
2708        | Op::TfIdfVectorizer
2709        | Op::LabelEncoder
2710        | Op::ArrayFeatureExtractor
2711        | Op::Binarizer { .. }
2712        | Op::TreeEnsemble
2713        | Op::ImageDecoder => input.clone(),
2714
2715        // Handled by early returns above the match; arms required for exhaustiveness.
2716        Op::Constant { shape, .. } => shape.clone(),
2717        Op::SequenceEmpty | Op::OptionalHasElement => vec![],
2718    }
2719}
2720
2721// ---------------------------------------------------------------------------
2722// SymTensor — a tensor that writes to the graph on every operation
2723// ---------------------------------------------------------------------------
2724
2725/// A symbolic tensor handle. Every layer operation on a `SymTensor` records
2726/// itself in the shared `Graph` and returns a new `SymTensor` pointing to
2727/// the new node. Cloning is cheap — it shares the graph via `Rc`.
2728#[derive(Clone)]
2729pub struct SymTensor {
2730    /// This tensor's node index in `graph`.
2731    pub node_id: usize,
2732    /// The shared graph this tensor's operations record into.
2733    pub graph: Rc<RefCell<Graph>>,
2734    /// This tensor's dtype.
2735    pub dtype: DtypeRepr,
2736    /// Output shape of this tensor. `None` in a slot means a dynamic/unknown
2737    /// dimension (e.g. the batch axis).
2738    pub shape: Shape,
2739}
2740
2741// SymTensor satisfies Tensor<D, RANK> for any D and RANK — shape is tracked
2742// dynamically at runtime; the compile-time SHAPE constant is zeroed (unused).
2743impl<D: Dtype, const RANK: usize> RankedTensor<D, RANK> for SymTensor {
2744    const SHAPE: [usize; RANK] = [0; RANK];
2745}
2746impl<D: Dtype, const RANK: usize> Tensor<D, RANK> for SymTensor {}
2747
2748impl SymTensor {
2749    /// Create an input placeholder, returning both the tensor and the shared
2750    /// graph handle. Keep the graph handle to inspect the result after tracing.
2751    ///
2752    /// Use `None` for dynamic dimensions (e.g. the batch axis):
2753    /// ```ignore
2754    /// SymTensor::input(DtypeRepr::F32, vec![None, Some(784)])
2755    /// ```
2756    pub fn input(dtype: DtypeRepr, shape: Shape) -> (Self, Rc<RefCell<Graph>>) {
2757        let graph = Rc::new(RefCell::new(Graph::new()));
2758        let node_id = graph
2759            .borrow_mut()
2760            .add_node(Op::Input, vec![], dtype, shape.clone());
2761        let tensor = Self {
2762            node_id,
2763            graph: graph.clone(),
2764            dtype,
2765            shape,
2766        };
2767        (tensor, graph)
2768    }
2769
2770    /// Number of dimensions of this tensor.
2771    pub fn rank(&self) -> usize {
2772        self.shape.len()
2773    }
2774
2775    fn record(&self, op: Op) -> Self {
2776        let output_shape = infer_output_shape(&op, &[&self.shape]);
2777        self.record_with_shape(op, output_shape)
2778    }
2779
2780    fn record_with_shape(&self, op: Op, shape: Shape) -> Self {
2781        let node_id =
2782            self.graph
2783                .borrow_mut()
2784                .add_node(op, vec![self.node_id], self.dtype, shape.clone());
2785        Self {
2786            node_id,
2787            graph: self.graph.clone(),
2788            dtype: self.dtype,
2789            shape,
2790        }
2791    }
2792
2793    /// Record a custom op whose output shape is determined by [`CustomOp::infer_output_shape`].
2794    ///
2795    /// `self` is the primary (first) input.  Pass additional inputs via
2796    /// `other_inputs`.  Pass `dtype` to override the output element type;
2797    /// defaults to the primary input's dtype.
2798    pub fn record_custom(
2799        &self,
2800        data: CustomData,
2801        other_inputs: &[&SymTensor],
2802        dtype: Option<DtypeRepr>,
2803    ) -> Self {
2804        let mut shapes: Vec<&Shape> = vec![&self.shape];
2805        shapes.extend(other_inputs.iter().map(|t| &t.shape));
2806        let output_shape = data.infer_output_shape(&shapes);
2807
2808        let mut input_ids: Vec<usize> = vec![self.node_id];
2809        input_ids.extend(other_inputs.iter().map(|t| t.node_id));
2810
2811        let out_dtype = dtype.unwrap_or(self.dtype);
2812        let node_id = self.graph.borrow_mut().add_node(
2813            Op::Custom { data },
2814            input_ids,
2815            out_dtype,
2816            output_shape.clone(),
2817        );
2818        Self {
2819            node_id,
2820            graph: self.graph.clone(),
2821            dtype: out_dtype,
2822            shape: output_shape,
2823        }
2824    }
2825}
2826
2827// ---------------------------------------------------------------------------
2828// Layer<SymTensor> impls — record op instead of computing
2829// ---------------------------------------------------------------------------
2830
2831// --- Linear / MLP ---
2832
2833impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Linear<D, SymTensor, SymTensor, RANK> {
2834    type Output = SymTensor;
2835    fn call(&self, input: SymTensor) -> SymTensor {
2836        input.record(Op::Linear {
2837            in_features: self.in_features,
2838            out_features: self.out_features,
2839            has_bias: self.has_bias,
2840        })
2841    }
2842}
2843
2844impl<D: Dtype> Layer<SymTensor> for Flatten<D, SymTensor, SymTensor> {
2845    type Output = SymTensor;
2846    fn call(&self, input: SymTensor) -> SymTensor {
2847        input.record(Op::Flatten)
2848    }
2849}
2850
2851// --- Normalisation ---
2852
2853impl<D: Dtype, const RANK: usize> Layer<SymTensor> for BatchNorm1d<D, SymTensor, SymTensor, RANK> {
2854    type Output = SymTensor;
2855    fn call(&self, input: SymTensor) -> SymTensor {
2856        input.record(Op::BatchNorm1d {
2857            num_features: self.num_features,
2858            eps: self.eps,
2859            momentum: self.momentum,
2860            affine: self.affine,
2861            track_running_stats: self.track_running_stats,
2862        })
2863    }
2864}
2865
2866impl<D: Dtype, const RANK: usize> Layer<SymTensor> for BatchNorm2d<D, SymTensor, SymTensor, RANK> {
2867    type Output = SymTensor;
2868    fn call(&self, input: SymTensor) -> SymTensor {
2869        input.record(Op::BatchNorm2d {
2870            num_features: self.num_features,
2871            eps: self.eps,
2872            momentum: self.momentum,
2873            affine: self.affine,
2874            track_running_stats: self.track_running_stats,
2875        })
2876    }
2877}
2878
2879impl<D: Dtype, const RANK: usize> Layer<SymTensor> for BatchNorm3d<D, SymTensor, SymTensor, RANK> {
2880    type Output = SymTensor;
2881    fn call(&self, input: SymTensor) -> SymTensor {
2882        input.record(Op::BatchNorm3d {
2883            num_features: self.num_features,
2884            eps: self.eps,
2885            momentum: self.momentum,
2886            affine: self.affine,
2887            track_running_stats: self.track_running_stats,
2888        })
2889    }
2890}
2891
2892impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LayerNorm<D, SymTensor, SymTensor, RANK> {
2893    type Output = SymTensor;
2894    fn call(&self, input: SymTensor) -> SymTensor {
2895        input.record(Op::LayerNorm {
2896            normalized_shape: self.normalized_shape.clone(),
2897            eps: self.eps,
2898            affine: self.affine,
2899        })
2900    }
2901}
2902
2903impl<D: Dtype, const RANK: usize> Layer<SymTensor> for RmsNorm<D, SymTensor, SymTensor, RANK> {
2904    type Output = SymTensor;
2905    fn call(&self, input: SymTensor) -> SymTensor {
2906        input.record(Op::RmsNorm {
2907            normalized_shape: self.normalized_shape.clone(),
2908            eps: self.eps,
2909            affine: self.affine,
2910        })
2911    }
2912}
2913
2914impl<D: Dtype, const RANK: usize> Layer<SymTensor> for GroupNorm<D, SymTensor, SymTensor, RANK> {
2915    type Output = SymTensor;
2916    fn call(&self, input: SymTensor) -> SymTensor {
2917        input.record(Op::GroupNorm {
2918            num_groups: self.num_groups,
2919            num_channels: self.num_channels,
2920            eps: self.eps,
2921            affine: self.affine,
2922        })
2923    }
2924}
2925
2926impl<D: Dtype, const RANK: usize> Layer<SymTensor>
2927    for InstanceNorm1d<D, SymTensor, SymTensor, RANK>
2928{
2929    type Output = SymTensor;
2930    fn call(&self, input: SymTensor) -> SymTensor {
2931        input.record(Op::InstanceNorm1d {
2932            num_features: self.num_features,
2933            eps: self.eps,
2934            momentum: self.momentum,
2935            affine: self.affine,
2936            track_running_stats: self.track_running_stats,
2937        })
2938    }
2939}
2940
2941impl<D: Dtype, const RANK: usize> Layer<SymTensor>
2942    for InstanceNorm2d<D, SymTensor, SymTensor, RANK>
2943{
2944    type Output = SymTensor;
2945    fn call(&self, input: SymTensor) -> SymTensor {
2946        input.record(Op::InstanceNorm2d {
2947            num_features: self.num_features,
2948            eps: self.eps,
2949            momentum: self.momentum,
2950            affine: self.affine,
2951            track_running_stats: self.track_running_stats,
2952        })
2953    }
2954}
2955
2956impl<D: Dtype, const RANK: usize> Layer<SymTensor>
2957    for InstanceNorm3d<D, SymTensor, SymTensor, RANK>
2958{
2959    type Output = SymTensor;
2960    fn call(&self, input: SymTensor) -> SymTensor {
2961        input.record(Op::InstanceNorm3d {
2962            num_features: self.num_features,
2963            eps: self.eps,
2964            momentum: self.momentum,
2965            affine: self.affine,
2966            track_running_stats: self.track_running_stats,
2967        })
2968    }
2969}
2970
2971// --- Convolution ---
2972
2973impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Conv1d<D, SymTensor, SymTensor, RANK> {
2974    type Output = SymTensor;
2975    fn call(&self, input: SymTensor) -> SymTensor {
2976        input.record(Op::Conv1d {
2977            in_channels: self.in_channels,
2978            out_channels: self.out_channels,
2979            kernel_l: self.kernel_l,
2980            stride: self.stride,
2981            padding: self.padding,
2982            has_bias: self.has_bias,
2983        })
2984    }
2985}
2986
2987impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Conv2d<D, SymTensor, SymTensor, RANK> {
2988    type Output = SymTensor;
2989    fn call(&self, input: SymTensor) -> SymTensor {
2990        input.record(Op::Conv2d {
2991            in_channels: self.in_channels,
2992            out_channels: self.out_channels,
2993            kernel_h: self.kernel_h,
2994            kernel_w: self.kernel_w,
2995            stride_h: self.stride_h,
2996            stride_w: self.stride_w,
2997            padding_h: self.padding_h,
2998            padding_w: self.padding_w,
2999            groups: self.groups,
3000            has_bias: self.has_bias,
3001        })
3002    }
3003}
3004
3005impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Conv3d<D, SymTensor, SymTensor, RANK> {
3006    type Output = SymTensor;
3007    fn call(&self, input: SymTensor) -> SymTensor {
3008        input.record(Op::Conv3d {
3009            in_channels: self.in_channels,
3010            out_channels: self.out_channels,
3011            kernel_d: self.kernel_d,
3012            kernel_h: self.kernel_h,
3013            kernel_w: self.kernel_w,
3014            stride_d: self.stride_d,
3015            stride_h: self.stride_h,
3016            stride_w: self.stride_w,
3017            padding_d: self.padding_d,
3018            padding_h: self.padding_h,
3019            padding_w: self.padding_w,
3020            has_bias: self.has_bias,
3021        })
3022    }
3023}
3024
3025// --- Pooling ---
3026
3027impl<D: Dtype, const RANK: usize> Layer<SymTensor> for AvgPool1d<D, SymTensor, SymTensor, RANK> {
3028    type Output = SymTensor;
3029    fn call(&self, input: SymTensor) -> SymTensor {
3030        input.record(Op::AvgPool1d {
3031            kernel_l: self.kernel_l,
3032            stride: self.stride,
3033        })
3034    }
3035}
3036
3037impl<D: Dtype, const RANK: usize> Layer<SymTensor> for AvgPool2d<D, SymTensor, SymTensor, RANK> {
3038    type Output = SymTensor;
3039    fn call(&self, input: SymTensor) -> SymTensor {
3040        input.record(Op::AvgPool2d {
3041            kernel_h: self.kernel_h,
3042            kernel_w: self.kernel_w,
3043            stride_h: self.stride_h,
3044            stride_w: self.stride_w,
3045        })
3046    }
3047}
3048
3049impl<D: Dtype, const RANK: usize> Layer<SymTensor> for AvgPool3d<D, SymTensor, SymTensor, RANK> {
3050    type Output = SymTensor;
3051    fn call(&self, input: SymTensor) -> SymTensor {
3052        input.record(Op::AvgPool3d {
3053            kernel_d: self.kernel_d,
3054            kernel_h: self.kernel_h,
3055            kernel_w: self.kernel_w,
3056            stride_d: self.stride_d,
3057            stride_h: self.stride_h,
3058            stride_w: self.stride_w,
3059        })
3060    }
3061}
3062
3063impl<D: Dtype, const RANK: usize> Layer<SymTensor> for MaxPool1d<D, SymTensor, SymTensor, RANK> {
3064    type Output = SymTensor;
3065    fn call(&self, input: SymTensor) -> SymTensor {
3066        input.record(Op::MaxPool1d {
3067            kernel_l: self.kernel_l,
3068            stride: self.stride,
3069        })
3070    }
3071}
3072
3073impl<D: Dtype, const RANK: usize> Layer<SymTensor> for MaxPool2d<D, SymTensor, SymTensor, RANK> {
3074    type Output = SymTensor;
3075    fn call(&self, input: SymTensor) -> SymTensor {
3076        input.record(Op::MaxPool2d {
3077            kernel_h: self.kernel_h,
3078            kernel_w: self.kernel_w,
3079            stride_h: self.stride_h,
3080            stride_w: self.stride_w,
3081            pad_h: self.padding_h,
3082            pad_w: self.padding_w,
3083        })
3084    }
3085}
3086
3087impl<D: Dtype, const RANK: usize> Layer<SymTensor> for MaxPool3d<D, SymTensor, SymTensor, RANK> {
3088    type Output = SymTensor;
3089    fn call(&self, input: SymTensor) -> SymTensor {
3090        input.record(Op::MaxPool3d {
3091            kernel_d: self.kernel_d,
3092            kernel_h: self.kernel_h,
3093            kernel_w: self.kernel_w,
3094            stride_d: self.stride_d,
3095            stride_h: self.stride_h,
3096            stride_w: self.stride_w,
3097        })
3098    }
3099}
3100
3101impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LpPool1d<D, SymTensor, SymTensor, RANK> {
3102    type Output = SymTensor;
3103    fn call(&self, input: SymTensor) -> SymTensor {
3104        input.record(Op::LpPool1d {
3105            kernel_l: self.kernel_l,
3106            stride: self.stride,
3107            p: self.p,
3108        })
3109    }
3110}
3111
3112impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LpPool2d<D, SymTensor, SymTensor, RANK> {
3113    type Output = SymTensor;
3114    fn call(&self, input: SymTensor) -> SymTensor {
3115        input.record(Op::LpPool2d {
3116            kernel_h: self.kernel_h,
3117            kernel_w: self.kernel_w,
3118            stride_h: self.stride_h,
3119            stride_w: self.stride_w,
3120            p: self.p,
3121        })
3122    }
3123}
3124
3125impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LpPool3d<D, SymTensor, SymTensor, RANK> {
3126    type Output = SymTensor;
3127    fn call(&self, input: SymTensor) -> SymTensor {
3128        input.record(Op::LpPool3d {
3129            kernel_d: self.kernel_d,
3130            kernel_h: self.kernel_h,
3131            kernel_w: self.kernel_w,
3132            stride_d: self.stride_d,
3133            stride_h: self.stride_h,
3134            stride_w: self.stride_w,
3135            p: self.p,
3136        })
3137    }
3138}
3139
3140// --- Padding ---
3141
3142impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3143    for ConstantPad1d<D, SymTensor, SymTensor, RANK>
3144{
3145    type Output = SymTensor;
3146    fn call(&self, input: SymTensor) -> SymTensor {
3147        input.record(Op::ConstantPad1d {
3148            pad_left: self.pad_left,
3149            pad_right: self.pad_right,
3150            value: self.value,
3151        })
3152    }
3153}
3154
3155impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3156    for ConstantPad2d<D, SymTensor, SymTensor, RANK>
3157{
3158    type Output = SymTensor;
3159    fn call(&self, input: SymTensor) -> SymTensor {
3160        input.record(Op::ConstantPad2d {
3161            pad_l: self.pad_l,
3162            pad_r: self.pad_r,
3163            pad_t: self.pad_t,
3164            pad_b: self.pad_b,
3165            value: self.value,
3166        })
3167    }
3168}
3169
3170impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3171    for ConstantPad3d<D, SymTensor, SymTensor, RANK>
3172{
3173    type Output = SymTensor;
3174    fn call(&self, input: SymTensor) -> SymTensor {
3175        input.record(Op::ConstantPad3d {
3176            pad_d1: self.pad_d1,
3177            pad_d2: self.pad_d2,
3178            pad_h1: self.pad_h1,
3179            pad_h2: self.pad_h2,
3180            pad_w1: self.pad_w1,
3181            pad_w2: self.pad_w2,
3182            value: self.value,
3183        })
3184    }
3185}
3186
3187impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3188    for ReflectionPad1d<D, SymTensor, SymTensor, RANK>
3189{
3190    type Output = SymTensor;
3191    fn call(&self, input: SymTensor) -> SymTensor {
3192        input.record(Op::ReflectionPad1d {
3193            pad_left: self.pad_left,
3194            pad_right: self.pad_right,
3195        })
3196    }
3197}
3198
3199impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3200    for ReflectionPad2d<D, SymTensor, SymTensor, RANK>
3201{
3202    type Output = SymTensor;
3203    fn call(&self, input: SymTensor) -> SymTensor {
3204        input.record(Op::ReflectionPad2d {
3205            pad_l: self.pad_l,
3206            pad_r: self.pad_r,
3207            pad_t: self.pad_t,
3208            pad_b: self.pad_b,
3209        })
3210    }
3211}
3212
3213impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3214    for ReflectionPad3d<D, SymTensor, SymTensor, RANK>
3215{
3216    type Output = SymTensor;
3217    fn call(&self, input: SymTensor) -> SymTensor {
3218        input.record(Op::ReflectionPad3d {
3219            pad_d1: self.pad_d1,
3220            pad_d2: self.pad_d2,
3221            pad_h1: self.pad_h1,
3222            pad_h2: self.pad_h2,
3223            pad_w1: self.pad_w1,
3224            pad_w2: self.pad_w2,
3225        })
3226    }
3227}
3228
3229impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3230    for ReplicationPad1d<D, SymTensor, SymTensor, RANK>
3231{
3232    type Output = SymTensor;
3233    fn call(&self, input: SymTensor) -> SymTensor {
3234        input.record(Op::ReplicationPad1d {
3235            pad_left: self.pad_left,
3236            pad_right: self.pad_right,
3237        })
3238    }
3239}
3240
3241impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3242    for ReplicationPad2d<D, SymTensor, SymTensor, RANK>
3243{
3244    type Output = SymTensor;
3245    fn call(&self, input: SymTensor) -> SymTensor {
3246        input.record(Op::ReplicationPad2d {
3247            pad_l: self.pad_l,
3248            pad_r: self.pad_r,
3249            pad_t: self.pad_t,
3250            pad_b: self.pad_b,
3251        })
3252    }
3253}
3254
3255impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3256    for ReplicationPad3d<D, SymTensor, SymTensor, RANK>
3257{
3258    type Output = SymTensor;
3259    fn call(&self, input: SymTensor) -> SymTensor {
3260        input.record(Op::ReplicationPad3d {
3261            pad_d1: self.pad_d1,
3262            pad_d2: self.pad_d2,
3263            pad_h1: self.pad_h1,
3264            pad_h2: self.pad_h2,
3265            pad_w1: self.pad_w1,
3266            pad_w2: self.pad_w2,
3267        })
3268    }
3269}
3270
3271impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3272    for CircularPad1d<D, SymTensor, SymTensor, RANK>
3273{
3274    type Output = SymTensor;
3275    fn call(&self, input: SymTensor) -> SymTensor {
3276        input.record(Op::CircularPad1d {
3277            pad_left: self.pad_left,
3278            pad_right: self.pad_right,
3279        })
3280    }
3281}
3282
3283impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3284    for CircularPad2d<D, SymTensor, SymTensor, RANK>
3285{
3286    type Output = SymTensor;
3287    fn call(&self, input: SymTensor) -> SymTensor {
3288        input.record(Op::CircularPad2d {
3289            pad_l: self.pad_l,
3290            pad_r: self.pad_r,
3291            pad_t: self.pad_t,
3292            pad_b: self.pad_b,
3293        })
3294    }
3295}
3296
3297impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3298    for CircularPad3d<D, SymTensor, SymTensor, RANK>
3299{
3300    type Output = SymTensor;
3301    fn call(&self, input: SymTensor) -> SymTensor {
3302        input.record(Op::CircularPad3d {
3303            pad_d1: self.pad_d1,
3304            pad_d2: self.pad_d2,
3305            pad_h1: self.pad_h1,
3306            pad_h2: self.pad_h2,
3307            pad_w1: self.pad_w1,
3308            pad_w2: self.pad_w2,
3309        })
3310    }
3311}
3312
3313// --- Activation ---
3314
3315impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Relu<D, SymTensor, RANK> {
3316    type Output = SymTensor;
3317    fn call(&self, input: SymTensor) -> SymTensor {
3318        input.record(Op::Relu)
3319    }
3320}
3321
3322impl<D: Float, const RANK: usize> Layer<SymTensor> for Elu<D, SymTensor, RANK> {
3323    type Output = SymTensor;
3324    fn call(&self, input: SymTensor) -> SymTensor {
3325        input.record(Op::Elu { alpha: self.alpha })
3326    }
3327}
3328
3329impl<D: Float, const RANK: usize> Layer<SymTensor> for Selu<D, SymTensor, RANK> {
3330    type Output = SymTensor;
3331    fn call(&self, input: SymTensor) -> SymTensor {
3332        input.record(Op::Selu)
3333    }
3334}
3335
3336impl<D: Float, const RANK: usize> Layer<SymTensor> for Celu<D, SymTensor, RANK> {
3337    type Output = SymTensor;
3338    fn call(&self, input: SymTensor) -> SymTensor {
3339        input.record(Op::Celu { alpha: self.alpha })
3340    }
3341}
3342
3343impl<D: Float, const RANK: usize> Layer<SymTensor> for Gelu<D, SymTensor, RANK> {
3344    type Output = SymTensor;
3345    fn call(&self, input: SymTensor) -> SymTensor {
3346        input.record(Op::Gelu)
3347    }
3348}
3349
3350impl<D: Float, const RANK: usize> Layer<SymTensor> for Mish<D, SymTensor, RANK> {
3351    type Output = SymTensor;
3352    fn call(&self, input: SymTensor) -> SymTensor {
3353        input.record(Op::Mish)
3354    }
3355}
3356
3357impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardtanh<D, SymTensor, RANK> {
3358    type Output = SymTensor;
3359    fn call(&self, input: SymTensor) -> SymTensor {
3360        input.record(Op::Hardtanh {
3361            min_val: self.min_val,
3362            max_val: self.max_val,
3363        })
3364    }
3365}
3366
3367impl<D: Float, const RANK: usize> Layer<SymTensor> for Relu6<D, SymTensor, RANK> {
3368    type Output = SymTensor;
3369    fn call(&self, input: SymTensor) -> SymTensor {
3370        input.record(Op::Relu6)
3371    }
3372}
3373
3374impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardsigmoid<D, SymTensor, RANK> {
3375    type Output = SymTensor;
3376    fn call(&self, input: SymTensor) -> SymTensor {
3377        input.record(Op::Hardsigmoid)
3378    }
3379}
3380
3381impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardswish<D, SymTensor, RANK> {
3382    type Output = SymTensor;
3383    fn call(&self, input: SymTensor) -> SymTensor {
3384        input.record(Op::Hardswish)
3385    }
3386}
3387
3388impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardshrink<D, SymTensor, RANK> {
3389    type Output = SymTensor;
3390    fn call(&self, input: SymTensor) -> SymTensor {
3391        input.record(Op::Hardshrink {
3392            lambda: self.lambda,
3393        })
3394    }
3395}
3396
3397impl<D: Float, const RANK: usize> Layer<SymTensor> for LeakyRelu<D, SymTensor, RANK> {
3398    type Output = SymTensor;
3399    fn call(&self, input: SymTensor) -> SymTensor {
3400        input.record(Op::LeakyRelu {
3401            negative_slope: self.negative_slope,
3402        })
3403    }
3404}
3405
3406impl<D: Float, const RANK: usize> Layer<SymTensor> for Threshold<D, SymTensor, RANK> {
3407    type Output = SymTensor;
3408    fn call(&self, input: SymTensor) -> SymTensor {
3409        input.record(Op::Threshold {
3410            threshold: self.threshold,
3411            value: self.value,
3412        })
3413    }
3414}
3415
3416impl<D: Float, const RANK: usize> Layer<SymTensor> for Softsign<D, SymTensor, RANK> {
3417    type Output = SymTensor;
3418    fn call(&self, input: SymTensor) -> SymTensor {
3419        input.record(Op::Softsign)
3420    }
3421}
3422
3423impl<D: Float, const RANK: usize> Layer<SymTensor> for Softshrink<D, SymTensor, RANK> {
3424    type Output = SymTensor;
3425    fn call(&self, input: SymTensor) -> SymTensor {
3426        input.record(Op::Softshrink {
3427            lambda: self.lambda,
3428        })
3429    }
3430}
3431
3432impl<D: Float, const RANK: usize> Layer<SymTensor> for Softplus<D, SymTensor, RANK> {
3433    type Output = SymTensor;
3434    fn call(&self, input: SymTensor) -> SymTensor {
3435        input.record(Op::Softplus {
3436            beta: self.beta,
3437            threshold: self.threshold,
3438        })
3439    }
3440}
3441
3442impl<D: Float, const RANK: usize> Layer<SymTensor> for Sigmoid<D, SymTensor, RANK> {
3443    type Output = SymTensor;
3444    fn call(&self, input: SymTensor) -> SymTensor {
3445        input.record(Op::Sigmoid)
3446    }
3447}
3448
3449impl<D: Float, const RANK: usize> Layer<SymTensor> for Silu<D, SymTensor, RANK> {
3450    type Output = SymTensor;
3451    fn call(&self, input: SymTensor) -> SymTensor {
3452        input.record(Op::Silu)
3453    }
3454}
3455
3456impl<D: Float, const RANK: usize> Layer<SymTensor> for Logsigmoid<D, SymTensor, RANK> {
3457    type Output = SymTensor;
3458    fn call(&self, input: SymTensor) -> SymTensor {
3459        input.record(Op::Logsigmoid)
3460    }
3461}
3462
3463impl<D: Float, const RANK: usize> Layer<SymTensor> for Tanh<D, SymTensor, RANK> {
3464    type Output = SymTensor;
3465    fn call(&self, input: SymTensor) -> SymTensor {
3466        input.record(Op::Tanh)
3467    }
3468}
3469
3470impl<D: Float, const RANK: usize> Layer<SymTensor> for Tanhshrink<D, SymTensor, RANK> {
3471    type Output = SymTensor;
3472    fn call(&self, input: SymTensor) -> SymTensor {
3473        input.record(Op::Tanhshrink)
3474    }
3475}
3476
3477impl<D: Float, const RANK: usize> Layer<SymTensor> for Softmax<D, SymTensor, RANK> {
3478    type Output = SymTensor;
3479    fn call(&self, input: SymTensor) -> SymTensor {
3480        input.record(Op::Softmax { dim: self.dim })
3481    }
3482}
3483
3484// ---------------------------------------------------------------------------
3485// Tests
3486// ---------------------------------------------------------------------------
3487
3488#[cfg(test)]
3489mod tests {
3490    use super::*;
3491    use crate::{
3492        nn::{
3493            activation::{relu::Relu, softmax::Softmax},
3494            conv2d::Conv2d,
3495            linear::Linear,
3496        },
3497        sequential,
3498    };
3499
3500    #[test]
3501    fn test_sequential_graph_extraction() {
3502        let (input, graph) = SymTensor::input(DtypeRepr::F32, vec![None, Some(784)]);
3503
3504        let model = sequential![
3505            Linear::<f32, SymTensor, SymTensor, 2>::new(784, 128, true),
3506            Relu::<f32, SymTensor, 2>::new(),
3507            Linear::<f32, SymTensor, SymTensor, 2>::new(128, 10, true),
3508            Softmax::<f32, SymTensor, 2>::new(1)
3509        ];
3510
3511        let _out = Layer::call(&model, input);
3512
3513        let g = graph.borrow();
3514        assert_eq!(g.nodes.len(), 5);
3515        assert!(matches!(g.nodes[0].op, Op::Input));
3516        assert_eq!(g.nodes[0].shape, vec![None, Some(784)]);
3517
3518        assert!(matches!(
3519            g.nodes[1].op,
3520            Op::Linear {
3521                in_features: 784,
3522                out_features: 128,
3523                ..
3524            }
3525        ));
3526        assert_eq!(g.nodes[1].shape, vec![None, Some(128)]);
3527
3528        assert!(matches!(g.nodes[2].op, Op::Relu));
3529        assert_eq!(g.nodes[2].shape, vec![None, Some(128)]);
3530
3531        assert!(matches!(
3532            g.nodes[3].op,
3533            Op::Linear {
3534                in_features: 128,
3535                out_features: 10,
3536                ..
3537            }
3538        ));
3539        assert_eq!(g.nodes[3].shape, vec![None, Some(10)]);
3540
3541        assert!(matches!(g.nodes[4].op, Op::Softmax { dim: 1 }));
3542        assert_eq!(g.nodes[4].shape, vec![None, Some(10)]);
3543    }
3544
3545    #[test]
3546    fn test_topological_sort_linear_chain() {
3547        let (input, graph) = SymTensor::input(DtypeRepr::F32, vec![None, Some(784)]);
3548
3549        let model = sequential![
3550            Linear::<f32, SymTensor, SymTensor, 2>::new(784, 128, true),
3551            Relu::<f32, SymTensor, 2>::new(),
3552            Linear::<f32, SymTensor, SymTensor, 2>::new(128, 10, true),
3553            Softmax::<f32, SymTensor, 2>::new(1)
3554        ];
3555
3556        let _out = Layer::call(&model, input);
3557
3558        let g = graph.borrow();
3559        let order = g.topological_sort();
3560        assert_eq!(order.len(), g.nodes.len());
3561        for (pos, &id) in order.iter().enumerate() {
3562            for &input_id in &g.nodes[id].inputs {
3563                let input_pos = order.iter().position(|&x| x == input_id).unwrap();
3564                assert!(
3565                    input_pos < pos,
3566                    "producer {input_id} must come before consumer {id}"
3567                );
3568            }
3569        }
3570    }
3571
3572    #[test]
3573    fn test_residual_graph_extraction() {
3574        let (input, graph) = SymTensor::input(DtypeRepr::F32, vec![None, Some(64)]);
3575
3576        let main = Linear::<f32, SymTensor, SymTensor, 2>::new(64, 64, true).call(input.clone());
3577        let main = Relu::<f32, SymTensor, 2>::new().call(main);
3578        let skip = Linear::<f32, SymTensor, SymTensor, 2>::new(64, 64, false).call(input);
3579
3580        assert!(Rc::ptr_eq(&main.graph, &skip.graph));
3581
3582        let g = graph.borrow();
3583        assert_eq!(g.nodes.len(), 4);
3584        assert_eq!(g.nodes[1].inputs, vec![0]);
3585        assert_eq!(g.nodes[3].inputs, vec![0]);
3586    }
3587
3588    #[test]
3589    fn test_conv2d_graph_extraction() {
3590        let (input, graph) =
3591            SymTensor::input(DtypeRepr::F32, vec![None, Some(3), Some(32), Some(32)]);
3592
3593        let conv = Conv2d::<f32, SymTensor, SymTensor, 4>::new(3, 64, (3, 3), (1, 1), (1, 1), true);
3594        let _out = Layer::call(&conv, input);
3595
3596        let g = graph.borrow();
3597        assert_eq!(g.nodes.len(), 2);
3598        assert!(matches!(
3599            g.nodes[1].op,
3600            Op::Conv2d {
3601                in_channels: 3,
3602                out_channels: 64,
3603                kernel_h: 3,
3604                kernel_w: 3,
3605                stride_h: 1,
3606                stride_w: 1,
3607                padding_h: 1,
3608                padding_w: 1,
3609                has_bias: true,
3610                ..
3611            }
3612        ));
3613        assert_eq!(g.nodes[1].shape, vec![None, Some(64), Some(32), Some(32)]);
3614    }
3615
3616    #[test]
3617    fn test_lenet5_shapes() {
3618        let (input, graph) =
3619            SymTensor::input(DtypeRepr::F32, vec![None, Some(1), Some(28), Some(28)]);
3620
3621        use crate::{
3622            nn::{flatten::Flatten, pool::AvgPool2d},
3623            sequential,
3624        };
3625
3626        let model = sequential![
3627            Conv2d::<f32, SymTensor, SymTensor, 4>::new(1, 6, (5, 5), (1, 1), (2, 2), true),
3628            Relu::<f32, SymTensor, 4>::new(),
3629            AvgPool2d::<f32, SymTensor, SymTensor, 4>::new((2, 2), (2, 2)),
3630            Conv2d::<f32, SymTensor, SymTensor, 4>::new(6, 16, (5, 5), (1, 1), (0, 0), true),
3631            Relu::<f32, SymTensor, 4>::new(),
3632            AvgPool2d::<f32, SymTensor, SymTensor, 4>::new((2, 2), (2, 2)),
3633            Flatten::<f32, SymTensor, SymTensor>::new(),
3634            Linear::<f32, SymTensor, SymTensor, 2>::new(400, 120, true),
3635            Relu::<f32, SymTensor, 2>::new(),
3636            Linear::<f32, SymTensor, SymTensor, 2>::new(120, 84, true),
3637            Relu::<f32, SymTensor, 2>::new(),
3638            Linear::<f32, SymTensor, SymTensor, 2>::new(84, 10, true),
3639            Softmax::<f32, SymTensor, 2>::new(1)
3640        ];
3641
3642        let _out = Layer::call(&model, input);
3643
3644        let g = graph.borrow();
3645        assert_eq!(g.nodes.len(), 14);
3646        assert_eq!(g.nodes[0].shape, vec![None, Some(1), Some(28), Some(28)]);
3647        assert_eq!(g.nodes[1].shape, vec![None, Some(6), Some(28), Some(28)]);
3648        assert_eq!(g.nodes[2].shape, vec![None, Some(6), Some(28), Some(28)]);
3649        assert_eq!(g.nodes[3].shape, vec![None, Some(6), Some(14), Some(14)]);
3650        assert_eq!(g.nodes[4].shape, vec![None, Some(16), Some(10), Some(10)]);
3651        assert_eq!(g.nodes[5].shape, vec![None, Some(16), Some(10), Some(10)]);
3652        assert_eq!(g.nodes[6].shape, vec![None, Some(16), Some(5), Some(5)]);
3653        assert_eq!(g.nodes[7].shape, vec![None, Some(400)]);
3654        assert_eq!(g.nodes[8].shape, vec![None, Some(120)]);
3655        assert_eq!(g.nodes[9].shape, vec![None, Some(120)]);
3656        assert_eq!(g.nodes[10].shape, vec![None, Some(84)]);
3657        assert_eq!(g.nodes[11].shape, vec![None, Some(84)]);
3658        assert_eq!(g.nodes[12].shape, vec![None, Some(10)]);
3659        assert_eq!(g.nodes[13].shape, vec![None, Some(10)]);
3660    }
3661
3662    // -----------------------------------------------------------------------
3663    // is_fusable_elementwise / Op::Fused / Graph::fuse_elementwise_chains
3664    // -----------------------------------------------------------------------
3665
3666    #[test]
3667    fn test_is_fusable_elementwise_allowlist() {
3668        assert!(is_fusable_elementwise(&Op::Relu));
3669        assert!(is_fusable_elementwise(&Op::Sigmoid));
3670        assert!(is_fusable_elementwise(&Op::Silu));
3671        assert!(is_fusable_elementwise(&Op::Tanh));
3672    }
3673
3674    #[test]
3675    fn test_is_fusable_elementwise_excludes_non_allowlisted_ops() {
3676        assert!(!is_fusable_elementwise(&Op::Input));
3677        assert!(!is_fusable_elementwise(&Op::Gelu));
3678        assert!(!is_fusable_elementwise(&Op::Softmax { dim: 1 }));
3679        assert!(!is_fusable_elementwise(&Op::Conv2d {
3680            in_channels: 3,
3681            out_channels: 8,
3682            kernel_h: 3,
3683            kernel_w: 3,
3684            stride_h: 1,
3685            stride_w: 1,
3686            padding_h: 1,
3687            padding_w: 1,
3688            groups: 1,
3689            has_bias: false,
3690        }));
3691        assert!(!is_fusable_elementwise(&Op::BatchNorm2d {
3692            num_features: 8,
3693            eps: 1e-5,
3694            momentum: 0.1,
3695            affine: true,
3696            track_running_stats: true,
3697        }));
3698        assert!(!is_fusable_elementwise(&Op::Linear {
3699            in_features: 4,
3700            out_features: 4,
3701            has_bias: false,
3702        }));
3703    }
3704
3705    #[test]
3706    fn test_infer_output_shape_fused_folds_through_members() {
3707        let shape = vec![None, Some(16)];
3708        let out = infer_output_shape(
3709            &Op::Fused {
3710                members: alloc::vec![Op::Relu, Op::Sigmoid, Op::Silu],
3711            },
3712            &[&shape],
3713        );
3714        // All three members are shape-preserving, so the fused node is too.
3715        assert_eq!(out, shape);
3716    }
3717
3718    fn shape_1d(n: usize) -> Shape {
3719        vec![None, Some(n)]
3720    }
3721
3722    #[test]
3723    fn test_fuse_elementwise_chains_fuses_linear_chain() {
3724        let mut g = Graph::new();
3725        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(16));
3726        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(16));
3727        let sigmoid = g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(16));
3728        let _silu = g.add_node(Op::Silu, vec![sigmoid], DtypeRepr::F32, shape_1d(16));
3729
3730        let fused = g.fuse_elementwise_chains();
3731
3732        // Input + one Fused node.
3733        assert_eq!(fused.nodes.len(), 2);
3734        assert!(matches!(fused.nodes[0].op, Op::Input));
3735        match &fused.nodes[1].op {
3736            Op::Fused { members } => {
3737                assert_eq!(members.len(), 3);
3738                assert!(matches!(members[0], Op::Relu));
3739                assert!(matches!(members[1], Op::Sigmoid));
3740                assert!(matches!(members[2], Op::Silu));
3741            }
3742            other => panic!("expected Op::Fused, got {other:?}"),
3743        }
3744        // The fused node's input is the original Input node (rewired correctly).
3745        assert_eq!(fused.nodes[1].inputs, vec![0]);
3746    }
3747
3748    #[test]
3749    fn test_fuse_elementwise_chains_no_fusion_for_single_op() {
3750        let mut g = Graph::new();
3751        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(16));
3752        g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(16));
3753
3754        let fused = g.fuse_elementwise_chains();
3755
3756        assert_eq!(fused.nodes.len(), 2);
3757        assert!(matches!(fused.nodes[1].op, Op::Relu));
3758    }
3759
3760    #[test]
3761    fn test_fuse_elementwise_chains_stops_at_non_fusable_op() {
3762        let mut g = Graph::new();
3763        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3764        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3765        let conv = g.add_node(
3766            Op::Conv2d {
3767                in_channels: 4,
3768                out_channels: 4,
3769                kernel_h: 3,
3770                kernel_w: 3,
3771                stride_h: 1,
3772                stride_w: 1,
3773                padding_h: 1,
3774                padding_w: 1,
3775                groups: 1,
3776                has_bias: false,
3777            },
3778            vec![relu],
3779            DtypeRepr::F32,
3780            shape_1d(4),
3781        );
3782        g.add_node(Op::Sigmoid, vec![conv], DtypeRepr::F32, shape_1d(4));
3783
3784        let fused = g.fuse_elementwise_chains();
3785
3786        // Conv2d isn't fusable, so it blocks fusion on both sides: Relu can't fuse
3787        // forward into Conv2d, and Sigmoid can't fuse backward past it either.
3788        assert_eq!(fused.nodes.len(), 4);
3789        assert!(matches!(fused.nodes[1].op, Op::Relu));
3790        assert!(matches!(fused.nodes[2].op, Op::Conv2d { .. }));
3791        assert!(matches!(fused.nodes[3].op, Op::Sigmoid));
3792    }
3793
3794    #[test]
3795    fn test_fuse_elementwise_chains_no_fusion_when_multiple_consumers() {
3796        let mut g = Graph::new();
3797        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3798        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3799        // Two consumers of `relu` — it can't be absorbed into either chain since
3800        // something else still needs its standalone output.
3801        g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3802        g.add_node(Op::Silu, vec![relu], DtypeRepr::F32, shape_1d(4));
3803
3804        let fused = g.fuse_elementwise_chains();
3805
3806        assert_eq!(fused.nodes.len(), 4);
3807        assert!(matches!(fused.nodes[1].op, Op::Relu));
3808        assert!(matches!(fused.nodes[2].op, Op::Sigmoid));
3809        assert!(matches!(fused.nodes[3].op, Op::Silu));
3810    }
3811
3812    #[test]
3813    fn test_fuse_elementwise_chains_rewires_downstream_consumer() {
3814        let mut g = Graph::new();
3815        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3816        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3817        let sigmoid = g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3818        // A non-fusable consumer downstream of the chain must end up pointing at
3819        // the new Fused node's index, not a dangling/stale one.
3820        g.add_node(
3821            Op::Conv2d {
3822                in_channels: 4,
3823                out_channels: 4,
3824                kernel_h: 1,
3825                kernel_w: 1,
3826                stride_h: 1,
3827                stride_w: 1,
3828                padding_h: 0,
3829                padding_w: 0,
3830                groups: 1,
3831                has_bias: false,
3832            },
3833            vec![sigmoid],
3834            DtypeRepr::F32,
3835            shape_1d(4),
3836        );
3837
3838        let fused = g.fuse_elementwise_chains();
3839
3840        assert_eq!(fused.nodes.len(), 3);
3841        let fused_idx = 1;
3842        assert!(matches!(fused.nodes[fused_idx].op, Op::Fused { .. }));
3843        assert_eq!(fused.nodes[2].inputs, vec![fused_idx]);
3844    }
3845
3846    #[test]
3847    fn test_fuse_elementwise_chains_grows_past_two_via_fixed_point() {
3848        // Four fusable ops in a row: a single fuse_elementwise_chain_pass() call only
3849        // merges one adjacent pair, so this only ends up as one node if the fixed-point
3850        // loop in fuse_elementwise_chains() actually keeps iterating.
3851        let mut g = Graph::new();
3852        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3853        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3854        let sigmoid = g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3855        let silu = g.add_node(Op::Silu, vec![sigmoid], DtypeRepr::F32, shape_1d(4));
3856        g.add_node(Op::Tanh, vec![silu], DtypeRepr::F32, shape_1d(4));
3857
3858        let fused = g.fuse_elementwise_chains();
3859
3860        assert_eq!(fused.nodes.len(), 2);
3861        match &fused.nodes[1].op {
3862            Op::Fused { members } => {
3863                assert_eq!(members.len(), 4);
3864                assert!(matches!(members[0], Op::Relu));
3865                assert!(matches!(members[1], Op::Sigmoid));
3866                assert!(matches!(members[2], Op::Silu));
3867                assert!(matches!(members[3], Op::Tanh));
3868            }
3869            other => panic!("expected Op::Fused, got {other:?}"),
3870        }
3871    }
3872
3873    #[test]
3874    fn test_optimise_does_not_produce_fused_nodes() {
3875        // optimise() must keep behaving exactly as it did before Op::Fused existed —
3876        // fuse_elementwise_chains() is opt-in precisely so existing optimise() callers
3877        // aren't handed a node type their lowering backend doesn't know how to compile.
3878        let mut g = Graph::new();
3879        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3880        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3881        g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3882
3883        let optimised = g.optimise();
3884
3885        assert_eq!(optimised.nodes.len(), 3);
3886        for node in &optimised.nodes {
3887            assert!(!matches!(node.op, Op::Fused { .. }));
3888        }
3889    }
3890}