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] =
1931                Some((Op::Fused { members }, self.nodes[parent_idx].inputs.clone()));
1932            changed = true;
1933        }
1934
1935        if !changed {
1936            return (self.clone(), false);
1937        }
1938
1939        let mut old_to_new = vec![0usize; n];
1940        let mut new_count = 0usize;
1941        for i in 0..n {
1942            if !dead[i] {
1943                old_to_new[i] = new_count;
1944                new_count += 1;
1945            }
1946        }
1947
1948        let mut new_graph = Graph::new();
1949        for old_idx in 0..n {
1950            if dead[old_idx] {
1951                continue;
1952            }
1953            let node = &self.nodes[old_idx];
1954            let (op, inputs) =
1955                if let Some((fused_op, fused_inputs)) = node_override[old_idx].clone() {
1956                    let mapped = fused_inputs.iter().map(|&i| old_to_new[i]).collect();
1957                    (fused_op, mapped)
1958                } else {
1959                    let mapped = node.inputs.iter().map(|&i| old_to_new[i]).collect();
1960                    (node.op.clone(), mapped)
1961                };
1962            let new_idx = new_graph.nodes.len();
1963            new_graph.nodes.push(GraphNode {
1964                op,
1965                inputs,
1966                dtype: node.dtype,
1967                shape: node.shape.clone(),
1968            });
1969            if let Some(name) = self.names.get(&old_idx) {
1970                new_graph.names.insert(new_idx, name.clone());
1971            }
1972        }
1973
1974        (new_graph, true)
1975    }
1976}
1977
1978/// Whether `op` is eligible to appear inside an `Op::Fused` chain.
1979///
1980/// Deliberately conservative: a fixed allowlist of simple, single-input/single-output
1981/// activation ops that are (a) genuinely shape-preserving and (b) already dispatched
1982/// through a uniform, fixed-block-size elementwise kernel (`BLOCK_SIZE = 1024`) in
1983/// `teeny-kernels`, so every candidate is known to share the same CTA/thread structure
1984/// without needing to consult a `RuntimeOp` (which doesn't exist yet at this point in
1985/// the pipeline — `fuse_elementwise_chains()` runs on the `teeny-core::Op` graph, before
1986/// `teeny-kernels`' lowering constructs any `RuntimeOp`). Excludes normalization ops
1987/// (`BatchNorm*`, `LayerNorm`, ...) even though they're also shape-preserving: several
1988/// of their `RuntimeOp` impls are unimplemented stubs today, and some aren't the plain
1989/// fixed-block-size elementwise shape this allowlist is scoped to.
1990pub fn is_fusable_elementwise(op: &Op) -> bool {
1991    matches!(op, Op::Relu | Op::Sigmoid | Op::Silu | Op::Tanh)
1992}
1993
1994// ---------------------------------------------------------------------------
1995// Shape inference — computes the output shape for each Op given an input shape
1996// ---------------------------------------------------------------------------
1997
1998fn infer_output_shape(op: &Op, inputs: &[&Shape]) -> Shape {
1999    // Constant has no tensor inputs — its shape is embedded in the op itself.
2000    if let Op::Constant { shape, .. } = op {
2001        return shape.clone();
2002    }
2003    // Zero-input ops that produce no tensor output.
2004    if matches!(op, Op::SequenceEmpty | Op::OptionalHasElement) {
2005        return vec![];
2006    }
2007    let input = inputs[0];
2008    match op {
2009        Op::Input => input.clone(),
2010
2011        Op::Fused { members } => members.iter().fold(input.clone(), |shape, member| {
2012            infer_output_shape(member, &[&shape])
2013        }),
2014
2015        // Element-wise / shape-preserving — output shape = input shape
2016        Op::Relu
2017        | Op::Elu { .. }
2018        | Op::Selu
2019        | Op::Celu { .. }
2020        | Op::Gelu
2021        | Op::Mish
2022        | Op::Hardtanh { .. }
2023        | Op::Relu6
2024        | Op::Hardsigmoid
2025        | Op::Hardswish
2026        | Op::Hardshrink { .. }
2027        | Op::LeakyRelu { .. }
2028        | Op::Threshold { .. }
2029        | Op::Softsign
2030        | Op::Softshrink { .. }
2031        | Op::Softplus { .. }
2032        | Op::Sigmoid
2033        | Op::Silu
2034        | Op::Logsigmoid
2035        | Op::Tanh
2036        | Op::Tanhshrink
2037        | Op::Softmax { .. }
2038        | Op::BatchNorm1d { .. }
2039        | Op::BatchNorm2d { .. }
2040        | Op::BatchNorm3d { .. }
2041        | Op::LayerNorm { .. }
2042        | Op::RmsNorm { .. }
2043        | Op::GroupNorm { .. }
2044        | Op::InstanceNorm1d { .. }
2045        | Op::InstanceNorm2d { .. }
2046        | Op::InstanceNorm3d { .. } => input.clone(),
2047
2048        Op::Linear { out_features, .. } => {
2049            // [..., in_features] → [..., out_features]
2050            let mut out = input[..input.len() - 1].to_vec();
2051            out.push(Some(*out_features));
2052            out
2053        }
2054
2055        Op::Flatten => {
2056            // [N, C, H, W, ...] → [N, C*H*W*...]
2057            let rest = &input[1..];
2058            let flat: Option<usize> = rest
2059                .iter()
2060                .try_fold(1usize, |acc, dim| dim.map(|d| acc * d));
2061            vec![input[0], flat]
2062        }
2063
2064        // --- Convolution ---
2065        Op::Conv1d {
2066            out_channels,
2067            kernel_l,
2068            stride,
2069            padding,
2070            ..
2071        } => {
2072            // [N, C_in, L] → [N, C_out, L_out]
2073            let l_out = input[2].map(|l| (l + 2 * padding - kernel_l) / stride + 1);
2074            vec![input[0], Some(*out_channels), l_out]
2075        }
2076
2077        Op::Conv2d {
2078            out_channels,
2079            kernel_h,
2080            kernel_w,
2081            stride_h,
2082            stride_w,
2083            padding_h,
2084            padding_w,
2085            ..
2086        }
2087        | Op::Conv2dBnSilu {
2088            out_channels,
2089            kernel_h,
2090            kernel_w,
2091            stride_h,
2092            stride_w,
2093            padding_h,
2094            padding_w,
2095            ..
2096        } => {
2097            // [N, C_in, H, W] → [N, C_out, H_out, W_out]
2098            let h_out = input[2].map(|h| (h + 2 * padding_h - kernel_h) / stride_h + 1);
2099            let w_out = input[3].map(|w| (w + 2 * padding_w - kernel_w) / stride_w + 1);
2100            vec![input[0], Some(*out_channels), h_out, w_out]
2101        }
2102
2103        Op::Conv3d {
2104            out_channels,
2105            kernel_d,
2106            kernel_h,
2107            kernel_w,
2108            stride_d,
2109            stride_h,
2110            stride_w,
2111            padding_d,
2112            padding_h,
2113            padding_w,
2114            ..
2115        } => {
2116            // [N, C_in, D, H, W] → [N, C_out, D_out, H_out, W_out]
2117            let d_out = input[2].map(|d| (d + 2 * padding_d - kernel_d) / stride_d + 1);
2118            let h_out = input[3].map(|h| (h + 2 * padding_h - kernel_h) / stride_h + 1);
2119            let w_out = input[4].map(|w| (w + 2 * padding_w - kernel_w) / stride_w + 1);
2120            vec![input[0], Some(*out_channels), d_out, h_out, w_out]
2121        }
2122
2123        // --- Pooling ---
2124        Op::AvgPool1d { kernel_l, stride } | Op::MaxPool1d { kernel_l, stride } => {
2125            let l_out = input[2].map(|l| (l - kernel_l) / stride + 1);
2126            vec![input[0], input[1], l_out]
2127        }
2128
2129        Op::LpPool1d {
2130            kernel_l, stride, ..
2131        } => {
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::AvgPool2d {
2137            kernel_h,
2138            kernel_w,
2139            stride_h,
2140            stride_w,
2141        } => {
2142            let h_out = input[2].map(|h| (h - kernel_h) / stride_h + 1);
2143            let w_out = input[3].map(|w| (w - kernel_w) / stride_w + 1);
2144            vec![input[0], input[1], h_out, w_out]
2145        }
2146
2147        Op::MaxPool2d {
2148            kernel_h,
2149            kernel_w,
2150            stride_h,
2151            stride_w,
2152            pad_h,
2153            pad_w,
2154        } => {
2155            let h_out = input[2].map(|h| (h + 2 * pad_h - kernel_h) / stride_h + 1);
2156            let w_out = input[3].map(|w| (w + 2 * pad_w - kernel_w) / stride_w + 1);
2157            vec![input[0], input[1], h_out, w_out]
2158        }
2159
2160        Op::LpPool2d {
2161            kernel_h,
2162            kernel_w,
2163            stride_h,
2164            stride_w,
2165            ..
2166        } => {
2167            let h_out = input[2].map(|h| (h - kernel_h) / stride_h + 1);
2168            let w_out = input[3].map(|w| (w - kernel_w) / stride_w + 1);
2169            vec![input[0], input[1], h_out, w_out]
2170        }
2171
2172        Op::AvgPool3d {
2173            kernel_d,
2174            kernel_h,
2175            kernel_w,
2176            stride_d,
2177            stride_h,
2178            stride_w,
2179        }
2180        | Op::MaxPool3d {
2181            kernel_d,
2182            kernel_h,
2183            kernel_w,
2184            stride_d,
2185            stride_h,
2186            stride_w,
2187        } => {
2188            let d_out = input[2].map(|d| (d - kernel_d) / stride_d + 1);
2189            let h_out = input[3].map(|h| (h - kernel_h) / stride_h + 1);
2190            let w_out = input[4].map(|w| (w - kernel_w) / stride_w + 1);
2191            vec![input[0], input[1], d_out, h_out, w_out]
2192        }
2193
2194        Op::LpPool3d {
2195            kernel_d,
2196            kernel_h,
2197            kernel_w,
2198            stride_d,
2199            stride_h,
2200            stride_w,
2201            ..
2202        } => {
2203            let d_out = input[2].map(|d| (d - kernel_d) / stride_d + 1);
2204            let h_out = input[3].map(|h| (h - kernel_h) / stride_h + 1);
2205            let w_out = input[4].map(|w| (w - kernel_w) / stride_w + 1);
2206            vec![input[0], input[1], d_out, h_out, w_out]
2207        }
2208
2209        // --- Upsample ---
2210        Op::UpsampleNearest2d { scale_h, scale_w } => {
2211            // [N, C, H, W] → [N, C, H * scale_h, W * scale_w]
2212            let h_out = input[2].map(|h| h * scale_h);
2213            let w_out = input[3].map(|w| w * scale_w);
2214            vec![input[0], input[1], h_out, w_out]
2215        }
2216
2217        // --- Padding ---
2218        Op::ConstantPad1d {
2219            pad_left,
2220            pad_right,
2221            ..
2222        }
2223        | Op::ReflectionPad1d {
2224            pad_left,
2225            pad_right,
2226        }
2227        | Op::ReplicationPad1d {
2228            pad_left,
2229            pad_right,
2230        }
2231        | Op::CircularPad1d {
2232            pad_left,
2233            pad_right,
2234        } => {
2235            // [N, C, L] → [N, C, L + pad_left + pad_right]
2236            let l_out = input[2].map(|l| l + pad_left + pad_right);
2237            vec![input[0], input[1], l_out]
2238        }
2239
2240        Op::ConstantPad2d {
2241            pad_l,
2242            pad_r,
2243            pad_t,
2244            pad_b,
2245            ..
2246        }
2247        | Op::ReflectionPad2d {
2248            pad_l,
2249            pad_r,
2250            pad_t,
2251            pad_b,
2252        }
2253        | Op::ReplicationPad2d {
2254            pad_l,
2255            pad_r,
2256            pad_t,
2257            pad_b,
2258        }
2259        | Op::CircularPad2d {
2260            pad_l,
2261            pad_r,
2262            pad_t,
2263            pad_b,
2264        } => {
2265            // [N, C, H, W] → [N, C, H + pad_t + pad_b, W + pad_l + pad_r]
2266            let h_out = input[2].map(|h| h + pad_t + pad_b);
2267            let w_out = input[3].map(|w| w + pad_l + pad_r);
2268            vec![input[0], input[1], h_out, w_out]
2269        }
2270
2271        Op::ConstantPad3d {
2272            pad_d1,
2273            pad_d2,
2274            pad_h1,
2275            pad_h2,
2276            pad_w1,
2277            pad_w2,
2278            ..
2279        }
2280        | Op::ReflectionPad3d {
2281            pad_d1,
2282            pad_d2,
2283            pad_h1,
2284            pad_h2,
2285            pad_w1,
2286            pad_w2,
2287        }
2288        | Op::ReplicationPad3d {
2289            pad_d1,
2290            pad_d2,
2291            pad_h1,
2292            pad_h2,
2293            pad_w1,
2294            pad_w2,
2295        }
2296        | Op::CircularPad3d {
2297            pad_d1,
2298            pad_d2,
2299            pad_h1,
2300            pad_h2,
2301            pad_w1,
2302            pad_w2,
2303        } => {
2304            // [N, C, D, H, W] → padded on each spatial dim
2305            let d_out = input[2].map(|d| d + pad_d1 + pad_d2);
2306            let h_out = input[3].map(|h| h + pad_h1 + pad_h2);
2307            let w_out = input[4].map(|w| w + pad_w1 + pad_w2);
2308            vec![input[0], input[1], d_out, h_out, w_out]
2309        }
2310
2311        Op::Attention { .. } => input.clone(),
2312
2313        Op::Add => input.clone(),
2314
2315        Op::ChannelChunk { chunk_c, .. } => {
2316            // [N, c_total, H, W] → [N, chunk_c, H, W]
2317            vec![input[0], Some(*chunk_c), input[2], input[3]]
2318        }
2319
2320        Op::ChannelCat { c_total } => {
2321            // multi-input; c_total encodes the output channel count
2322            vec![input[0], Some(*c_total), input[2], input[3]]
2323        }
2324
2325        Op::ChannelBiasAdd { .. } => input.to_vec(),
2326
2327        Op::Custom { data } => data.infer_output_shape(inputs),
2328
2329        // -------------------------------------------------------------------
2330        // ONNX-sourced ops — shape inference below.
2331        // For ops whose output shape equals the primary input shape (element-
2332        // wise, identity-like, or shape-tracked via ONNX value_info), we just
2333        // clone the input shape.  Ops with genuinely different output shapes
2334        // have explicit arms.
2335        // -------------------------------------------------------------------
2336
2337        // Unary element-wise — output shape = input shape
2338        Op::Abs
2339        | Op::Neg
2340        | Op::Ceil
2341        | Op::Floor
2342        | Op::Round
2343        | Op::Sqrt
2344        | Op::Reciprocal
2345        | Op::Exp
2346        | Op::Log
2347        | Op::Erf
2348        | Op::Sign
2349        | Op::IsNaN
2350        | Op::IsInf { .. }
2351        | Op::Not
2352        | Op::BitwiseNot
2353        | Op::Sin
2354        | Op::Cos
2355        | Op::Tan
2356        | Op::Asin
2357        | Op::Acos
2358        | Op::Atan
2359        | Op::Sinh
2360        | Op::Cosh
2361        | Op::Asinh
2362        | Op::Acosh
2363        | Op::Atanh
2364        | Op::PRelu
2365        | Op::ThresholdedRelu { .. }
2366        | Op::Shrink { .. }
2367        | Op::Clip
2368        | Op::Swish
2369        | Op::LogSoftmax { .. }
2370        | Op::Hardmax { .. }
2371        | Op::Dropout { .. }
2372        | Op::Identity
2373        | Op::LRN { .. }
2374        | Op::MeanVarianceNormalization { .. }
2375        | Op::LpNormalization { .. }
2376        | Op::Pad { .. }
2377        | Op::ReverseSequence { .. }
2378        | Op::Trilu { .. }
2379        | Op::CumSum { .. }
2380        | Op::CumProd { .. }
2381        | Op::QuantizeLinear { .. }
2382        | Op::DequantizeLinear { .. }
2383        | Op::DynamicQuantizeLinear
2384        | Op::Bernoulli { .. }
2385        | Op::RandomUniformLike { .. }
2386        | Op::EyeLike { .. }
2387        | Op::RotaryEmbedding
2388        | Op::MultiHeadAttention { .. }
2389        | Op::FlexAttention { .. }
2390        | Op::LinearAttention { .. }
2391        | Op::CausalConvWithState { .. } => input.clone(),
2392
2393        // Binary / variadic element-wise — approximate as first-input shape
2394        Op::Mul
2395        | Op::Sub
2396        | Op::Div
2397        | Op::Pow
2398        | Op::Mod { .. }
2399        | Op::ElemMin
2400        | Op::ElemMax
2401        | Op::ElemMean
2402        | Op::ElemSum
2403        | Op::Equal
2404        | Op::Greater
2405        | Op::GreaterOrEqual
2406        | Op::Less
2407        | Op::LessOrEqual
2408        | Op::And
2409        | Op::Or
2410        | Op::Xor
2411        | Op::BitwiseAnd
2412        | Op::BitwiseOr
2413        | Op::BitwiseXor
2414        | Op::BitShift { .. }
2415        | Op::Cast { .. }
2416        | Op::CastLike
2417        | Op::BitCast { .. }
2418        | Op::Where => input.clone(),
2419
2420        // Structural ops where output shape = input shape or is unknown at
2421        // static inference time (ONNX value_info carries the true shape).
2422        Op::Reshape
2423        | Op::Squeeze { .. }
2424        | Op::Unsqueeze { .. }
2425        | Op::Slice
2426        | Op::Gather { .. }
2427        | Op::GatherElements { .. }
2428        | Op::GatherND { .. }
2429        | Op::ScatterElements { .. }
2430        | Op::ScatterND
2431        | Op::Tile
2432        | Op::Expand
2433        | Op::Compress { .. }
2434        | Op::Range
2435        | Op::ConstantOfShape { .. }
2436        | Op::NonZero
2437        | Op::Scatter { .. }
2438        | Op::TensorScatter
2439        | Op::Resize { .. }
2440        | Op::GridSample { .. }
2441        | Op::AffineGrid { .. }
2442        | Op::CenterCropPad { .. } => input.clone(),
2443
2444        Op::Transpose { perm } => {
2445            if perm.is_empty() {
2446                input.iter().rev().cloned().collect()
2447            } else {
2448                perm.iter()
2449                    .map(|&i| input.get(i).copied().unwrap_or(None))
2450                    .collect()
2451            }
2452        }
2453
2454        Op::Concat { axis } => {
2455            let rank = input.len();
2456            if rank == 0 {
2457                return input.clone();
2458            }
2459            let ax = axis.rem_euclid(rank as i64) as usize;
2460            let mut out = input.clone();
2461            // Sum the concatenated axis across all inputs.
2462            out[ax] = inputs
2463                .iter()
2464                .try_fold(0usize, |acc, s| {
2465                    s.get(ax).copied().unwrap_or(None).map(|d| acc + d)
2466                })
2467                .map(Some)
2468                .unwrap_or(None);
2469            out
2470        }
2471
2472        Op::Split { axis, num_outputs } => {
2473            let rank = input.len();
2474            if rank == 0 {
2475                return input.clone();
2476            }
2477            let ax = axis.rem_euclid(rank as i64) as usize;
2478            let mut out = input.clone();
2479            out[ax] = input[ax].map(|d| d / num_outputs.max(&1));
2480            out
2481        }
2482
2483        Op::ShapeOf { start, end } => {
2484            let rank = input.len() as i64;
2485            let s = start.rem_euclid(rank.max(1));
2486            let e = end.rem_euclid(rank.max(1));
2487            vec![Some((e - s).max(0) as usize)]
2488        }
2489
2490        Op::SizeOf => vec![Some(1)],
2491
2492        Op::Gemm {
2493            trans_a, trans_b, ..
2494        } => {
2495            let m = if *trans_a {
2496                input.get(1)
2497            } else {
2498                input.first()
2499            }
2500            .copied()
2501            .unwrap_or(None);
2502            let n = if inputs.len() >= 2 {
2503                let b = inputs[1];
2504                if *trans_b { b.first() } else { b.get(1) }
2505                    .copied()
2506                    .unwrap_or(None)
2507            } else {
2508                None
2509            };
2510            vec![m, n]
2511        }
2512
2513        Op::MatMul | Op::MatMulInteger | Op::QLinearMatMul => {
2514            if inputs.len() >= 2 && !input.is_empty() {
2515                let other = inputs[1];
2516                let mut out = input[..input.len() - 1].to_vec();
2517                out.push(other.last().copied().unwrap_or(None));
2518                out
2519            } else {
2520                input.clone()
2521            }
2522        }
2523
2524        Op::Einsum { .. }
2525        | Op::Det
2526        | Op::Col2Im { .. }
2527        | Op::ConvInteger { .. }
2528        | Op::DeformConv { .. }
2529        | Op::QLinearConv { .. } => input.clone(),
2530
2531        Op::ConvTranspose {
2532            out_channels,
2533            kernel_h,
2534            kernel_w,
2535            stride_h,
2536            stride_w,
2537            padding_h,
2538            padding_w,
2539            output_padding_h,
2540            output_padding_w,
2541            ..
2542        } => {
2543            let h_out =
2544                input[2].map(|h| (h - 1) * stride_h - 2 * padding_h + kernel_h + output_padding_h);
2545            let w_out =
2546                input[3].map(|w| (w - 1) * stride_w - 2 * padding_w + kernel_w + output_padding_w);
2547            vec![input[0], Some(*out_channels), h_out, w_out]
2548        }
2549
2550        Op::ReduceSum { keepdims, .. }
2551        | Op::ReduceMean { keepdims, .. }
2552        | Op::ReduceMax { keepdims, .. }
2553        | Op::ReduceMin { keepdims, .. }
2554        | Op::ReduceProd { keepdims, .. }
2555        | Op::ReduceL1 { keepdims, .. }
2556        | Op::ReduceL2 { keepdims, .. }
2557        | Op::ReduceLogSum { keepdims, .. }
2558        | Op::ReduceLogSumExp { keepdims, .. }
2559        | Op::ReduceSumSquare { keepdims, .. } => {
2560            // Without axis info at static-inference time, approximate:
2561            // keepdims=true → same rank, keepdims=false → reduce all → scalar.
2562            if *keepdims {
2563                input.clone()
2564            } else {
2565                vec![Some(1)]
2566            }
2567        }
2568
2569        Op::ArgMax { axis, keepdims, .. } | Op::ArgMin { axis, keepdims, .. } => {
2570            if input.is_empty() {
2571                return vec![];
2572            }
2573            let ax = axis.rem_euclid(input.len() as i64) as usize;
2574            if *keepdims {
2575                let mut out = input.clone();
2576                out[ax] = Some(1);
2577                out
2578            } else {
2579                let mut out = input.clone();
2580                out.remove(ax);
2581                out
2582            }
2583        }
2584
2585        Op::GlobalAvgPool | Op::GlobalMaxPool => {
2586            let mut out = input[..2.min(input.len())].to_vec();
2587            for _ in 2..input.len() {
2588                out.push(Some(1));
2589            }
2590            out
2591        }
2592
2593        Op::Lstm {
2594            hidden_size,
2595            bidirectional,
2596            ..
2597        }
2598        | Op::Gru {
2599            hidden_size,
2600            bidirectional,
2601            ..
2602        }
2603        | Op::Rnn {
2604            hidden_size,
2605            bidirectional,
2606            ..
2607        } => {
2608            let num_dirs: usize = if *bidirectional { 2 } else { 1 };
2609            // [seq_len, num_directions, batch, hidden_size] (approximate)
2610            vec![
2611                input.first().copied().unwrap_or(None),
2612                Some(num_dirs),
2613                input.get(1).copied().unwrap_or(None),
2614                Some(*hidden_size),
2615            ]
2616        }
2617
2618        Op::SpaceToDepth { blocksize } => {
2619            let c_out = input[1].map(|c| c * blocksize * blocksize);
2620            let h_out = input[2].map(|h| h / blocksize);
2621            let w_out = input[3].map(|w| w / blocksize);
2622            vec![input[0], c_out, h_out, w_out]
2623        }
2624
2625        Op::DepthToSpace { 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::RoiAlign {
2633            output_h, output_w, ..
2634        } => {
2635            vec![input[0], input[1], Some(*output_h), Some(*output_w)]
2636        }
2637
2638        Op::MaxUnpool {
2639            kernel_h,
2640            kernel_w,
2641            stride_h,
2642            stride_w,
2643        } => {
2644            let h_out = input[2].map(|h| (h - 1) * stride_h + kernel_h);
2645            let w_out = input[3].map(|w| (w - 1) * stride_w + kernel_w);
2646            vec![input[0], input[1], h_out, w_out]
2647        }
2648
2649        Op::NonMaxSuppression { .. } => vec![None, Some(3)],
2650
2651        Op::TopK { axis, .. } => {
2652            // Second input is k (runtime). Return input shape as approximation.
2653            let _ = axis;
2654            input.clone()
2655        }
2656
2657        Op::Unique { .. } => input.clone(),
2658        Op::OneHot { .. } => input.clone(),
2659
2660        Op::NegativeLogLikelihoodLoss { .. } | Op::SoftmaxCrossEntropyLoss { .. } => {
2661            vec![Some(1)]
2662        }
2663
2664        Op::Dft { onesided, .. } => {
2665            // DFT last dim: full=N, onesided=N/2+1. Approximate.
2666            if *onesided && input.len() >= 2 {
2667                let mut out = input.clone();
2668                *out.last_mut().unwrap() = None;
2669                out
2670            } else {
2671                input.clone()
2672            }
2673        }
2674
2675        Op::Stft
2676        | Op::MelWeightMatrix
2677        | Op::HannWindow { .. }
2678        | Op::BlackmanWindow { .. }
2679        | Op::HammingWindow { .. } => input.clone(),
2680
2681        Op::SequenceAt
2682        | Op::SequenceConstruct
2683        | Op::SequenceErase
2684        | Op::SequenceInsert
2685        | Op::SequenceLength
2686        | Op::SequenceMap
2687        | Op::SplitToSequence { .. }
2688        | Op::ConcatFromSequence { .. }
2689        | Op::OptionalGetElement
2690        | Op::Loop
2691        | Op::Scan { .. }
2692        | Op::If
2693        | Op::Adagrad
2694        | Op::Adam
2695        | Op::Momentum
2696        | Op::Gradient
2697        | Op::StringNormalizer
2698        | Op::RegexFullMatch { .. }
2699        | Op::StringConcat
2700        | Op::StringSplit
2701        | Op::TfIdfVectorizer
2702        | Op::LabelEncoder
2703        | Op::ArrayFeatureExtractor
2704        | Op::Binarizer { .. }
2705        | Op::TreeEnsemble
2706        | Op::ImageDecoder => input.clone(),
2707
2708        // Handled by early returns above the match; arms required for exhaustiveness.
2709        Op::Constant { shape, .. } => shape.clone(),
2710        Op::SequenceEmpty | Op::OptionalHasElement => vec![],
2711    }
2712}
2713
2714// ---------------------------------------------------------------------------
2715// SymTensor — a tensor that writes to the graph on every operation
2716// ---------------------------------------------------------------------------
2717
2718/// A symbolic tensor handle. Every layer operation on a `SymTensor` records
2719/// itself in the shared `Graph` and returns a new `SymTensor` pointing to
2720/// the new node. Cloning is cheap — it shares the graph via `Rc`.
2721#[derive(Clone)]
2722pub struct SymTensor {
2723    /// This tensor's node index in `graph`.
2724    pub node_id: usize,
2725    /// The shared graph this tensor's operations record into.
2726    pub graph: Rc<RefCell<Graph>>,
2727    /// This tensor's dtype.
2728    pub dtype: DtypeRepr,
2729    /// Output shape of this tensor. `None` in a slot means a dynamic/unknown
2730    /// dimension (e.g. the batch axis).
2731    pub shape: Shape,
2732}
2733
2734// SymTensor satisfies Tensor<D, RANK> for any D and RANK — shape is tracked
2735// dynamically at runtime; the compile-time SHAPE constant is zeroed (unused).
2736impl<D: Dtype, const RANK: usize> RankedTensor<D, RANK> for SymTensor {
2737    const SHAPE: [usize; RANK] = [0; RANK];
2738}
2739impl<D: Dtype, const RANK: usize> Tensor<D, RANK> for SymTensor {}
2740
2741impl SymTensor {
2742    /// Create an input placeholder, returning both the tensor and the shared
2743    /// graph handle. Keep the graph handle to inspect the result after tracing.
2744    ///
2745    /// Use `None` for dynamic dimensions (e.g. the batch axis):
2746    /// ```ignore
2747    /// SymTensor::input(DtypeRepr::F32, vec![None, Some(784)])
2748    /// ```
2749    pub fn input(dtype: DtypeRepr, shape: Shape) -> (Self, Rc<RefCell<Graph>>) {
2750        let graph = Rc::new(RefCell::new(Graph::new()));
2751        let node_id = graph
2752            .borrow_mut()
2753            .add_node(Op::Input, vec![], dtype, shape.clone());
2754        let tensor = Self {
2755            node_id,
2756            graph: graph.clone(),
2757            dtype,
2758            shape,
2759        };
2760        (tensor, graph)
2761    }
2762
2763    /// Number of dimensions of this tensor.
2764    pub fn rank(&self) -> usize {
2765        self.shape.len()
2766    }
2767
2768    fn record(&self, op: Op) -> Self {
2769        let output_shape = infer_output_shape(&op, &[&self.shape]);
2770        self.record_with_shape(op, output_shape)
2771    }
2772
2773    fn record_with_shape(&self, op: Op, shape: Shape) -> Self {
2774        let node_id =
2775            self.graph
2776                .borrow_mut()
2777                .add_node(op, vec![self.node_id], self.dtype, shape.clone());
2778        Self {
2779            node_id,
2780            graph: self.graph.clone(),
2781            dtype: self.dtype,
2782            shape,
2783        }
2784    }
2785
2786    /// Record a custom op whose output shape is determined by [`CustomOp::infer_output_shape`].
2787    ///
2788    /// `self` is the primary (first) input.  Pass additional inputs via
2789    /// `other_inputs`.  Pass `dtype` to override the output element type;
2790    /// defaults to the primary input's dtype.
2791    pub fn record_custom(
2792        &self,
2793        data: CustomData,
2794        other_inputs: &[&SymTensor],
2795        dtype: Option<DtypeRepr>,
2796    ) -> Self {
2797        let mut shapes: Vec<&Shape> = vec![&self.shape];
2798        shapes.extend(other_inputs.iter().map(|t| &t.shape));
2799        let output_shape = data.infer_output_shape(&shapes);
2800
2801        let mut input_ids: Vec<usize> = vec![self.node_id];
2802        input_ids.extend(other_inputs.iter().map(|t| t.node_id));
2803
2804        let out_dtype = dtype.unwrap_or(self.dtype);
2805        let node_id = self.graph.borrow_mut().add_node(
2806            Op::Custom { data },
2807            input_ids,
2808            out_dtype,
2809            output_shape.clone(),
2810        );
2811        Self {
2812            node_id,
2813            graph: self.graph.clone(),
2814            dtype: out_dtype,
2815            shape: output_shape,
2816        }
2817    }
2818}
2819
2820// ---------------------------------------------------------------------------
2821// Layer<SymTensor> impls — record op instead of computing
2822// ---------------------------------------------------------------------------
2823
2824// --- Linear / MLP ---
2825
2826impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Linear<D, SymTensor, SymTensor, RANK> {
2827    type Output = SymTensor;
2828    fn call(&self, input: SymTensor) -> SymTensor {
2829        input.record(Op::Linear {
2830            in_features: self.in_features,
2831            out_features: self.out_features,
2832            has_bias: self.has_bias,
2833        })
2834    }
2835}
2836
2837impl<D: Dtype> Layer<SymTensor> for Flatten<D, SymTensor, SymTensor> {
2838    type Output = SymTensor;
2839    fn call(&self, input: SymTensor) -> SymTensor {
2840        input.record(Op::Flatten)
2841    }
2842}
2843
2844// --- Normalisation ---
2845
2846impl<D: Dtype, const RANK: usize> Layer<SymTensor> for BatchNorm1d<D, SymTensor, SymTensor, RANK> {
2847    type Output = SymTensor;
2848    fn call(&self, input: SymTensor) -> SymTensor {
2849        input.record(Op::BatchNorm1d {
2850            num_features: self.num_features,
2851            eps: self.eps,
2852            momentum: self.momentum,
2853            affine: self.affine,
2854            track_running_stats: self.track_running_stats,
2855        })
2856    }
2857}
2858
2859impl<D: Dtype, const RANK: usize> Layer<SymTensor> for BatchNorm2d<D, SymTensor, SymTensor, RANK> {
2860    type Output = SymTensor;
2861    fn call(&self, input: SymTensor) -> SymTensor {
2862        input.record(Op::BatchNorm2d {
2863            num_features: self.num_features,
2864            eps: self.eps,
2865            momentum: self.momentum,
2866            affine: self.affine,
2867            track_running_stats: self.track_running_stats,
2868        })
2869    }
2870}
2871
2872impl<D: Dtype, const RANK: usize> Layer<SymTensor> for BatchNorm3d<D, SymTensor, SymTensor, RANK> {
2873    type Output = SymTensor;
2874    fn call(&self, input: SymTensor) -> SymTensor {
2875        input.record(Op::BatchNorm3d {
2876            num_features: self.num_features,
2877            eps: self.eps,
2878            momentum: self.momentum,
2879            affine: self.affine,
2880            track_running_stats: self.track_running_stats,
2881        })
2882    }
2883}
2884
2885impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LayerNorm<D, SymTensor, SymTensor, RANK> {
2886    type Output = SymTensor;
2887    fn call(&self, input: SymTensor) -> SymTensor {
2888        input.record(Op::LayerNorm {
2889            normalized_shape: self.normalized_shape.clone(),
2890            eps: self.eps,
2891            affine: self.affine,
2892        })
2893    }
2894}
2895
2896impl<D: Dtype, const RANK: usize> Layer<SymTensor> for RmsNorm<D, SymTensor, SymTensor, RANK> {
2897    type Output = SymTensor;
2898    fn call(&self, input: SymTensor) -> SymTensor {
2899        input.record(Op::RmsNorm {
2900            normalized_shape: self.normalized_shape.clone(),
2901            eps: self.eps,
2902            affine: self.affine,
2903        })
2904    }
2905}
2906
2907impl<D: Dtype, const RANK: usize> Layer<SymTensor> for GroupNorm<D, SymTensor, SymTensor, RANK> {
2908    type Output = SymTensor;
2909    fn call(&self, input: SymTensor) -> SymTensor {
2910        input.record(Op::GroupNorm {
2911            num_groups: self.num_groups,
2912            num_channels: self.num_channels,
2913            eps: self.eps,
2914            affine: self.affine,
2915        })
2916    }
2917}
2918
2919impl<D: Dtype, const RANK: usize> Layer<SymTensor>
2920    for InstanceNorm1d<D, SymTensor, SymTensor, RANK>
2921{
2922    type Output = SymTensor;
2923    fn call(&self, input: SymTensor) -> SymTensor {
2924        input.record(Op::InstanceNorm1d {
2925            num_features: self.num_features,
2926            eps: self.eps,
2927            momentum: self.momentum,
2928            affine: self.affine,
2929            track_running_stats: self.track_running_stats,
2930        })
2931    }
2932}
2933
2934impl<D: Dtype, const RANK: usize> Layer<SymTensor>
2935    for InstanceNorm2d<D, SymTensor, SymTensor, RANK>
2936{
2937    type Output = SymTensor;
2938    fn call(&self, input: SymTensor) -> SymTensor {
2939        input.record(Op::InstanceNorm2d {
2940            num_features: self.num_features,
2941            eps: self.eps,
2942            momentum: self.momentum,
2943            affine: self.affine,
2944            track_running_stats: self.track_running_stats,
2945        })
2946    }
2947}
2948
2949impl<D: Dtype, const RANK: usize> Layer<SymTensor>
2950    for InstanceNorm3d<D, SymTensor, SymTensor, RANK>
2951{
2952    type Output = SymTensor;
2953    fn call(&self, input: SymTensor) -> SymTensor {
2954        input.record(Op::InstanceNorm3d {
2955            num_features: self.num_features,
2956            eps: self.eps,
2957            momentum: self.momentum,
2958            affine: self.affine,
2959            track_running_stats: self.track_running_stats,
2960        })
2961    }
2962}
2963
2964// --- Convolution ---
2965
2966impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Conv1d<D, SymTensor, SymTensor, RANK> {
2967    type Output = SymTensor;
2968    fn call(&self, input: SymTensor) -> SymTensor {
2969        input.record(Op::Conv1d {
2970            in_channels: self.in_channels,
2971            out_channels: self.out_channels,
2972            kernel_l: self.kernel_l,
2973            stride: self.stride,
2974            padding: self.padding,
2975            has_bias: self.has_bias,
2976        })
2977    }
2978}
2979
2980impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Conv2d<D, SymTensor, SymTensor, RANK> {
2981    type Output = SymTensor;
2982    fn call(&self, input: SymTensor) -> SymTensor {
2983        input.record(Op::Conv2d {
2984            in_channels: self.in_channels,
2985            out_channels: self.out_channels,
2986            kernel_h: self.kernel_h,
2987            kernel_w: self.kernel_w,
2988            stride_h: self.stride_h,
2989            stride_w: self.stride_w,
2990            padding_h: self.padding_h,
2991            padding_w: self.padding_w,
2992            groups: self.groups,
2993            has_bias: self.has_bias,
2994        })
2995    }
2996}
2997
2998impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Conv3d<D, SymTensor, SymTensor, RANK> {
2999    type Output = SymTensor;
3000    fn call(&self, input: SymTensor) -> SymTensor {
3001        input.record(Op::Conv3d {
3002            in_channels: self.in_channels,
3003            out_channels: self.out_channels,
3004            kernel_d: self.kernel_d,
3005            kernel_h: self.kernel_h,
3006            kernel_w: self.kernel_w,
3007            stride_d: self.stride_d,
3008            stride_h: self.stride_h,
3009            stride_w: self.stride_w,
3010            padding_d: self.padding_d,
3011            padding_h: self.padding_h,
3012            padding_w: self.padding_w,
3013            has_bias: self.has_bias,
3014        })
3015    }
3016}
3017
3018// --- Pooling ---
3019
3020impl<D: Dtype, const RANK: usize> Layer<SymTensor> for AvgPool1d<D, SymTensor, SymTensor, RANK> {
3021    type Output = SymTensor;
3022    fn call(&self, input: SymTensor) -> SymTensor {
3023        input.record(Op::AvgPool1d {
3024            kernel_l: self.kernel_l,
3025            stride: self.stride,
3026        })
3027    }
3028}
3029
3030impl<D: Dtype, const RANK: usize> Layer<SymTensor> for AvgPool2d<D, SymTensor, SymTensor, RANK> {
3031    type Output = SymTensor;
3032    fn call(&self, input: SymTensor) -> SymTensor {
3033        input.record(Op::AvgPool2d {
3034            kernel_h: self.kernel_h,
3035            kernel_w: self.kernel_w,
3036            stride_h: self.stride_h,
3037            stride_w: self.stride_w,
3038        })
3039    }
3040}
3041
3042impl<D: Dtype, const RANK: usize> Layer<SymTensor> for AvgPool3d<D, SymTensor, SymTensor, RANK> {
3043    type Output = SymTensor;
3044    fn call(&self, input: SymTensor) -> SymTensor {
3045        input.record(Op::AvgPool3d {
3046            kernel_d: self.kernel_d,
3047            kernel_h: self.kernel_h,
3048            kernel_w: self.kernel_w,
3049            stride_d: self.stride_d,
3050            stride_h: self.stride_h,
3051            stride_w: self.stride_w,
3052        })
3053    }
3054}
3055
3056impl<D: Dtype, const RANK: usize> Layer<SymTensor> for MaxPool1d<D, SymTensor, SymTensor, RANK> {
3057    type Output = SymTensor;
3058    fn call(&self, input: SymTensor) -> SymTensor {
3059        input.record(Op::MaxPool1d {
3060            kernel_l: self.kernel_l,
3061            stride: self.stride,
3062        })
3063    }
3064}
3065
3066impl<D: Dtype, const RANK: usize> Layer<SymTensor> for MaxPool2d<D, SymTensor, SymTensor, RANK> {
3067    type Output = SymTensor;
3068    fn call(&self, input: SymTensor) -> SymTensor {
3069        input.record(Op::MaxPool2d {
3070            kernel_h: self.kernel_h,
3071            kernel_w: self.kernel_w,
3072            stride_h: self.stride_h,
3073            stride_w: self.stride_w,
3074            pad_h: self.padding_h,
3075            pad_w: self.padding_w,
3076        })
3077    }
3078}
3079
3080impl<D: Dtype, const RANK: usize> Layer<SymTensor> for MaxPool3d<D, SymTensor, SymTensor, RANK> {
3081    type Output = SymTensor;
3082    fn call(&self, input: SymTensor) -> SymTensor {
3083        input.record(Op::MaxPool3d {
3084            kernel_d: self.kernel_d,
3085            kernel_h: self.kernel_h,
3086            kernel_w: self.kernel_w,
3087            stride_d: self.stride_d,
3088            stride_h: self.stride_h,
3089            stride_w: self.stride_w,
3090        })
3091    }
3092}
3093
3094impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LpPool1d<D, SymTensor, SymTensor, RANK> {
3095    type Output = SymTensor;
3096    fn call(&self, input: SymTensor) -> SymTensor {
3097        input.record(Op::LpPool1d {
3098            kernel_l: self.kernel_l,
3099            stride: self.stride,
3100            p: self.p,
3101        })
3102    }
3103}
3104
3105impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LpPool2d<D, SymTensor, SymTensor, RANK> {
3106    type Output = SymTensor;
3107    fn call(&self, input: SymTensor) -> SymTensor {
3108        input.record(Op::LpPool2d {
3109            kernel_h: self.kernel_h,
3110            kernel_w: self.kernel_w,
3111            stride_h: self.stride_h,
3112            stride_w: self.stride_w,
3113            p: self.p,
3114        })
3115    }
3116}
3117
3118impl<D: Dtype, const RANK: usize> Layer<SymTensor> for LpPool3d<D, SymTensor, SymTensor, RANK> {
3119    type Output = SymTensor;
3120    fn call(&self, input: SymTensor) -> SymTensor {
3121        input.record(Op::LpPool3d {
3122            kernel_d: self.kernel_d,
3123            kernel_h: self.kernel_h,
3124            kernel_w: self.kernel_w,
3125            stride_d: self.stride_d,
3126            stride_h: self.stride_h,
3127            stride_w: self.stride_w,
3128            p: self.p,
3129        })
3130    }
3131}
3132
3133// --- Padding ---
3134
3135impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3136    for ConstantPad1d<D, SymTensor, SymTensor, RANK>
3137{
3138    type Output = SymTensor;
3139    fn call(&self, input: SymTensor) -> SymTensor {
3140        input.record(Op::ConstantPad1d {
3141            pad_left: self.pad_left,
3142            pad_right: self.pad_right,
3143            value: self.value,
3144        })
3145    }
3146}
3147
3148impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3149    for ConstantPad2d<D, SymTensor, SymTensor, RANK>
3150{
3151    type Output = SymTensor;
3152    fn call(&self, input: SymTensor) -> SymTensor {
3153        input.record(Op::ConstantPad2d {
3154            pad_l: self.pad_l,
3155            pad_r: self.pad_r,
3156            pad_t: self.pad_t,
3157            pad_b: self.pad_b,
3158            value: self.value,
3159        })
3160    }
3161}
3162
3163impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3164    for ConstantPad3d<D, SymTensor, SymTensor, RANK>
3165{
3166    type Output = SymTensor;
3167    fn call(&self, input: SymTensor) -> SymTensor {
3168        input.record(Op::ConstantPad3d {
3169            pad_d1: self.pad_d1,
3170            pad_d2: self.pad_d2,
3171            pad_h1: self.pad_h1,
3172            pad_h2: self.pad_h2,
3173            pad_w1: self.pad_w1,
3174            pad_w2: self.pad_w2,
3175            value: self.value,
3176        })
3177    }
3178}
3179
3180impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3181    for ReflectionPad1d<D, SymTensor, SymTensor, RANK>
3182{
3183    type Output = SymTensor;
3184    fn call(&self, input: SymTensor) -> SymTensor {
3185        input.record(Op::ReflectionPad1d {
3186            pad_left: self.pad_left,
3187            pad_right: self.pad_right,
3188        })
3189    }
3190}
3191
3192impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3193    for ReflectionPad2d<D, SymTensor, SymTensor, RANK>
3194{
3195    type Output = SymTensor;
3196    fn call(&self, input: SymTensor) -> SymTensor {
3197        input.record(Op::ReflectionPad2d {
3198            pad_l: self.pad_l,
3199            pad_r: self.pad_r,
3200            pad_t: self.pad_t,
3201            pad_b: self.pad_b,
3202        })
3203    }
3204}
3205
3206impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3207    for ReflectionPad3d<D, SymTensor, SymTensor, RANK>
3208{
3209    type Output = SymTensor;
3210    fn call(&self, input: SymTensor) -> SymTensor {
3211        input.record(Op::ReflectionPad3d {
3212            pad_d1: self.pad_d1,
3213            pad_d2: self.pad_d2,
3214            pad_h1: self.pad_h1,
3215            pad_h2: self.pad_h2,
3216            pad_w1: self.pad_w1,
3217            pad_w2: self.pad_w2,
3218        })
3219    }
3220}
3221
3222impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3223    for ReplicationPad1d<D, SymTensor, SymTensor, RANK>
3224{
3225    type Output = SymTensor;
3226    fn call(&self, input: SymTensor) -> SymTensor {
3227        input.record(Op::ReplicationPad1d {
3228            pad_left: self.pad_left,
3229            pad_right: self.pad_right,
3230        })
3231    }
3232}
3233
3234impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3235    for ReplicationPad2d<D, SymTensor, SymTensor, RANK>
3236{
3237    type Output = SymTensor;
3238    fn call(&self, input: SymTensor) -> SymTensor {
3239        input.record(Op::ReplicationPad2d {
3240            pad_l: self.pad_l,
3241            pad_r: self.pad_r,
3242            pad_t: self.pad_t,
3243            pad_b: self.pad_b,
3244        })
3245    }
3246}
3247
3248impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3249    for ReplicationPad3d<D, SymTensor, SymTensor, RANK>
3250{
3251    type Output = SymTensor;
3252    fn call(&self, input: SymTensor) -> SymTensor {
3253        input.record(Op::ReplicationPad3d {
3254            pad_d1: self.pad_d1,
3255            pad_d2: self.pad_d2,
3256            pad_h1: self.pad_h1,
3257            pad_h2: self.pad_h2,
3258            pad_w1: self.pad_w1,
3259            pad_w2: self.pad_w2,
3260        })
3261    }
3262}
3263
3264impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3265    for CircularPad1d<D, SymTensor, SymTensor, RANK>
3266{
3267    type Output = SymTensor;
3268    fn call(&self, input: SymTensor) -> SymTensor {
3269        input.record(Op::CircularPad1d {
3270            pad_left: self.pad_left,
3271            pad_right: self.pad_right,
3272        })
3273    }
3274}
3275
3276impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3277    for CircularPad2d<D, SymTensor, SymTensor, RANK>
3278{
3279    type Output = SymTensor;
3280    fn call(&self, input: SymTensor) -> SymTensor {
3281        input.record(Op::CircularPad2d {
3282            pad_l: self.pad_l,
3283            pad_r: self.pad_r,
3284            pad_t: self.pad_t,
3285            pad_b: self.pad_b,
3286        })
3287    }
3288}
3289
3290impl<D: Dtype, const RANK: usize> Layer<SymTensor>
3291    for CircularPad3d<D, SymTensor, SymTensor, RANK>
3292{
3293    type Output = SymTensor;
3294    fn call(&self, input: SymTensor) -> SymTensor {
3295        input.record(Op::CircularPad3d {
3296            pad_d1: self.pad_d1,
3297            pad_d2: self.pad_d2,
3298            pad_h1: self.pad_h1,
3299            pad_h2: self.pad_h2,
3300            pad_w1: self.pad_w1,
3301            pad_w2: self.pad_w2,
3302        })
3303    }
3304}
3305
3306// --- Activation ---
3307
3308impl<D: Dtype, const RANK: usize> Layer<SymTensor> for Relu<D, SymTensor, RANK> {
3309    type Output = SymTensor;
3310    fn call(&self, input: SymTensor) -> SymTensor {
3311        input.record(Op::Relu)
3312    }
3313}
3314
3315impl<D: Float, const RANK: usize> Layer<SymTensor> for Elu<D, SymTensor, RANK> {
3316    type Output = SymTensor;
3317    fn call(&self, input: SymTensor) -> SymTensor {
3318        input.record(Op::Elu { alpha: self.alpha })
3319    }
3320}
3321
3322impl<D: Float, const RANK: usize> Layer<SymTensor> for Selu<D, SymTensor, RANK> {
3323    type Output = SymTensor;
3324    fn call(&self, input: SymTensor) -> SymTensor {
3325        input.record(Op::Selu)
3326    }
3327}
3328
3329impl<D: Float, const RANK: usize> Layer<SymTensor> for Celu<D, SymTensor, RANK> {
3330    type Output = SymTensor;
3331    fn call(&self, input: SymTensor) -> SymTensor {
3332        input.record(Op::Celu { alpha: self.alpha })
3333    }
3334}
3335
3336impl<D: Float, const RANK: usize> Layer<SymTensor> for Gelu<D, SymTensor, RANK> {
3337    type Output = SymTensor;
3338    fn call(&self, input: SymTensor) -> SymTensor {
3339        input.record(Op::Gelu)
3340    }
3341}
3342
3343impl<D: Float, const RANK: usize> Layer<SymTensor> for Mish<D, SymTensor, RANK> {
3344    type Output = SymTensor;
3345    fn call(&self, input: SymTensor) -> SymTensor {
3346        input.record(Op::Mish)
3347    }
3348}
3349
3350impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardtanh<D, SymTensor, RANK> {
3351    type Output = SymTensor;
3352    fn call(&self, input: SymTensor) -> SymTensor {
3353        input.record(Op::Hardtanh {
3354            min_val: self.min_val,
3355            max_val: self.max_val,
3356        })
3357    }
3358}
3359
3360impl<D: Float, const RANK: usize> Layer<SymTensor> for Relu6<D, SymTensor, RANK> {
3361    type Output = SymTensor;
3362    fn call(&self, input: SymTensor) -> SymTensor {
3363        input.record(Op::Relu6)
3364    }
3365}
3366
3367impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardsigmoid<D, SymTensor, RANK> {
3368    type Output = SymTensor;
3369    fn call(&self, input: SymTensor) -> SymTensor {
3370        input.record(Op::Hardsigmoid)
3371    }
3372}
3373
3374impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardswish<D, SymTensor, RANK> {
3375    type Output = SymTensor;
3376    fn call(&self, input: SymTensor) -> SymTensor {
3377        input.record(Op::Hardswish)
3378    }
3379}
3380
3381impl<D: Float, const RANK: usize> Layer<SymTensor> for Hardshrink<D, SymTensor, RANK> {
3382    type Output = SymTensor;
3383    fn call(&self, input: SymTensor) -> SymTensor {
3384        input.record(Op::Hardshrink {
3385            lambda: self.lambda,
3386        })
3387    }
3388}
3389
3390impl<D: Float, const RANK: usize> Layer<SymTensor> for LeakyRelu<D, SymTensor, RANK> {
3391    type Output = SymTensor;
3392    fn call(&self, input: SymTensor) -> SymTensor {
3393        input.record(Op::LeakyRelu {
3394            negative_slope: self.negative_slope,
3395        })
3396    }
3397}
3398
3399impl<D: Float, const RANK: usize> Layer<SymTensor> for Threshold<D, SymTensor, RANK> {
3400    type Output = SymTensor;
3401    fn call(&self, input: SymTensor) -> SymTensor {
3402        input.record(Op::Threshold {
3403            threshold: self.threshold,
3404            value: self.value,
3405        })
3406    }
3407}
3408
3409impl<D: Float, const RANK: usize> Layer<SymTensor> for Softsign<D, SymTensor, RANK> {
3410    type Output = SymTensor;
3411    fn call(&self, input: SymTensor) -> SymTensor {
3412        input.record(Op::Softsign)
3413    }
3414}
3415
3416impl<D: Float, const RANK: usize> Layer<SymTensor> for Softshrink<D, SymTensor, RANK> {
3417    type Output = SymTensor;
3418    fn call(&self, input: SymTensor) -> SymTensor {
3419        input.record(Op::Softshrink {
3420            lambda: self.lambda,
3421        })
3422    }
3423}
3424
3425impl<D: Float, const RANK: usize> Layer<SymTensor> for Softplus<D, SymTensor, RANK> {
3426    type Output = SymTensor;
3427    fn call(&self, input: SymTensor) -> SymTensor {
3428        input.record(Op::Softplus {
3429            beta: self.beta,
3430            threshold: self.threshold,
3431        })
3432    }
3433}
3434
3435impl<D: Float, const RANK: usize> Layer<SymTensor> for Sigmoid<D, SymTensor, RANK> {
3436    type Output = SymTensor;
3437    fn call(&self, input: SymTensor) -> SymTensor {
3438        input.record(Op::Sigmoid)
3439    }
3440}
3441
3442impl<D: Float, const RANK: usize> Layer<SymTensor> for Silu<D, SymTensor, RANK> {
3443    type Output = SymTensor;
3444    fn call(&self, input: SymTensor) -> SymTensor {
3445        input.record(Op::Silu)
3446    }
3447}
3448
3449impl<D: Float, const RANK: usize> Layer<SymTensor> for Logsigmoid<D, SymTensor, RANK> {
3450    type Output = SymTensor;
3451    fn call(&self, input: SymTensor) -> SymTensor {
3452        input.record(Op::Logsigmoid)
3453    }
3454}
3455
3456impl<D: Float, const RANK: usize> Layer<SymTensor> for Tanh<D, SymTensor, RANK> {
3457    type Output = SymTensor;
3458    fn call(&self, input: SymTensor) -> SymTensor {
3459        input.record(Op::Tanh)
3460    }
3461}
3462
3463impl<D: Float, const RANK: usize> Layer<SymTensor> for Tanhshrink<D, SymTensor, RANK> {
3464    type Output = SymTensor;
3465    fn call(&self, input: SymTensor) -> SymTensor {
3466        input.record(Op::Tanhshrink)
3467    }
3468}
3469
3470impl<D: Float, const RANK: usize> Layer<SymTensor> for Softmax<D, SymTensor, RANK> {
3471    type Output = SymTensor;
3472    fn call(&self, input: SymTensor) -> SymTensor {
3473        input.record(Op::Softmax { dim: self.dim })
3474    }
3475}
3476
3477// ---------------------------------------------------------------------------
3478// Tests
3479// ---------------------------------------------------------------------------
3480
3481#[cfg(test)]
3482mod tests {
3483    use super::*;
3484    use crate::{
3485        nn::{
3486            activation::{relu::Relu, softmax::Softmax},
3487            conv2d::Conv2d,
3488            linear::Linear,
3489        },
3490        sequential,
3491    };
3492
3493    #[test]
3494    fn test_sequential_graph_extraction() {
3495        let (input, graph) = SymTensor::input(DtypeRepr::F32, vec![None, Some(784)]);
3496
3497        let model = sequential![
3498            Linear::<f32, SymTensor, SymTensor, 2>::new(784, 128, true),
3499            Relu::<f32, SymTensor, 2>::new(),
3500            Linear::<f32, SymTensor, SymTensor, 2>::new(128, 10, true),
3501            Softmax::<f32, SymTensor, 2>::new(1)
3502        ];
3503
3504        let _out = Layer::call(&model, input);
3505
3506        let g = graph.borrow();
3507        assert_eq!(g.nodes.len(), 5);
3508        assert!(matches!(g.nodes[0].op, Op::Input));
3509        assert_eq!(g.nodes[0].shape, vec![None, Some(784)]);
3510
3511        assert!(matches!(
3512            g.nodes[1].op,
3513            Op::Linear {
3514                in_features: 784,
3515                out_features: 128,
3516                ..
3517            }
3518        ));
3519        assert_eq!(g.nodes[1].shape, vec![None, Some(128)]);
3520
3521        assert!(matches!(g.nodes[2].op, Op::Relu));
3522        assert_eq!(g.nodes[2].shape, vec![None, Some(128)]);
3523
3524        assert!(matches!(
3525            g.nodes[3].op,
3526            Op::Linear {
3527                in_features: 128,
3528                out_features: 10,
3529                ..
3530            }
3531        ));
3532        assert_eq!(g.nodes[3].shape, vec![None, Some(10)]);
3533
3534        assert!(matches!(g.nodes[4].op, Op::Softmax { dim: 1 }));
3535        assert_eq!(g.nodes[4].shape, vec![None, Some(10)]);
3536    }
3537
3538    #[test]
3539    fn test_topological_sort_linear_chain() {
3540        let (input, graph) = SymTensor::input(DtypeRepr::F32, vec![None, Some(784)]);
3541
3542        let model = sequential![
3543            Linear::<f32, SymTensor, SymTensor, 2>::new(784, 128, true),
3544            Relu::<f32, SymTensor, 2>::new(),
3545            Linear::<f32, SymTensor, SymTensor, 2>::new(128, 10, true),
3546            Softmax::<f32, SymTensor, 2>::new(1)
3547        ];
3548
3549        let _out = Layer::call(&model, input);
3550
3551        let g = graph.borrow();
3552        let order = g.topological_sort();
3553        assert_eq!(order.len(), g.nodes.len());
3554        for (pos, &id) in order.iter().enumerate() {
3555            for &input_id in &g.nodes[id].inputs {
3556                let input_pos = order.iter().position(|&x| x == input_id).unwrap();
3557                assert!(
3558                    input_pos < pos,
3559                    "producer {input_id} must come before consumer {id}"
3560                );
3561            }
3562        }
3563    }
3564
3565    #[test]
3566    fn test_residual_graph_extraction() {
3567        let (input, graph) = SymTensor::input(DtypeRepr::F32, vec![None, Some(64)]);
3568
3569        let main = Linear::<f32, SymTensor, SymTensor, 2>::new(64, 64, true).call(input.clone());
3570        let main = Relu::<f32, SymTensor, 2>::new().call(main);
3571        let skip = Linear::<f32, SymTensor, SymTensor, 2>::new(64, 64, false).call(input);
3572
3573        assert!(Rc::ptr_eq(&main.graph, &skip.graph));
3574
3575        let g = graph.borrow();
3576        assert_eq!(g.nodes.len(), 4);
3577        assert_eq!(g.nodes[1].inputs, vec![0]);
3578        assert_eq!(g.nodes[3].inputs, vec![0]);
3579    }
3580
3581    #[test]
3582    fn test_conv2d_graph_extraction() {
3583        let (input, graph) =
3584            SymTensor::input(DtypeRepr::F32, vec![None, Some(3), Some(32), Some(32)]);
3585
3586        let conv = Conv2d::<f32, SymTensor, SymTensor, 4>::new(3, 64, (3, 3), (1, 1), (1, 1), true);
3587        let _out = Layer::call(&conv, input);
3588
3589        let g = graph.borrow();
3590        assert_eq!(g.nodes.len(), 2);
3591        assert!(matches!(
3592            g.nodes[1].op,
3593            Op::Conv2d {
3594                in_channels: 3,
3595                out_channels: 64,
3596                kernel_h: 3,
3597                kernel_w: 3,
3598                stride_h: 1,
3599                stride_w: 1,
3600                padding_h: 1,
3601                padding_w: 1,
3602                has_bias: true,
3603                ..
3604            }
3605        ));
3606        assert_eq!(g.nodes[1].shape, vec![None, Some(64), Some(32), Some(32)]);
3607    }
3608
3609    #[test]
3610    fn test_lenet5_shapes() {
3611        let (input, graph) =
3612            SymTensor::input(DtypeRepr::F32, vec![None, Some(1), Some(28), Some(28)]);
3613
3614        use crate::{
3615            nn::{flatten::Flatten, pool::AvgPool2d},
3616            sequential,
3617        };
3618
3619        let model = sequential![
3620            Conv2d::<f32, SymTensor, SymTensor, 4>::new(1, 6, (5, 5), (1, 1), (2, 2), true),
3621            Relu::<f32, SymTensor, 4>::new(),
3622            AvgPool2d::<f32, SymTensor, SymTensor, 4>::new((2, 2), (2, 2)),
3623            Conv2d::<f32, SymTensor, SymTensor, 4>::new(6, 16, (5, 5), (1, 1), (0, 0), true),
3624            Relu::<f32, SymTensor, 4>::new(),
3625            AvgPool2d::<f32, SymTensor, SymTensor, 4>::new((2, 2), (2, 2)),
3626            Flatten::<f32, SymTensor, SymTensor>::new(),
3627            Linear::<f32, SymTensor, SymTensor, 2>::new(400, 120, true),
3628            Relu::<f32, SymTensor, 2>::new(),
3629            Linear::<f32, SymTensor, SymTensor, 2>::new(120, 84, true),
3630            Relu::<f32, SymTensor, 2>::new(),
3631            Linear::<f32, SymTensor, SymTensor, 2>::new(84, 10, true),
3632            Softmax::<f32, SymTensor, 2>::new(1)
3633        ];
3634
3635        let _out = Layer::call(&model, input);
3636
3637        let g = graph.borrow();
3638        assert_eq!(g.nodes.len(), 14);
3639        assert_eq!(g.nodes[0].shape, vec![None, Some(1), Some(28), Some(28)]);
3640        assert_eq!(g.nodes[1].shape, vec![None, Some(6), Some(28), Some(28)]);
3641        assert_eq!(g.nodes[2].shape, vec![None, Some(6), Some(28), Some(28)]);
3642        assert_eq!(g.nodes[3].shape, vec![None, Some(6), Some(14), Some(14)]);
3643        assert_eq!(g.nodes[4].shape, vec![None, Some(16), Some(10), Some(10)]);
3644        assert_eq!(g.nodes[5].shape, vec![None, Some(16), Some(10), Some(10)]);
3645        assert_eq!(g.nodes[6].shape, vec![None, Some(16), Some(5), Some(5)]);
3646        assert_eq!(g.nodes[7].shape, vec![None, Some(400)]);
3647        assert_eq!(g.nodes[8].shape, vec![None, Some(120)]);
3648        assert_eq!(g.nodes[9].shape, vec![None, Some(120)]);
3649        assert_eq!(g.nodes[10].shape, vec![None, Some(84)]);
3650        assert_eq!(g.nodes[11].shape, vec![None, Some(84)]);
3651        assert_eq!(g.nodes[12].shape, vec![None, Some(10)]);
3652        assert_eq!(g.nodes[13].shape, vec![None, Some(10)]);
3653    }
3654
3655    // -----------------------------------------------------------------------
3656    // is_fusable_elementwise / Op::Fused / Graph::fuse_elementwise_chains
3657    // -----------------------------------------------------------------------
3658
3659    #[test]
3660    fn test_is_fusable_elementwise_allowlist() {
3661        assert!(is_fusable_elementwise(&Op::Relu));
3662        assert!(is_fusable_elementwise(&Op::Sigmoid));
3663        assert!(is_fusable_elementwise(&Op::Silu));
3664        assert!(is_fusable_elementwise(&Op::Tanh));
3665    }
3666
3667    #[test]
3668    fn test_is_fusable_elementwise_excludes_non_allowlisted_ops() {
3669        assert!(!is_fusable_elementwise(&Op::Input));
3670        assert!(!is_fusable_elementwise(&Op::Gelu));
3671        assert!(!is_fusable_elementwise(&Op::Softmax { dim: 1 }));
3672        assert!(!is_fusable_elementwise(&Op::Conv2d {
3673            in_channels: 3,
3674            out_channels: 8,
3675            kernel_h: 3,
3676            kernel_w: 3,
3677            stride_h: 1,
3678            stride_w: 1,
3679            padding_h: 1,
3680            padding_w: 1,
3681            groups: 1,
3682            has_bias: false,
3683        }));
3684        assert!(!is_fusable_elementwise(&Op::BatchNorm2d {
3685            num_features: 8,
3686            eps: 1e-5,
3687            momentum: 0.1,
3688            affine: true,
3689            track_running_stats: true,
3690        }));
3691        assert!(!is_fusable_elementwise(&Op::Linear {
3692            in_features: 4,
3693            out_features: 4,
3694            has_bias: false,
3695        }));
3696    }
3697
3698    #[test]
3699    fn test_infer_output_shape_fused_folds_through_members() {
3700        let shape = vec![None, Some(16)];
3701        let out = infer_output_shape(
3702            &Op::Fused {
3703                members: alloc::vec![Op::Relu, Op::Sigmoid, Op::Silu],
3704            },
3705            &[&shape],
3706        );
3707        // All three members are shape-preserving, so the fused node is too.
3708        assert_eq!(out, shape);
3709    }
3710
3711    fn shape_1d(n: usize) -> Shape {
3712        vec![None, Some(n)]
3713    }
3714
3715    #[test]
3716    fn test_fuse_elementwise_chains_fuses_linear_chain() {
3717        let mut g = Graph::new();
3718        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(16));
3719        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(16));
3720        let sigmoid = g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(16));
3721        let _silu = g.add_node(Op::Silu, vec![sigmoid], DtypeRepr::F32, shape_1d(16));
3722
3723        let fused = g.fuse_elementwise_chains();
3724
3725        // Input + one Fused node.
3726        assert_eq!(fused.nodes.len(), 2);
3727        assert!(matches!(fused.nodes[0].op, Op::Input));
3728        match &fused.nodes[1].op {
3729            Op::Fused { members } => {
3730                assert_eq!(members.len(), 3);
3731                assert!(matches!(members[0], Op::Relu));
3732                assert!(matches!(members[1], Op::Sigmoid));
3733                assert!(matches!(members[2], Op::Silu));
3734            }
3735            other => panic!("expected Op::Fused, got {other:?}"),
3736        }
3737        // The fused node's input is the original Input node (rewired correctly).
3738        assert_eq!(fused.nodes[1].inputs, vec![0]);
3739    }
3740
3741    #[test]
3742    fn test_fuse_elementwise_chains_no_fusion_for_single_op() {
3743        let mut g = Graph::new();
3744        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(16));
3745        g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(16));
3746
3747        let fused = g.fuse_elementwise_chains();
3748
3749        assert_eq!(fused.nodes.len(), 2);
3750        assert!(matches!(fused.nodes[1].op, Op::Relu));
3751    }
3752
3753    #[test]
3754    fn test_fuse_elementwise_chains_stops_at_non_fusable_op() {
3755        let mut g = Graph::new();
3756        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3757        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3758        let conv = g.add_node(
3759            Op::Conv2d {
3760                in_channels: 4,
3761                out_channels: 4,
3762                kernel_h: 3,
3763                kernel_w: 3,
3764                stride_h: 1,
3765                stride_w: 1,
3766                padding_h: 1,
3767                padding_w: 1,
3768                groups: 1,
3769                has_bias: false,
3770            },
3771            vec![relu],
3772            DtypeRepr::F32,
3773            shape_1d(4),
3774        );
3775        g.add_node(Op::Sigmoid, vec![conv], DtypeRepr::F32, shape_1d(4));
3776
3777        let fused = g.fuse_elementwise_chains();
3778
3779        // Conv2d isn't fusable, so it blocks fusion on both sides: Relu can't fuse
3780        // forward into Conv2d, and Sigmoid can't fuse backward past it either.
3781        assert_eq!(fused.nodes.len(), 4);
3782        assert!(matches!(fused.nodes[1].op, Op::Relu));
3783        assert!(matches!(fused.nodes[2].op, Op::Conv2d { .. }));
3784        assert!(matches!(fused.nodes[3].op, Op::Sigmoid));
3785    }
3786
3787    #[test]
3788    fn test_fuse_elementwise_chains_no_fusion_when_multiple_consumers() {
3789        let mut g = Graph::new();
3790        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3791        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3792        // Two consumers of `relu` — it can't be absorbed into either chain since
3793        // something else still needs its standalone output.
3794        g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3795        g.add_node(Op::Silu, vec![relu], DtypeRepr::F32, shape_1d(4));
3796
3797        let fused = g.fuse_elementwise_chains();
3798
3799        assert_eq!(fused.nodes.len(), 4);
3800        assert!(matches!(fused.nodes[1].op, Op::Relu));
3801        assert!(matches!(fused.nodes[2].op, Op::Sigmoid));
3802        assert!(matches!(fused.nodes[3].op, Op::Silu));
3803    }
3804
3805    #[test]
3806    fn test_fuse_elementwise_chains_rewires_downstream_consumer() {
3807        let mut g = Graph::new();
3808        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3809        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3810        let sigmoid = g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3811        // A non-fusable consumer downstream of the chain must end up pointing at
3812        // the new Fused node's index, not a dangling/stale one.
3813        g.add_node(
3814            Op::Conv2d {
3815                in_channels: 4,
3816                out_channels: 4,
3817                kernel_h: 1,
3818                kernel_w: 1,
3819                stride_h: 1,
3820                stride_w: 1,
3821                padding_h: 0,
3822                padding_w: 0,
3823                groups: 1,
3824                has_bias: false,
3825            },
3826            vec![sigmoid],
3827            DtypeRepr::F32,
3828            shape_1d(4),
3829        );
3830
3831        let fused = g.fuse_elementwise_chains();
3832
3833        assert_eq!(fused.nodes.len(), 3);
3834        let fused_idx = 1;
3835        assert!(matches!(fused.nodes[fused_idx].op, Op::Fused { .. }));
3836        assert_eq!(fused.nodes[2].inputs, vec![fused_idx]);
3837    }
3838
3839    #[test]
3840    fn test_fuse_elementwise_chains_grows_past_two_via_fixed_point() {
3841        // Four fusable ops in a row: a single fuse_elementwise_chain_pass() call only
3842        // merges one adjacent pair, so this only ends up as one node if the fixed-point
3843        // loop in fuse_elementwise_chains() actually keeps iterating.
3844        let mut g = Graph::new();
3845        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3846        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3847        let sigmoid = g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3848        let silu = g.add_node(Op::Silu, vec![sigmoid], DtypeRepr::F32, shape_1d(4));
3849        g.add_node(Op::Tanh, vec![silu], DtypeRepr::F32, shape_1d(4));
3850
3851        let fused = g.fuse_elementwise_chains();
3852
3853        assert_eq!(fused.nodes.len(), 2);
3854        match &fused.nodes[1].op {
3855            Op::Fused { members } => {
3856                assert_eq!(members.len(), 4);
3857                assert!(matches!(members[0], Op::Relu));
3858                assert!(matches!(members[1], Op::Sigmoid));
3859                assert!(matches!(members[2], Op::Silu));
3860                assert!(matches!(members[3], Op::Tanh));
3861            }
3862            other => panic!("expected Op::Fused, got {other:?}"),
3863        }
3864    }
3865
3866    #[test]
3867    fn test_optimise_does_not_produce_fused_nodes() {
3868        // optimise() must keep behaving exactly as it did before Op::Fused existed —
3869        // fuse_elementwise_chains() is opt-in precisely so existing optimise() callers
3870        // aren't handed a node type their lowering backend doesn't know how to compile.
3871        let mut g = Graph::new();
3872        let input = g.add_node(Op::Input, vec![], DtypeRepr::F32, shape_1d(4));
3873        let relu = g.add_node(Op::Relu, vec![input], DtypeRepr::F32, shape_1d(4));
3874        g.add_node(Op::Sigmoid, vec![relu], DtypeRepr::F32, shape_1d(4));
3875
3876        let optimised = g.optimise();
3877
3878        assert_eq!(optimised.nodes.len(), 3);
3879        for node in &optimised.nodes {
3880            assert!(!matches!(node.op, Op::Fused { .. }));
3881        }
3882    }
3883}