Skip to main content

rlx_tensor/
tensor.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Symbolic tensor handle — zero payload bytes until compile + run.
6
7// The binary/compare builder methods take `impl Into<BinaryRhs>` so callers can
8// pass `&Tensor` or a scalar literal. `BinaryRhs` / `Scalar` are sealed
9// conversion targets — deliberately reachable only through `Into`, never named
10// by callers — so we accept them appearing in these public bounds/signatures.
11// (`impl Into<BinaryRhs>` is a *bound*, hence `private_bounds`.)
12#![allow(private_bounds, private_interfaces)]
13
14use std::ops::{Add, Div, Mul, Neg, Sub};
15
16use rlx_ir::op::{Activation, BinaryOp, CmpOp, MaskKind, ReduceOp};
17use rlx_ir::{DType, Dim, GraphExt, NodeId, Op, ScaleLayout, ScaledFormat, Shape};
18
19use crate::handle::GraphHandle;
20use crate::scalar::{Scalar, promote_scalar};
21use crate::slice::SliceSpec;
22
23/// Generate same-shape unary [`Activation`] methods (`name => Variant`).
24/// Each delegates to [`Tensor::map_act`], so adding a JAX-style elementwise
25/// op is a one-line table entry, not a copied method body.
26macro_rules! unary_ops {
27    ($( $(#[$doc:meta])* $name:ident => $variant:ident ),+ $(,)?) => {
28        impl Tensor {
29            $(
30                $(#[$doc])*
31                pub fn $name(&self) -> Self {
32                    self.map_act(Activation::$variant)
33                }
34            )+
35        }
36    };
37}
38
39/// Generate elementwise comparison methods (`name => Variant`) → `Bool` tensor.
40/// Each accepts a `&Tensor` or a scalar (`x.gt(0.0)`), so masks read naturally
41/// (both directly and from the `rlx!` DSL's `x > 0.0`).
42macro_rules! cmp_ops {
43    ($( $(#[$doc:meta])* $name:ident => $variant:ident ),+ $(,)?) => {
44        impl Tensor {
45            $(
46                $(#[$doc])*
47                pub fn $name<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
48                    self.map_cmp(rhs.into(), CmpOp::$variant)
49                }
50            )+
51        }
52    };
53}
54
55/// Generate axis-reduction methods (`name => Variant`).
56macro_rules! reduce_ops {
57    ($( $(#[$doc:meta])* $name:ident => $variant:ident ),+ $(,)?) => {
58        impl Tensor {
59            $(
60                $(#[$doc])*
61                pub fn $name(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
62                    self.map_reduce(ReduceOp::$variant, axes.into(), keep_dim)
63                }
64            )+
65        }
66    };
67}
68
69/// Convert a [`Dim`] to a reshape-style `i64` (dynamic → `-1`).
70fn dim_to_i64(d: &Dim) -> i64 {
71    match d {
72        Dim::Static(n) => *n as i64,
73        Dim::Dynamic(_) => -1,
74    }
75}
76
77/// Render row-major `data` as nested brackets matching `dims`.
78#[cfg(feature = "eval")]
79fn fmt_nested(data: &[f32], dims: &[usize]) -> String {
80    match dims {
81        [] => format!("{}", data.first().copied().unwrap_or(0.0)),
82        [_] => {
83            let body = data
84                .iter()
85                .map(|x| format!("{x}"))
86                .collect::<Vec<_>>()
87                .join(", ");
88            format!("[{body}]")
89        }
90        [outer, rest @ ..] => {
91            let inner: usize = rest.iter().product();
92            let parts = (0..*outer)
93                .map(|i| fmt_nested(&data[i * inner..(i + 1) * inner], rest))
94                .collect::<Vec<_>>()
95                .join(", ");
96            format!("[{parts}]")
97        }
98    }
99}
100
101#[cfg(feature = "eval")]
102impl std::fmt::Display for Tensor {
103    /// Realizes the tensor (compile + run) and prints values — the lazy graph
104    /// is forced here, like ndarray's eager `Display`.
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.write_str(&self.show())
107    }
108}
109
110/// A symbolic tensor in a traced graph.
111///
112/// **Lazy by construction.** Despite the eager-looking operator syntax, a
113/// `Tensor` is a deferred handle: `&a + &b`, `.relu()`, `.matmul(..)` etc.
114/// only append IR nodes — no host or device buffers are allocated and nothing
115/// executes. The graph is compiled (fused + memory-planned) and run **only**
116/// when you force it with [`to_vec`](Self::to_vec) / [`realize`](Self::realize)
117/// (both require the `eval` feature). This is what lets the whole expression
118/// fuse instead of materializing every intermediate.
119///
120/// **Zero-copy clone & slice.** `clone()` just bumps a refcount on the shared
121/// graph handle — no data is duplicated. `slice`/`narrow`/`reshape`/`select`
122/// add a *view* node into the same graph (referencing the parent by id), so
123/// they move zero payload bytes either. Use [`shares_graph`](Self::shares_graph)
124/// to observe storage sharing and [`storage_bytes`](Self::storage_bytes) to
125/// confirm no data was copied. (Combining two *independently-constructed*
126/// tensors is the one case that copies — the right operand's nodes are merged
127/// in once; see the `handle` module.)
128#[derive(Clone, Debug)]
129pub struct Tensor {
130    pub(crate) handle: GraphHandle,
131    pub(crate) id: NodeId,
132}
133
134impl Tensor {
135    pub(crate) fn new(handle: GraphHandle, id: NodeId) -> Self {
136        Self { handle, id }
137    }
138
139    /// IR node id (mix with raw [`rlx_ir::Graph`] builders via [`NodeId`]).
140    pub fn id(&self) -> NodeId {
141        self.id
142    }
143
144    pub fn shape(&self) -> Shape {
145        self.handle.with_graph(|g| g.shape(self.id).clone())
146    }
147
148    pub fn dtype(&self) -> DType {
149        self.shape().dtype()
150    }
151
152    pub fn rank(&self) -> usize {
153        self.shape().rank()
154    }
155
156    /// Static dimensions as a `Vec<usize>` (panics on a dynamic axis).
157    pub fn dims(&self) -> Vec<usize> {
158        self.shape()
159            .dims()
160            .iter()
161            .map(|d| match d {
162                Dim::Static(n) => *n,
163                Dim::Dynamic(_) => panic!("dims(): tensor has a dynamic axis"),
164            })
165            .collect()
166    }
167
168    /// Total number of elements (panics on a dynamic axis).
169    pub fn numel(&self) -> usize {
170        self.dims().iter().product()
171    }
172
173    /// True when every dimension is statically known.
174    pub fn is_static_shape(&self) -> bool {
175        self.shape().is_static()
176    }
177
178    /// True if `self` and `other` are backed by the **same** graph — i.e. one
179    /// is a [`clone`](Clone) or a view (`slice`/`reshape`/`narrow`/…) of the
180    /// other, sharing storage with zero copy. Tensors from independent
181    /// constructors return `false` until they're combined in an op.
182    pub fn shares_graph(&self, other: &Tensor) -> bool {
183        self.handle.same(&other.handle)
184    }
185
186    /// Total bytes of constant (payload) data physically held in the backing
187    /// graph. **Cloning and slicing do not increase this** — they share
188    /// storage and only add zero-payload view nodes. Combining tensors from
189    /// different graphs copies the right-hand operand's data in (once).
190    pub fn storage_bytes(&self) -> usize {
191        self.handle.with_graph(|g| {
192            g.nodes()
193                .iter()
194                .filter_map(|n| match &n.op {
195                    Op::Constant { data } => Some(data.len()),
196                    _ => None,
197                })
198                .sum()
199        })
200    }
201
202    /// View slice — lowers to [`narrow`](Self::narrow) (no copy at IR level).
203    pub fn slice(&self, spec: SliceSpec) -> Self {
204        spec.apply(self)
205    }
206
207    fn map_unary(&self, f: impl FnOnce(&mut rlx_ir::Graph, NodeId) -> NodeId) -> Self {
208        let id = self.handle.with_graph(|g| f(g, self.id));
209        Self::new(self.handle.clone(), id)
210    }
211
212    /// Pull `other`'s graph into this tensor's graph (no-op when they already
213    /// share one), returning the operand's node id in *this* graph.
214    pub(crate) fn adopt(&self, other: &Tensor) -> NodeId {
215        self.handle.adopt(&other.handle, other.id)
216    }
217
218    fn map_binary(
219        &self,
220        rhs: BinaryRhs<'_>,
221        f: impl FnOnce(&mut rlx_ir::Graph, NodeId, NodeId) -> NodeId,
222    ) -> Self {
223        let rhs_id = match rhs {
224            BinaryRhs::Tensor(v) => self.adopt(v),
225            BinaryRhs::Scalar(s) => self.handle.with_graph(|g| {
226                let dtype = g.shape(self.id).dtype();
227                promote_scalar(g, s, dtype)
228            }),
229        };
230        let id = self.handle.with_graph(|g| f(g, self.id, rhs_id));
231        Self::new(self.handle.clone(), id)
232    }
233
234    /// Apply a unary [`Activation`] with same-shape output.
235    fn map_act(&self, act: Activation) -> Self {
236        self.map_unary(|g, x| {
237            let s = rlx_ir::shape::unary_shape(g.shape(x));
238            g.activation(act, x, s)
239        })
240    }
241
242    /// Elementwise [`BinaryOp`] with broadcast shape inference.
243    fn map_binary_op(&self, rhs: BinaryRhs<'_>, op: BinaryOp) -> Self {
244        self.map_binary(rhs, |g, a, b| {
245            let s = rlx_ir::shape::binary_shape(g.shape(a), g.shape(b))
246                .expect("binary shape inference");
247            g.binary(op, a, b, s)
248        })
249    }
250
251    /// Elementwise comparison ([`CmpOp`]) → `Bool` tensor.
252    fn map_cmp(&self, rhs: BinaryRhs<'_>, op: CmpOp) -> Self {
253        self.map_binary(rhs, |g, a, b| {
254            let s = rlx_ir::shape::compare_shape(g.shape(a), g.shape(b))
255                .expect("compare shape inference");
256            g.add_node(Op::Compare(op), vec![a, b], s)
257        })
258    }
259
260    /// Reduction ([`ReduceOp`]) over `axes`.
261    fn map_reduce(&self, op: ReduceOp, axes: Vec<usize>, keep_dim: bool) -> Self {
262        self.map_unary(|g, x| {
263            let s = rlx_ir::shape::reduce_shape(g.shape(x), &axes, keep_dim)
264                .expect("reduce shape inference");
265            g.reduce(x, op, axes, keep_dim, s)
266        })
267    }
268
269    /// Matrix multiply (`@` in Python / JAX).
270    #[doc(alias = "@")]
271    pub fn mm(&self, rhs: &Tensor) -> Self {
272        self.matmul(rhs)
273    }
274
275    pub fn matmul(&self, rhs: &Tensor) -> Self {
276        self.map_binary(BinaryRhs::Tensor(rhs), |g, a, b| g.mm(a, b))
277    }
278
279    /// Native low-precision GEMM (TN: `self [m,k] · rhs [n,k]ᵀ → [m,n]`).
280    /// Both operands are dynamically quantized to the minifloat `fmt` +
281    /// `layout`; `rhs` must be K-last (`[n, k]`). `fmt` may be any
282    /// [`ScaledFormat`], including a parameterized `Custom` — e.g.
283    /// `t.scaled_matmul(&w, ScaledFormat::custom(3, 0), ScaleLayout::mx())` runs
284    /// the matmul in `f4e3m0`.
285    pub fn scaled_matmul(&self, rhs: &Tensor, fmt: ScaledFormat, layout: ScaleLayout) -> Self {
286        self.map_binary(BinaryRhs::Tensor(rhs), move |g, a, b| {
287            g.scaled_matmul(a, b, fmt, layout)
288        })
289    }
290
291    pub fn softmax(&self, axis: i32) -> Self {
292        self.map_unary(|g, x| g.sm(x, axis))
293    }
294
295    pub fn reshape(&self, new_shape: impl Into<Vec<i64>>) -> Self {
296        self.map_unary(|g, x| g.reshape_(x, new_shape.into()))
297    }
298
299    pub fn transpose(&self, perm: impl Into<Vec<usize>>) -> Self {
300        self.map_unary(|g, x| g.transpose_(x, perm.into()))
301    }
302
303    pub fn t(&self) -> Self {
304        let rank = self.rank();
305        assert!(rank >= 2, "t() requires rank >= 2");
306        let perm: Vec<usize> = (0..rank - 2).chain([rank - 1, rank - 2]).collect();
307        self.transpose(perm)
308    }
309
310    pub fn cast(&self, to: DType) -> Self {
311        self.map_unary(|g, x| g.cast(x, to))
312    }
313
314    pub fn narrow(&self, axis: usize, start: usize, len: usize) -> Self {
315        self.map_unary(|g, x| g.narrow_(x, axis, start, len))
316    }
317
318    /// Broadcast (expand) to an explicit target shape — NumPy `broadcast_to`.
319    /// A `1` axis stretches; sizes must otherwise match. Use `-1`-free static
320    /// targets.
321    pub fn broadcast_to(&self, shape: impl Into<Vec<i64>>) -> Self {
322        let target = shape.into();
323        self.map_unary(|g, x| {
324            let s = rlx_ir::shape::expand_shape(g.shape(x), &target)
325                .expect("broadcast_to shape inference");
326            g.add_node(
327                Op::Expand {
328                    target_shape: target,
329                },
330                vec![x],
331                s,
332            )
333        })
334    }
335
336    /// Flatten to a 1-D tensor (row-major).
337    pub fn flatten(&self) -> Self {
338        self.reshape(vec![-1_i64])
339    }
340
341    /// Insert a size-1 axis at `axis` (NumPy `expand_dims` / PyTorch
342    /// `unsqueeze`).
343    pub fn unsqueeze(&self, axis: usize) -> Self {
344        let mut dims = self.dims_i64();
345        assert!(axis <= dims.len(), "unsqueeze: axis {axis} out of range");
346        dims.insert(axis, 1);
347        self.reshape(dims)
348    }
349
350    /// Remove the size-1 axis at `axis` (panics if it is not size 1).
351    pub fn squeeze(&self, axis: usize) -> Self {
352        let dims = self.shape();
353        let dims = dims.dims();
354        assert!(
355            matches!(dims.get(axis), Some(Dim::Static(1))),
356            "squeeze: axis {axis} is not size 1"
357        );
358        let new: Vec<i64> = dims
359            .iter()
360            .enumerate()
361            .filter(|(i, _)| *i != axis)
362            .map(|(_, d)| dim_to_i64(d))
363            .collect();
364        self.reshape(new)
365    }
366
367    /// Remove every size-1 axis.
368    pub fn squeeze_all(&self) -> Self {
369        let new: Vec<i64> = self
370            .shape()
371            .dims()
372            .iter()
373            .filter(|d| !matches!(d, Dim::Static(1)))
374            .map(dim_to_i64)
375            .collect();
376        self.reshape(new)
377    }
378
379    /// Current dims as `i64` (dynamic → `-1`), for reshape-style builders.
380    fn dims_i64(&self) -> Vec<i64> {
381        self.shape().dims().iter().map(dim_to_i64).collect()
382    }
383
384    /// Split `axis` into contiguous pieces of the given `sizes` (must sum to
385    /// the axis length). NumPy `split` / PyTorch `split_with_sizes`.
386    pub fn split(&self, axis: usize, sizes: &[usize]) -> Vec<Tensor> {
387        let mut out = Vec::with_capacity(sizes.len());
388        let mut start = 0;
389        for &len in sizes {
390            out.push(self.narrow(axis, start, len));
391            start += len;
392        }
393        out
394    }
395
396    /// Split `axis` into `n` near-equal chunks (first `len % n` get one extra).
397    pub fn chunk(&self, axis: usize, n: usize) -> Vec<Tensor> {
398        assert!(n >= 1, "chunk: n must be >= 1");
399        let total = self.dims()[axis];
400        let base = total / n;
401        let rem = total % n;
402        let sizes: Vec<usize> = (0..n).map(|i| base + usize::from(i < rem)).collect();
403        self.split(axis, &sizes)
404    }
405
406    /// Repeat the whole tensor `reps` times along `axis` (NumPy `tile` on one
407    /// axis): output axis length becomes `len * reps`.
408    pub fn tile(&self, axis: usize, reps: usize) -> Tensor {
409        assert!(reps >= 1, "tile: reps must be >= 1");
410        let copies = vec![self; reps];
411        crate::array::cat(&copies, axis)
412    }
413
414    /// Reverse the order of elements along `axis` (NumPy `flip`).
415    pub fn flip(&self, axis: usize) -> Tensor {
416        let n = self.dims()[axis];
417        let rev: Vec<i64> = (0..n as i64).rev().collect();
418        self.index_select(axis, &Tensor::index_vec(rev))
419    }
420
421    /// Cyclically shift elements along `axis` by `shift` (NumPy `roll`).
422    pub fn roll(&self, axis: usize, shift: usize) -> Tensor {
423        let n = self.dims()[axis];
424        let s = if n == 0 { 0 } else { shift % n };
425        if s == 0 {
426            return self.clone();
427        }
428        let tail = self.narrow(axis, n - s, s);
429        let head = self.narrow(axis, 0, n - s);
430        crate::array::cat(&[&tail, &head], axis)
431    }
432
433    /// A constant filled with `value`, built on *this* tensor's graph.
434    ///
435    /// [`Tensor::full`] starts a fresh graph and [`crate::array::cat`] adopts
436    /// into its *first* operand, so padding a traced value with a standalone
437    /// constant would migrate the whole downstream chain off the graph being
438    /// traced — and the next `GraphScope` call, which does not adopt, would
439    /// then index it with a stale id.
440    fn full_on(&self, dims: &[usize], value: f32) -> Tensor {
441        let n: usize = dims.iter().product();
442        let bytes: Vec<u8> = std::iter::repeat_n(value, n)
443            .flat_map(f32::to_le_bytes)
444            .collect();
445        let shape = Shape::new(dims, DType::F32);
446        let id = self
447            .handle
448            .with_graph(|g| g.add_node(Op::Constant { data: bytes }, vec![], shape));
449        Self::new(self.handle.clone(), id)
450    }
451
452    /// Pad `axis` with `before`/`after` entries of `value` (constant pad).
453    pub fn pad(&self, axis: usize, before: usize, after: usize, value: f32) -> Tensor {
454        let mut parts: Vec<Tensor> = Vec::new();
455        if before > 0 {
456            let mut d = self.dims();
457            d[axis] = before;
458            parts.push(self.full_on(&d, value));
459        }
460        parts.push(self.clone());
461        if after > 0 {
462            let mut d = self.dims();
463            d[axis] = after;
464            parts.push(self.full_on(&d, value));
465        }
466        let refs: Vec<&Tensor> = parts.iter().collect();
467        crate::array::cat(&refs, axis)
468    }
469
470    pub fn gather(&self, indices: &Tensor, axis: usize) -> Self {
471        self.map_binary(BinaryRhs::Tensor(indices), |g, table, idx| {
472            g.gather_(table, idx, axis)
473        })
474    }
475
476    /// Gather along `axis` by an index tensor — NumPy `take` / PyTorch
477    /// `index_select`. Alias for [`gather`](Self::gather).
478    pub fn index_select(&self, axis: usize, indices: &Tensor) -> Self {
479        self.gather(indices, axis)
480    }
481
482    /// Pick a single position along `axis`, **dropping** that axis (NumPy
483    /// `a[…, i, …]`). Negative `index` counts from the end.
484    pub fn select(&self, axis: usize, index: i64) -> Self {
485        let dim = match self.shape().dims().get(axis) {
486            Some(Dim::Static(n)) => Some(*n),
487            _ => None,
488        };
489        let pos = crate::slice::resolve_index(index, dim, axis);
490        self.narrow(axis, pos, 1).squeeze(axis)
491    }
492
493    pub fn stop_gradient(&self) -> Self {
494        self.map_unary(|g, x| g.stop_gradient(x))
495    }
496
497    /// Index of the maximum along `axis` (f32-encoded indices). Drops the axis
498    /// unless `keep_dim`.
499    pub fn argmax(&self, axis: usize, keep_dim: bool) -> Self {
500        self.map_unary(|g, x| {
501            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
502                .expect("argmax shape inference");
503            g.argmax(x, axis, keep_dim, s)
504        })
505    }
506
507    /// Index of the minimum along `axis` (f32-encoded indices).
508    pub fn argmin(&self, axis: usize, keep_dim: bool) -> Self {
509        self.map_unary(|g, x| {
510            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
511                .expect("argmin shape inference");
512            g.argmin(x, axis, keep_dim, s)
513        })
514    }
515
516    /// Matrix inverse of a square 2-D tensor. Composite over `DenseSolve`
517    /// (`inv(A) = solve(A, I)`), so it needs a BLAS-backed eval build (LAPACK)
518    /// to run, not the default splat backend.
519    pub fn inv(&self) -> Self {
520        let dims = self.dims();
521        assert!(
522            dims.len() == 2 && dims[0] == dims[1],
523            "inv() requires a square 2-D matrix, got {dims:?}"
524        );
525        let n = dims[0];
526        let eye_id = self.adopt(&Tensor::eye(n));
527        let id = self.handle.with_graph(|g| {
528            let s = Shape::new(&[n, n], DType::F32);
529            g.dense_solve(self.id, eye_id, s)
530        });
531        Self::new(self.handle.clone(), id)
532    }
533
534    /// Solve the linear system `self @ x = b` for `x` (LAPACK via `DenseSolve`;
535    /// needs a BLAS eval build). `x` takes `b`'s shape.
536    pub fn solve(&self, b: &Tensor) -> Self {
537        let s = b.shape();
538        let b_id = self.adopt(b);
539        let id = self.handle.with_graph(|g| g.dense_solve(self.id, b_id, s));
540        Self::new(self.handle.clone(), id)
541    }
542
543    /// Cumulative sum along `axis`. `exclusive` shifts so `output[0]` = 0.
544    pub fn cumsum(&self, axis: i32, exclusive: bool) -> Self {
545        self.map_unary(|g, x| {
546            let s = rlx_ir::shape::unary_shape(g.shape(x));
547            g.cumsum(x, axis, exclusive, s)
548        })
549    }
550
551    /// Population variance over `axes`: `mean((x - mean(x))²)`.
552    pub fn var(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
553        let axes = axes.into();
554        let mu = self.mean(axes.clone(), true);
555        let centered = self - &mu;
556        (&centered * &centered).mean(axes, keep_dim)
557    }
558
559    /// Population standard deviation over `axes`.
560    pub fn std(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
561        self.var(axes, keep_dim).sqrt()
562    }
563
564    /// L2 (Frobenius) norm over `axes`: `sqrt(sum(x²))`.
565    pub fn norm(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
566        (self * self).sum(axes, keep_dim).sqrt()
567    }
568
569    /// Numerically-stable `log(sum(exp(x)))` along `axis`
570    /// (`max + log(sum(exp(x - max)))`).
571    pub fn logsumexp(&self, axis: usize, keep_dim: bool) -> Self {
572        let m = self.max([axis], true);
573        let shifted = self - &m;
574        let summed = shifted.exp().sum([axis], true);
575        let lse = &summed.log() + &m;
576        if keep_dim { lse } else { lse.squeeze(axis) }
577    }
578
579    /// 1-D FFT along the last axis (unnormalized; `ifft(fft(x)) = N·x`).
580    /// Complex data uses the `[..., 2N]` block layout (real plane then imag
581    /// plane) for F32/F64, or a `C64` last axis. Output matches input shape.
582    pub fn fft(&self) -> Self {
583        self.map_unary(|g, x| g.fft(x, false))
584    }
585
586    /// Inverse 1-D FFT along the last axis (unnormalized — pairs with [`Self::fft`]
587    /// up to the `N` factor).
588    pub fn ifft(&self) -> Self {
589        self.map_unary(|g, x| g.fft(x, true))
590    }
591
592    /// 1-D FFT along an arbitrary `axis` (transpose → fft → transpose).
593    pub fn fft_axis(&self, axis: usize, inverse: bool) -> Self {
594        self.map_unary(|g, x| g.fft_axis(x, axis, inverse))
595    }
596
597    pub fn layer_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
598        let g_id = self.adopt(gamma);
599        let b_id = self.adopt(beta);
600        let id = self.handle.with_graph(|g| {
601            let s = rlx_ir::shape::unary_shape(g.shape(self.id));
602            g.layer_norm(self.id, g_id, b_id, -1, eps, s)
603        });
604        Self::new(self.handle.clone(), id)
605    }
606
607    pub fn rms_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
608        let g_id = self.adopt(gamma);
609        let b_id = self.adopt(beta);
610        let id = self
611            .handle
612            .with_graph(|g| g.rms_norm(self.id, g_id, b_id, eps));
613        Self::new(self.handle.clone(), id)
614    }
615
616    /// Fused softmax + cross-entropy against a dense (soft / one-hot) target
617    /// distribution — the standard classifier / language-model loss, in one
618    /// differentiable op (native CPU & Metal kernels; lowered to primitives on
619    /// other backends). `self` are logits `[N, C]`, `targets` a probability
620    /// distribution `[N, C]` (one-hot, or label-smoothed). Returns the per-row
621    /// loss `[N]`: `loss[n] = logsumexp(logits[n]) − Σ_c targets[n,c]·logits[n,c]`.
622    /// Reduce it to a scalar training loss with [`mean_all`](Self::mean_all) —
623    /// `mean(cross_entropy(logits, targets))` in the `rlx!` DSL. Gradients flow
624    /// to both `self` and `targets`.
625    pub fn cross_entropy(&self, targets: &Tensor) -> Self {
626        let t_id = self.adopt(targets);
627        let id = self
628            .handle
629            .with_graph(|g| g.softmax_cross_entropy(self.id, t_id));
630        Self::new(self.handle.clone(), id)
631    }
632
633    /// Fused softmax + cross-entropy with integer class labels — PyTorch
634    /// `cross_entropy(logits, labels, reduction="none")`. `self` are logits
635    /// `[N, C]`, `labels` the f32-encoded class ids `[N]`. Returns the per-row
636    /// loss `[N]`; reduce with [`mean_all`](Self::mean_all) for a scalar loss.
637    pub fn softmax_cross_entropy_with_logits(&self, labels: &Tensor) -> Self {
638        let l_id = self.adopt(labels);
639        let id = self
640            .handle
641            .with_graph(|g| g.softmax_cross_entropy_with_logits(self.id, l_id));
642        Self::new(self.handle.clone(), id)
643    }
644
645    /// Mean over *every* axis → a scalar (rank-0). The idiomatic loss reduction
646    /// — `mean(cross_entropy(logits, targets))` in the `rlx!` DSL, or
647    /// `loss.mean_all()` in Rust. A rank-0 input is returned unchanged.
648    pub fn mean_all(&self) -> Self {
649        let r = self.rank();
650        if r == 0 {
651            return self.clone();
652        }
653        self.mean((0..r).collect::<Vec<usize>>(), false)
654    }
655
656    /// Sum over *every* axis → a scalar (rank-0). A rank-0 input is returned
657    /// unchanged.
658    pub fn sum_all(&self) -> Self {
659        let r = self.rank();
660        if r == 0 {
661            return self.clone();
662        }
663        self.sum((0..r).collect::<Vec<usize>>(), false)
664    }
665
666    /// 2-D pooling over NCHW (`Op::Pool`). Channels pass through; only the two
667    /// spatial axes shrink, by the rule convolution uses with dilation 1.
668    ///
669    /// `kind` selects the reduction — [`ReduceOp::Max`] and [`ReduceOp::Mean`]
670    /// are the ones backends lower.
671    pub fn pool2d(
672        &self,
673        kind: ReduceOp,
674        kernel_size: [usize; 2],
675        stride: [usize; 2],
676        padding: [usize; 2],
677    ) -> Self {
678        let id = self
679            .handle
680            .with_graph(|g| g.pool2d(self.id, kind, kernel_size, stride, padding));
681        Self::new(self.handle.clone(), id)
682    }
683
684    /// Max pooling over NCHW — [`pool2d`](Self::pool2d) with [`ReduceOp::Max`].
685    ///
686    /// ```no_run
687    /// # use rlx_tensor::Tensor;
688    /// # fn f(x: &Tensor) -> Tensor {
689    /// // `[N, C, H, W]` -> halve H with a width-3 window
690    /// x.max_pool2d([3, 1], [2, 1], [0, 0])
691    /// # }
692    /// ```
693    pub fn max_pool2d(
694        &self,
695        kernel_size: [usize; 2],
696        stride: [usize; 2],
697        padding: [usize; 2],
698    ) -> Self {
699        self.pool2d(ReduceOp::Max, kernel_size, stride, padding)
700    }
701
702    /// Average pooling over NCHW — [`pool2d`](Self::pool2d) with [`ReduceOp::Mean`].
703    pub fn avg_pool2d(
704        &self,
705        kernel_size: [usize; 2],
706        stride: [usize; 2],
707        padding: [usize; 2],
708    ) -> Self {
709        self.pool2d(ReduceOp::Mean, kernel_size, stride, padding)
710    }
711
712    pub fn conv2d(
713        &self,
714        weight: &Tensor,
715        kernel_size: [usize; 2],
716        stride: [usize; 2],
717        padding: [usize; 2],
718        dilation: [usize; 2],
719        groups: usize,
720    ) -> Self {
721        let w_id = self.adopt(weight);
722        let id = self.handle.with_graph(|g| {
723            g.conv2d(
724                self.id,
725                w_id,
726                kernel_size,
727                stride,
728                padding,
729                dilation,
730                groups,
731            )
732        });
733        Self::new(self.handle.clone(), id)
734    }
735
736    pub fn attention(
737        &self,
738        k: &Tensor,
739        v: &Tensor,
740        num_heads: usize,
741        head_dim: usize,
742        mask_kind: MaskKind,
743    ) -> Self {
744        let k_id = self.adopt(k);
745        let v_id = self.adopt(v);
746        let id = self.handle.with_graph(|g| {
747            let s = rlx_ir::shape::attention_shape(g.shape(self.id));
748            g.attention_kind(self.id, k_id, v_id, num_heads, head_dim, mask_kind, s)
749        });
750        Self::new(self.handle.clone(), id)
751    }
752
753    /// On-chip codebook weight-synthesis matmul (`Op::SynthMatMul`): `y = x·Wᵀ`
754    /// where the weight `[n, k]` is reconstructed inside the matmul from `indices`
755    /// `[n, k/entry_dim]` (u8, fixed quantization structure) + `codebook`
756    /// `[num_entries, entry_dim]` (f32, trained). `x` is `[m, k]`, output `[m, n]`.
757    /// The codebook is a tiny trainable param; the full weight is never stored —
758    /// "functions not data". Backs `rlx-tiny`.
759    pub fn synth_matmul(
760        &self,
761        indices: &Tensor,
762        codebook: &Tensor,
763        entry_dim: u32,
764        num_entries: u32,
765    ) -> Self {
766        let idx_id = self.adopt(indices);
767        let cb_id = self.adopt(codebook);
768        let id = self.handle.with_graph(|g| {
769            let m = g.shape(self.id).dim(0).unwrap_static();
770            let n = g.shape(idx_id).dim(0).unwrap_static();
771            let out = rlx_ir::Shape::new(&[m, n], rlx_ir::DType::F32);
772            g.synth_matmul(
773                self.id,
774                idx_id,
775                cb_id,
776                rlx_ir::SynthKind::Codebook {
777                    entry_dim,
778                    num_entries,
779                },
780                out,
781            )
782        });
783        Self::new(self.handle.clone(), id)
784    }
785
786    /// Reconstruct the dense weight `W [k,n]` from `self` = indices `[n, k/entry_dim]`
787    /// + `codebook [num_entries, entry_dim]` (the same gather the SynthMatMul
788    /// decompose uses, WITHOUT the matmul). Lets a model fold multi-stage VQ + LoRA
789    /// into ONE effective weight (`W_eff = Σ reconstruct + A·Bᵀ`) so `x·W_eff` is a
790    /// single GEMM — dense-speed forward AND a 2-GEMM backward (the multi-stage /
791    /// LoRA gradients collapse to codebook scatters + rank-r products with no batch
792    /// dim). For training/prefill (large m); decode keeps `synth_matmul` in-kernel.
793    pub fn synth_reconstruct(&self, codebook: &Tensor, entry_dim: u32) -> Self {
794        let cb_id = self.adopt(codebook);
795        let id = self.handle.with_graph(|g| {
796            use rlx_ir::{DType, Op, Shape};
797            // DECOMPOSED reconstruction (Cast→Reshape→Gather→Reshape→Transpose →
798            // W[k,n]), NOT the native `Op::SynthReconstruct` fused kernel. MEASURED
799            // (both `[k,n]` and `[n,k]` native layouts): the fused kernel wins the
800            // FORWARD (~35 vs 47ms — one dispatch vs cast+gather+reshape) but LOSES
801            // the backward by more (~+20ms), because the opaque op hides structure
802            // from the backward CSE + transpose-simplification that dedups the
803            // per-stage `grad_W` transposes and makes `dx=dy·w_bt` free. Net-worse
804            // for TRAINING → keep the transparent primitives here. The native op +
805            // kernel + `LowerSynthReconstruct` oracle stay in-tree (dormant) for a
806            // future INFERENCE-only path (forward win, no backward to regress).
807            let d = entry_dim as usize;
808            let idx_shape = g.shape(self.id);
809            let n = idx_shape.dim(0).unwrap_static();
810            let kb = idx_shape.dim(1).unwrap_static();
811            let (k, p) = (kb * d, n * kb);
812            let idx_i64 = g.add_node(
813                Op::Cast { to: DType::I64 },
814                vec![self.id],
815                Shape::new(&[n, kb], DType::I64),
816            );
817            let idx_flat = g.add_node(
818                Op::Reshape {
819                    new_shape: vec![p as i64],
820                },
821                vec![idx_i64],
822                Shape::new(&[p], DType::I64),
823            );
824            let rows = g.add_node(
825                Op::Gather { axis: 0 },
826                vec![cb_id, idx_flat],
827                Shape::new(&[p, d], DType::F32),
828            );
829            let w_bt = g.add_node(
830                Op::Reshape {
831                    new_shape: vec![n as i64, k as i64],
832                },
833                vec![rows],
834                Shape::new(&[n, k], DType::F32),
835            );
836            g.add_node(
837                Op::Transpose { perm: vec![1, 0] },
838                vec![w_bt],
839                Shape::new(&[k, n], DType::F32),
840            )
841        });
842        Self::new(self.handle.clone(), id)
843    }
844
845    /// KAN learnable per-channel spline activation (`Op::SplineActivation`): each
846    /// channel `c` of the last axis gets its own univariate function, a Gaussian-RBF
847    /// expansion `Σ_g coeff[c,g]·exp(-((x−center_g)·inv_h)²)`. `coeff` is
848    /// `[C, num_basis]` (trained); output matches `self`. A learnable activation
849    /// replacing a fixed GELU/ReLU. Backs `rlx-tiny`.
850    pub fn spline_activation(
851        &self,
852        coeff: &Tensor,
853        num_basis: u32,
854        grid_min: f32,
855        grid_max: f32,
856    ) -> Self {
857        let c_id = self.adopt(coeff);
858        let id = self
859            .handle
860            .with_graph(|g| g.spline_activation(self.id, c_id, num_basis, grid_min, grid_max));
861        Self::new(self.handle.clone(), id)
862    }
863
864    /// HuggingFace-style linear `x · Wᵀ + b` where `w` is stored `[out, in]`,
865    /// as a single fused matmul+bias node (`Op::FusedMatMulBiasAct`). The
866    /// weight transpose is emitted in-graph so callers bind the raw `[out, in]`
867    /// checkpoint tensor directly (no host pre-transpose). Backs the `rlx!`
868    /// `linear(x, w, b)` / `x @t w` sugar.
869    pub fn linear(&self, w: &Tensor, b: &Tensor) -> Self {
870        self.linear_act(w, b, None)
871    }
872
873    /// [`linear`](Self::linear) with a fused elementwise activation applied to
874    /// the result — `act(x · Wᵀ + b)` in one op. Backs `gelu(linear(..))` etc.
875    pub fn linear_act(&self, w: &Tensor, b: &Tensor, act: Option<Activation>) -> Self {
876        let w_id = self.adopt(w);
877        let b_id = self.adopt(b);
878        let id = self.handle.with_graph(|g| {
879            let rank = g.shape(w_id).rank();
880            let wt = g.transpose_(w_id, vec![rank - 1, rank - 2]);
881            let s = rlx_ir::shape::matmul_shape(g.shape(self.id), g.shape(wt))
882                .expect("linear shape inference");
883            g.fused_matmul_bias_act(self.id, wt, b_id, act, s)
884        });
885        Self::new(self.handle.clone(), id)
886    }
887
888    /// Matrix multiply against a transposed right operand: `self · rhsᵀ`
889    /// (`x @t w` in the DSL). Avoids a separate `.t()` at call sites; the
890    /// transpose is emitted in-graph.
891    pub fn matmul_t(&self, rhs: &Tensor) -> Self {
892        self.matmul(&rhs.t())
893    }
894
895    /// Scaled dot-product attention with an explicit `[batch, key_len]` padding
896    /// mask (`1.0` = valid, `<0.5` = ignored) — the 4-input `MaskKind::Custom`
897    /// form that [`attention`](Self::attention) (kernel-synthesized masks) can't
898    /// express. Backs the `rlx!` `attention(q, k, v, mask, heads, head_dim)`
899    /// sugar.
900    pub fn attention_masked(
901        &self,
902        k: &Tensor,
903        v: &Tensor,
904        mask: &Tensor,
905        num_heads: usize,
906        head_dim: usize,
907    ) -> Self {
908        let k_id = self.adopt(k);
909        let v_id = self.adopt(v);
910        let m_id = self.adopt(mask);
911        let id = self.handle.with_graph(|g| {
912            let s = rlx_ir::shape::attention_shape(g.shape(self.id));
913            g.attention(self.id, k_id, v_id, m_id, num_heads, head_dim, s)
914        });
915        Self::new(self.handle.clone(), id)
916    }
917
918    pub fn rope(&self, cos: &Tensor, sin: &Tensor, head_dim: usize) -> Self {
919        let cos_id = self.adopt(cos);
920        let sin_id = self.adopt(sin);
921        let id = self
922            .handle
923            .with_graph(|g| g.rope(self.id, cos_id, sin_id, head_dim));
924        Self::new(self.handle.clone(), id)
925    }
926
927    /// Select `on_true` where `self` (a mask) is true, else `on_false`. Each
928    /// branch is a `&Tensor` or a scalar (`mask.where_(&x, 0.0)`), so the
929    /// `rlx!` DSL's `select(mask, x, 0.0)` lowers here directly. A scalar
930    /// branch is promoted to the other branch's dtype (else `F32`).
931    pub fn where_<'t, 'f>(
932        &self,
933        on_true: impl Into<BinaryRhs<'t>>,
934        on_false: impl Into<BinaryRhs<'f>>,
935    ) -> Self {
936        let t = on_true.into();
937        let f = on_false.into();
938        let hint = if let BinaryRhs::Tensor(x) = &t {
939            x.dtype()
940        } else if let BinaryRhs::Tensor(x) = &f {
941            x.dtype()
942        } else {
943            DType::F32
944        };
945        let t_id = self.where_branch(t, hint);
946        let f_id = self.where_branch(f, hint);
947        let id = self.handle.with_graph(|g| {
948            let s = rlx_ir::shape::binary_shape(g.shape(t_id), g.shape(f_id))
949                .expect("where shape inference");
950            g.add_node(Op::Where, vec![self.id, t_id, f_id], s)
951        });
952        Self::new(self.handle.clone(), id)
953    }
954
955    /// Resolve a `where_` branch to a node id in this graph — adopting a tensor
956    /// or promoting a scalar to `hint`'s dtype.
957    fn where_branch(&self, rhs: BinaryRhs<'_>, hint: DType) -> NodeId {
958        match rhs {
959            BinaryRhs::Tensor(t) => self.adopt(t),
960            BinaryRhs::Scalar(s) => self.handle.with_graph(|g| promote_scalar(g, s, hint)),
961        }
962    }
963
964    /// Replace entries of `self` where `mask` is true with `value` (NumPy
965    /// `masked_fill` / the attention-mask primitive: `scores.masked_fill(causal,
966    /// f32::NEG_INFINITY)` then `softmax`). Requires a static shape.
967    pub fn masked_fill(&self, mask: &Tensor, value: f64) -> Self {
968        let dims: Vec<usize> = self
969            .shape()
970            .dims()
971            .iter()
972            .map(|d| match d {
973                Dim::Static(n) => *n,
974                Dim::Dynamic(_) => panic!("masked_fill requires a static shape"),
975            })
976            .collect();
977        let fill = Tensor::full(dims, value as f32);
978        mask.where_(&fill, self)
979    }
980
981    /// Elementwise maximum (accepts a `&Tensor` or a scalar).
982    pub fn maximum<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
983        self.map_binary_op(rhs.into(), BinaryOp::Max)
984    }
985
986    /// Elementwise minimum (accepts a `&Tensor` or a scalar).
987    pub fn minimum<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
988        self.map_binary_op(rhs.into(), BinaryOp::Min)
989    }
990
991    /// Elementwise `atan2(self, rhs)` — quadrant-aware arctangent.
992    pub fn atan2<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
993        self.map_binary_op(rhs.into(), BinaryOp::Atan2)
994    }
995
996    /// Elementwise `self % rhs` (C `fmod`; accepts a `&Tensor` or a scalar).
997    pub fn rem<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
998        self.map_binary_op(rhs.into(), BinaryOp::Mod)
999    }
1000
1001    /// Clamp values to `[min, max]`.
1002    pub fn clamp(&self, min: f64, max: f64) -> Self {
1003        self.clamp_min(min).clamp_max(max)
1004    }
1005
1006    /// Lower-bound values at `min`.
1007    pub fn clamp_min(&self, min: f64) -> Self {
1008        self.map_binary_op(BinaryRhs::Scalar(min.into()), BinaryOp::Max)
1009    }
1010
1011    /// Upper-bound values at `max`.
1012    pub fn clamp_max(&self, max: f64) -> Self {
1013        self.map_binary_op(BinaryRhs::Scalar(max.into()), BinaryOp::Min)
1014    }
1015
1016    /// Elementwise power `self ^ rhs` (accepts a `&Tensor` or a scalar).
1017    pub fn pow<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
1018        self.map_binary_op(rhs.into(), BinaryOp::Pow)
1019    }
1020
1021    pub fn pow_scalar(&self, exp: f64) -> Self {
1022        self.map_binary_op(BinaryRhs::Scalar(exp.into()), BinaryOp::Pow)
1023    }
1024}
1025
1026unary_ops! {
1027    /// Rectified linear unit `max(x, 0)`.
1028    relu => Relu,
1029    /// Gaussian error linear unit (exact).
1030    gelu => Gelu,
1031    /// GELU, tanh approximation.
1032    gelu_approx => GeluApprox,
1033    /// Sigmoid-weighted linear unit `x · σ(x)` (a.k.a. SwiGLU gate).
1034    silu => Silu,
1035    /// Hyperbolic tangent.
1036    tanh => Tanh,
1037    /// Natural exponential `e^x`.
1038    exp => Exp,
1039    /// Square root.
1040    sqrt => Sqrt,
1041    /// Logistic sigmoid `1 / (1 + e^-x)`.
1042    sigmoid => Sigmoid,
1043    /// Natural logarithm.
1044    log => Log,
1045    /// Reciprocal square root `1 / sqrt(x)`.
1046    rsqrt => Rsqrt,
1047    /// Absolute value.
1048    abs => Abs,
1049    /// Sine.
1050    sin => Sin,
1051    /// Cosine.
1052    cos => Cos,
1053    /// Tangent.
1054    tan => Tan,
1055    /// Arctangent.
1056    atan => Atan,
1057    /// Round to nearest (half-to-even); straight-through gradient.
1058    round => Round,
1059    /// Reciprocal `1 / x`.
1060    recip => Recip,
1061    /// `floor(x)` (piecewise-constant gradient).
1062    floor => Floor,
1063    /// `ceil(x)`.
1064    ceil => Ceil,
1065    /// `sign(x)` = -1/0/+1.
1066    sign => Sign,
1067    /// Softplus `ln(1 + eˣ)`.
1068    softplus => Softplus,
1069    /// ELU (α=1): `x if x>0 else eˣ-1`.
1070    elu => Elu,
1071    /// Gauss error function `erf(x)`.
1072    erf => Erf,
1073    /// HardSwish `x·clamp(x+3,0,6)/6`.
1074    hard_swish => HardSwish,
1075    /// HardSigmoid `clamp(x/6+0.5,0,1)`.
1076    hard_sigmoid => HardSigmoid,
1077    /// Mish `x·tanh(softplus(x))`.
1078    mish => Mish,
1079    /// Softsign `x/(1+|x|)`.
1080    softsign => Softsign,
1081    /// LogSigmoid `log(σ(x))`.
1082    log_sigmoid => LogSigmoid,
1083}
1084
1085cmp_ops! {
1086    /// Elementwise `==`.
1087    eq => Eq,
1088    /// Elementwise `!=`.
1089    ne => Ne,
1090    /// Elementwise `<`.
1091    lt => Lt,
1092    /// Elementwise `<=`.
1093    le => Le,
1094    /// Elementwise `>`.
1095    gt => Gt,
1096    /// Elementwise `>=`.
1097    ge => Ge,
1098}
1099
1100reduce_ops! {
1101    /// Sum over `axes`.
1102    sum => Sum,
1103    /// Mean over `axes`.
1104    mean => Mean,
1105    /// Maximum over `axes`.
1106    max => Max,
1107    /// Minimum over `axes`.
1108    min => Min,
1109    /// Product over `axes`.
1110    prod => Prod,
1111}
1112
1113/// Generate typed little-endian readback methods (`name => (T, DType, width)`):
1114/// realize, assert the tensor dtype, then decode fixed-width POD elements.
1115/// `f32` (the default) and `bool` (multi-encoding) are hand-rolled separately.
1116#[cfg(feature = "eval")]
1117macro_rules! pod_readers {
1118    ($($(#[$m:meta])* $name:ident => ($ty:ty, $dt:ident, $width:literal)),+ $(,)?) => {
1119        $(
1120            $(#[$m])*
1121            pub fn $name(&self) -> Vec<$ty> {
1122                let (bytes, dt) = self.eval_typed();
1123                assert_eq!(
1124                    dt, DType::$dt,
1125                    concat!(stringify!($name), ": tensor dtype is {:?}"), dt,
1126                );
1127                bytes
1128                    .chunks_exact($width)
1129                    .map(|c| <$ty>::from_le_bytes(c.try_into().unwrap()))
1130                    .collect()
1131            }
1132        )+
1133    };
1134}
1135
1136/// Materialization — compile this tensor's graph through `rlx_runtime` and
1137/// read the result back to host memory. The graph stays lazy until one of
1138/// these is called, so the compiler fuses and memory-plans the whole
1139/// expression first. Available with the `eval` feature.
1140#[cfg(feature = "eval")]
1141impl Tensor {
1142    /// Compile + run and copy the output to a `Vec<f32>`. The device is chosen
1143    /// automatically — the fastest backend compiled into this build that can
1144    /// run the graph (CPU when nothing faster is available).
1145    pub fn to_vec(&self) -> Vec<f32> {
1146        self.to_vec_on(self.auto_device())
1147    }
1148
1149    /// Compile + run on an explicit device.
1150    pub fn to_vec_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
1151        // A raw constant's realized value is its own host data — device-independent,
1152        // so return it directly and skip backend compilation. This is both an
1153        // optimization (no GPU round-trip for a constant) and a robustness fix: the
1154        // MLX backend crashes when asked to compile a constant-only trace.
1155        if let Some((bytes, DType::F32)) = self.constant_payload() {
1156            return bytes
1157                .chunks_exact(4)
1158                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
1159                .collect();
1160        }
1161        let compiled = self.compiled_on(device);
1162        let mut outputs = compiled.borrow_mut().run(&[]);
1163        outputs.pop().expect("eval produced no output")
1164    }
1165
1166    /// If this tensor's output node is a raw `Op::Constant` that physically holds its
1167    /// full contiguous payload, returns `(bytes, dtype)` — its realized value with no
1168    /// computation to run. (Views/slices of a constant are *not* raw constants, so
1169    /// they fall through to normal evaluation.)
1170    fn constant_payload(&self) -> Option<(Vec<u8>, DType)> {
1171        self.handle.with_graph(|g| {
1172            let node = g.node(self.id);
1173            if let Op::Constant { data } = &node.op {
1174                if node.shape.size_bytes() == Some(data.len()) {
1175                    return Some((data.clone(), node.shape.dtype()));
1176                }
1177            }
1178            None
1179        })
1180    }
1181
1182    /// Fastest available backend that can run this tensor's graph (no clone).
1183    fn auto_device(&self) -> rlx_runtime::Device {
1184        self.handle
1185            .with_graph(|g| rlx_runtime::fastest_device_for(g))
1186    }
1187
1188    /// Compiled graph for this tensor's output — borrows the shared graph for
1189    /// the cache fingerprint, cloning only on a miss (zero-copy on hits).
1190    fn compiled_on(
1191        &self,
1192        device: rlx_runtime::Device,
1193    ) -> std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>> {
1194        self.handle
1195            .with_graph(|g| crate::cache::compiled_output(g, self.id, device))
1196    }
1197
1198    /// Force evaluation — alias for [`to_vec`](Self::to_vec). Building a
1199    /// `Tensor` allocates **nothing** and runs **nothing**; it only records IR.
1200    /// `realize` is the single point where the accumulated graph is compiled
1201    /// (fused + memory-planned) and executed. Use it to make the lazy model
1202    /// explicit at call sites.
1203    pub fn realize(&self) -> Vec<f32> {
1204        self.to_vec()
1205    }
1206
1207    /// [`realize`](Self::realize) on an explicit device.
1208    pub fn realize_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
1209        self.to_vec_on(device)
1210    }
1211
1212    /// Realize a scalar (rank-0 or single-element) tensor to one `f32`.
1213    pub fn item(&self) -> f32 {
1214        let v = self.to_vec();
1215        assert_eq!(v.len(), 1, "item(): tensor has {} elements, not 1", v.len());
1216        v[0]
1217    }
1218
1219    pod_readers! {
1220        /// Realize and read back as `f64` (tensor dtype must be `F64`).
1221        to_vec_f64 => (f64, F64, 8),
1222        /// Realize and read back as `i64` (tensor dtype must be `I64`).
1223        to_vec_i64 => (i64, I64, 8),
1224        /// Realize and read back as `i32` (tensor dtype must be `I32`).
1225        to_vec_i32 => (i32, I32, 4),
1226    }
1227
1228    /// Realize and read back a `Bool` tensor. Robust to the backend storing
1229    /// bools 1-byte or f32-encoded (an element is `true` iff any of its bytes
1230    /// is non-zero).
1231    pub fn to_vec_bool(&self) -> Vec<bool> {
1232        let (bytes, dt) = self.eval_typed();
1233        assert_eq!(dt, DType::Bool, "to_vec_bool: tensor dtype is {dt:?}");
1234        let n = self.numel();
1235        assert!(n > 0 && bytes.len() % n == 0, "to_vec_bool: ragged output");
1236        let w = bytes.len() / n;
1237        bytes
1238            .chunks_exact(w)
1239            .map(|c| c.iter().any(|&b| b != 0))
1240            .collect()
1241    }
1242
1243    /// Compile + run, returning the output's raw little-endian bytes + dtype.
1244    /// Device is auto-selected; backs the typed `to_vec_*` readers.
1245    fn eval_typed(&self) -> (Vec<u8>, DType) {
1246        // Raw constant → its bytes are the result (see `to_vec_on`).
1247        if let Some(ct) = self.constant_payload() {
1248            return ct;
1249        }
1250        let compiled = self.compiled_on(self.auto_device());
1251        let mut outputs = compiled.borrow_mut().run_typed(&[]);
1252        outputs.pop().expect("eval produced no output")
1253    }
1254
1255    /// Realize and pretty-print with shape + dtype, NumPy/ndarray-style nesting.
1256    /// `println!("{}", t)` does the same (with the `eval` feature).
1257    pub fn show(&self) -> String {
1258        let dims = self.dims();
1259        let data = self.to_vec();
1260        format!(
1261            "Tensor{dims:?} {:?}\n{}",
1262            self.dtype(),
1263            fmt_nested(&data, &dims)
1264        )
1265    }
1266
1267    /// Select a device fluently: `t.on(Device::Metal).to_vec()`.
1268    pub fn on(&self, device: rlx_runtime::Device) -> Materialize<'_> {
1269        Materialize {
1270            tensor: self,
1271            device,
1272        }
1273    }
1274}
1275
1276/// Fluent device selector returned by [`Tensor::on`].
1277#[cfg(feature = "eval")]
1278pub struct Materialize<'a> {
1279    tensor: &'a Tensor,
1280    device: rlx_runtime::Device,
1281}
1282
1283#[cfg(feature = "eval")]
1284impl Materialize<'_> {
1285    /// Compile + run on the selected device and copy the output to host.
1286    pub fn to_vec(self) -> Vec<f32> {
1287        self.tensor.to_vec_on(self.device)
1288    }
1289}
1290
1291/// Reverse-mode autodiff. Available with the `grad` feature; pair with `eval`
1292/// to read the resulting gradient values.
1293#[cfg(feature = "autodiff")]
1294impl Tensor {
1295    /// Gradients of this tensor (treated as the loss) w.r.t. each `wrt`,
1296    /// returned as symbolic [`Tensor`]s in the same order as `wrt`.
1297    ///
1298    /// The seed `∂self/∂self = 1` is baked in as a constant (ones, matching
1299    /// `self`'s shape — so a non-scalar `self` differentiates `sum(self)`),
1300    /// making each returned gradient a self-contained graph you can
1301    /// `to_vec()` or compose further.
1302    ///
1303    /// ```ignore
1304    /// let loss = (&a * &b).sum([0], false);
1305    /// let g = loss.grad(&[&a, &b]);   // [∂/∂a, ∂/∂b]
1306    /// assert_eq!(g[0].to_vec(), b.to_vec());
1307    /// ```
1308    pub fn grad(&self, wrt: &[&Tensor]) -> Vec<Tensor> {
1309        // Resolve each `wrt` to its node id *within this loss's graph*.
1310        // Memoized adoption guarantees these are the exact nodes the loss was
1311        // built from, so gradient actually flows (not to a stale duplicate).
1312        let wrt_ids: Vec<NodeId> = wrt.iter().map(|t| self.adopt(t)).collect();
1313        let loss_id = self.id;
1314        let bwd = self.handle.with_graph(|g| {
1315            let mut forward = g.clone();
1316            forward.set_outputs(vec![loss_id]);
1317            let mut bwd = rlx_autodiff::grad(&forward, &wrt_ids);
1318            bake_unit_seed(&mut bwd);
1319            bwd
1320        });
1321        let handle = GraphHandle::new(bwd);
1322        let outputs = handle.with_graph(|g| g.outputs.clone());
1323        outputs
1324            .into_iter()
1325            .map(|id| Tensor::new(handle.clone(), id))
1326            .collect()
1327    }
1328}
1329
1330/// Replace `rlx_autodiff`'s `d_output` seed input with a constant of ones, so
1331/// the backward graph runs with no external inputs.
1332#[cfg(feature = "autodiff")]
1333pub(crate) fn bake_unit_seed(bwd: &mut rlx_ir::Graph) {
1334    let seed = bwd.nodes().iter().find_map(|n| match &n.op {
1335        Op::Input { name } if name == "d_output" => Some(n.id),
1336        _ => None,
1337    });
1338    if let Some(id) = seed {
1339        let n = bwd.node(id).shape.num_elements().unwrap_or(1);
1340        let data: Vec<u8> = (0..n).flat_map(|_| 1.0f32.to_le_bytes()).collect();
1341        let node = bwd.node_mut(id);
1342        node.op = Op::Constant { data };
1343        node.inputs = Vec::new();
1344    }
1345}
1346
1347impl From<Tensor> for NodeId {
1348    fn from(v: Tensor) -> Self {
1349        v.id
1350    }
1351}
1352
1353impl From<&Tensor> for NodeId {
1354    fn from(v: &Tensor) -> Self {
1355        v.id
1356    }
1357}
1358
1359pub(crate) enum BinaryRhs<'a> {
1360    Tensor(&'a Tensor),
1361    Scalar(Scalar),
1362}
1363
1364impl<'a> BinaryRhs<'a> {
1365    fn scalar(s: Scalar) -> Self {
1366        Self::Scalar(s)
1367    }
1368}
1369
1370impl<'a> From<&'a Tensor> for BinaryRhs<'a> {
1371    fn from(v: &'a Tensor) -> Self {
1372        Self::Tensor(v)
1373    }
1374}
1375
1376macro_rules! impl_scalar_rhs {
1377    ($($ty:ty),+ $(,)?) => {
1378        $(
1379            impl From<$ty> for BinaryRhs<'_> {
1380                fn from(v: $ty) -> Self {
1381                    Self::Scalar(v.into())
1382                }
1383            }
1384        )+
1385    };
1386}
1387
1388impl_scalar_rhs!(bool, i32, i64, f32, f64);
1389
1390macro_rules! impl_tensor_binop {
1391    ($trait:ident, $method:ident, $graph:ident) => {
1392        impl $trait<&Tensor> for &Tensor {
1393            type Output = Tensor;
1394
1395            fn $method(self, rhs: &Tensor) -> Tensor {
1396                self.map_binary(BinaryRhs::Tensor(rhs), |g, a, b| g.$graph(a, b))
1397            }
1398        }
1399
1400        impl $trait<Tensor> for &Tensor {
1401            type Output = Tensor;
1402
1403            fn $method(self, rhs: Tensor) -> Tensor {
1404                self.$method(&rhs)
1405            }
1406        }
1407
1408        impl $trait<f64> for &Tensor {
1409            type Output = Tensor;
1410
1411            fn $method(self, rhs: f64) -> Tensor {
1412                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1413            }
1414        }
1415
1416        impl $trait<f32> for &Tensor {
1417            type Output = Tensor;
1418
1419            fn $method(self, rhs: f32) -> Tensor {
1420                self.$method(rhs as f64)
1421            }
1422        }
1423
1424        impl $trait<i32> for &Tensor {
1425            type Output = Tensor;
1426
1427            fn $method(self, rhs: i32) -> Tensor {
1428                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1429            }
1430        }
1431
1432        impl $trait<&Tensor> for Tensor {
1433            type Output = Tensor;
1434
1435            fn $method(self, rhs: &Tensor) -> Tensor {
1436                (&self).$method(rhs)
1437            }
1438        }
1439
1440        impl $trait<Tensor> for Tensor {
1441            type Output = Tensor;
1442
1443            fn $method(self, rhs: Tensor) -> Tensor {
1444                (&self).$method(&rhs)
1445            }
1446        }
1447
1448        impl $trait<f64> for Tensor {
1449            type Output = Tensor;
1450
1451            fn $method(self, rhs: f64) -> Tensor {
1452                (&self).$method(rhs)
1453            }
1454        }
1455
1456        impl $trait<f32> for Tensor {
1457            type Output = Tensor;
1458
1459            fn $method(self, rhs: f32) -> Tensor {
1460                (&self).$method(rhs)
1461            }
1462        }
1463
1464        impl $trait<i32> for Tensor {
1465            type Output = Tensor;
1466
1467            fn $method(self, rhs: i32) -> Tensor {
1468                (&self).$method(rhs)
1469            }
1470        }
1471    };
1472}
1473
1474impl_tensor_binop!(Add, add, add);
1475impl_tensor_binop!(Sub, sub, sub);
1476impl_tensor_binop!(Mul, mul, mul);
1477impl_tensor_binop!(Div, div, div);
1478
1479impl Neg for &Tensor {
1480    type Output = Tensor;
1481
1482    fn neg(self) -> Tensor {
1483        self.map_unary(|g, x| g.neg(x))
1484    }
1485}
1486
1487macro_rules! impl_scalar_left {
1488    ($trait:ident, $method:ident, $graph:ident) => {
1489        impl $trait<&Tensor> for f64 {
1490            type Output = Tensor;
1491
1492            fn $method(self, rhs: &Tensor) -> Tensor {
1493                let id = rhs.handle.with_graph(|g| {
1494                    let lhs_id = promote_scalar(g, self.into(), g.shape(rhs.id).dtype());
1495                    g.$graph(lhs_id, rhs.id)
1496                });
1497                Tensor::new(rhs.handle.clone(), id)
1498            }
1499        }
1500
1501        impl $trait<&Tensor> for f32 {
1502            type Output = Tensor;
1503
1504            fn $method(self, rhs: &Tensor) -> Tensor {
1505                (self as f64).$method(rhs)
1506            }
1507        }
1508
1509        impl $trait<&Tensor> for i32 {
1510            type Output = Tensor;
1511
1512            fn $method(self, rhs: &Tensor) -> Tensor {
1513                (self as i64 as f64).$method(rhs)
1514            }
1515        }
1516    };
1517}
1518
1519impl_scalar_left!(Add, add, add);
1520impl_scalar_left!(Sub, sub, sub);
1521impl_scalar_left!(Mul, mul, mul);
1522impl_scalar_left!(Div, div, div);
1523
1524impl Neg for Tensor {
1525    type Output = Tensor;
1526
1527    fn neg(self) -> Tensor {
1528        (&self).neg()
1529    }
1530}