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    /// Pad `axis` with `before`/`after` entries of `value` (constant pad).
434    pub fn pad(&self, axis: usize, before: usize, after: usize, value: f32) -> Tensor {
435        let mut parts: Vec<Tensor> = Vec::new();
436        if before > 0 {
437            let mut d = self.dims();
438            d[axis] = before;
439            parts.push(Tensor::full(&d, value));
440        }
441        parts.push(self.clone());
442        if after > 0 {
443            let mut d = self.dims();
444            d[axis] = after;
445            parts.push(Tensor::full(&d, value));
446        }
447        let refs: Vec<&Tensor> = parts.iter().collect();
448        crate::array::cat(&refs, axis)
449    }
450
451    pub fn gather(&self, indices: &Tensor, axis: usize) -> Self {
452        self.map_binary(BinaryRhs::Tensor(indices), |g, table, idx| {
453            g.gather_(table, idx, axis)
454        })
455    }
456
457    /// Gather along `axis` by an index tensor — NumPy `take` / PyTorch
458    /// `index_select`. Alias for [`gather`](Self::gather).
459    pub fn index_select(&self, axis: usize, indices: &Tensor) -> Self {
460        self.gather(indices, axis)
461    }
462
463    /// Pick a single position along `axis`, **dropping** that axis (NumPy
464    /// `a[…, i, …]`). Negative `index` counts from the end.
465    pub fn select(&self, axis: usize, index: i64) -> Self {
466        let dim = match self.shape().dims().get(axis) {
467            Some(Dim::Static(n)) => Some(*n),
468            _ => None,
469        };
470        let pos = crate::slice::resolve_index(index, dim, axis);
471        self.narrow(axis, pos, 1).squeeze(axis)
472    }
473
474    pub fn stop_gradient(&self) -> Self {
475        self.map_unary(|g, x| g.stop_gradient(x))
476    }
477
478    /// Index of the maximum along `axis` (f32-encoded indices). Drops the axis
479    /// unless `keep_dim`.
480    pub fn argmax(&self, axis: usize, keep_dim: bool) -> Self {
481        self.map_unary(|g, x| {
482            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
483                .expect("argmax shape inference");
484            g.argmax(x, axis, keep_dim, s)
485        })
486    }
487
488    /// Index of the minimum along `axis` (f32-encoded indices).
489    pub fn argmin(&self, axis: usize, keep_dim: bool) -> Self {
490        self.map_unary(|g, x| {
491            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
492                .expect("argmin shape inference");
493            g.argmin(x, axis, keep_dim, s)
494        })
495    }
496
497    /// Matrix inverse of a square 2-D tensor. Composite over `DenseSolve`
498    /// (`inv(A) = solve(A, I)`), so it needs a BLAS-backed eval build (LAPACK)
499    /// to run, not the default splat backend.
500    pub fn inv(&self) -> Self {
501        let dims = self.dims();
502        assert!(
503            dims.len() == 2 && dims[0] == dims[1],
504            "inv() requires a square 2-D matrix, got {dims:?}"
505        );
506        let n = dims[0];
507        let eye_id = self.adopt(&Tensor::eye(n));
508        let id = self.handle.with_graph(|g| {
509            let s = Shape::new(&[n, n], DType::F32);
510            g.dense_solve(self.id, eye_id, s)
511        });
512        Self::new(self.handle.clone(), id)
513    }
514
515    /// Solve the linear system `self @ x = b` for `x` (LAPACK via `DenseSolve`;
516    /// needs a BLAS eval build). `x` takes `b`'s shape.
517    pub fn solve(&self, b: &Tensor) -> Self {
518        let s = b.shape();
519        let b_id = self.adopt(b);
520        let id = self.handle.with_graph(|g| g.dense_solve(self.id, b_id, s));
521        Self::new(self.handle.clone(), id)
522    }
523
524    /// Cumulative sum along `axis`. `exclusive` shifts so `output[0]` = 0.
525    pub fn cumsum(&self, axis: i32, exclusive: bool) -> Self {
526        self.map_unary(|g, x| {
527            let s = rlx_ir::shape::unary_shape(g.shape(x));
528            g.cumsum(x, axis, exclusive, s)
529        })
530    }
531
532    /// Population variance over `axes`: `mean((x - mean(x))²)`.
533    pub fn var(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
534        let axes = axes.into();
535        let mu = self.mean(axes.clone(), true);
536        let centered = self - &mu;
537        (&centered * &centered).mean(axes, keep_dim)
538    }
539
540    /// Population standard deviation over `axes`.
541    pub fn std(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
542        self.var(axes, keep_dim).sqrt()
543    }
544
545    /// L2 (Frobenius) norm over `axes`: `sqrt(sum(x²))`.
546    pub fn norm(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
547        (self * self).sum(axes, keep_dim).sqrt()
548    }
549
550    /// Numerically-stable `log(sum(exp(x)))` along `axis`
551    /// (`max + log(sum(exp(x - max)))`).
552    pub fn logsumexp(&self, axis: usize, keep_dim: bool) -> Self {
553        let m = self.max([axis], true);
554        let shifted = self - &m;
555        let summed = shifted.exp().sum([axis], true);
556        let lse = &summed.log() + &m;
557        if keep_dim { lse } else { lse.squeeze(axis) }
558    }
559
560    /// 1-D FFT along the last axis (unnormalized; `ifft(fft(x)) = N·x`).
561    /// Complex data uses the `[..., 2N]` block layout (real plane then imag
562    /// plane) for F32/F64, or a `C64` last axis. Output matches input shape.
563    pub fn fft(&self) -> Self {
564        self.map_unary(|g, x| g.fft(x, false))
565    }
566
567    /// Inverse 1-D FFT along the last axis (unnormalized — pairs with [`Self::fft`]
568    /// up to the `N` factor).
569    pub fn ifft(&self) -> Self {
570        self.map_unary(|g, x| g.fft(x, true))
571    }
572
573    /// 1-D FFT along an arbitrary `axis` (transpose → fft → transpose).
574    pub fn fft_axis(&self, axis: usize, inverse: bool) -> Self {
575        self.map_unary(|g, x| g.fft_axis(x, axis, inverse))
576    }
577
578    pub fn layer_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
579        let g_id = self.adopt(gamma);
580        let b_id = self.adopt(beta);
581        let id = self.handle.with_graph(|g| {
582            let s = rlx_ir::shape::unary_shape(g.shape(self.id));
583            g.layer_norm(self.id, g_id, b_id, -1, eps, s)
584        });
585        Self::new(self.handle.clone(), id)
586    }
587
588    pub fn rms_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
589        let g_id = self.adopt(gamma);
590        let b_id = self.adopt(beta);
591        let id = self
592            .handle
593            .with_graph(|g| g.rms_norm(self.id, g_id, b_id, eps));
594        Self::new(self.handle.clone(), id)
595    }
596
597    /// Fused softmax + cross-entropy against a dense (soft / one-hot) target
598    /// distribution — the standard classifier / language-model loss, in one
599    /// differentiable op (native CPU & Metal kernels; lowered to primitives on
600    /// other backends). `self` are logits `[N, C]`, `targets` a probability
601    /// distribution `[N, C]` (one-hot, or label-smoothed). Returns the per-row
602    /// loss `[N]`: `loss[n] = logsumexp(logits[n]) − Σ_c targets[n,c]·logits[n,c]`.
603    /// Reduce it to a scalar training loss with [`mean_all`](Self::mean_all) —
604    /// `mean(cross_entropy(logits, targets))` in the `rlx!` DSL. Gradients flow
605    /// to both `self` and `targets`.
606    pub fn cross_entropy(&self, targets: &Tensor) -> Self {
607        let t_id = self.adopt(targets);
608        let id = self
609            .handle
610            .with_graph(|g| g.softmax_cross_entropy(self.id, t_id));
611        Self::new(self.handle.clone(), id)
612    }
613
614    /// Fused softmax + cross-entropy with integer class labels — PyTorch
615    /// `cross_entropy(logits, labels, reduction="none")`. `self` are logits
616    /// `[N, C]`, `labels` the f32-encoded class ids `[N]`. Returns the per-row
617    /// loss `[N]`; reduce with [`mean_all`](Self::mean_all) for a scalar loss.
618    pub fn softmax_cross_entropy_with_logits(&self, labels: &Tensor) -> Self {
619        let l_id = self.adopt(labels);
620        let id = self
621            .handle
622            .with_graph(|g| g.softmax_cross_entropy_with_logits(self.id, l_id));
623        Self::new(self.handle.clone(), id)
624    }
625
626    /// Mean over *every* axis → a scalar (rank-0). The idiomatic loss reduction
627    /// — `mean(cross_entropy(logits, targets))` in the `rlx!` DSL, or
628    /// `loss.mean_all()` in Rust. A rank-0 input is returned unchanged.
629    pub fn mean_all(&self) -> Self {
630        let r = self.rank();
631        if r == 0 {
632            return self.clone();
633        }
634        self.mean((0..r).collect::<Vec<usize>>(), false)
635    }
636
637    /// Sum over *every* axis → a scalar (rank-0). A rank-0 input is returned
638    /// unchanged.
639    pub fn sum_all(&self) -> Self {
640        let r = self.rank();
641        if r == 0 {
642            return self.clone();
643        }
644        self.sum((0..r).collect::<Vec<usize>>(), false)
645    }
646
647    pub fn conv2d(
648        &self,
649        weight: &Tensor,
650        kernel_size: [usize; 2],
651        stride: [usize; 2],
652        padding: [usize; 2],
653        dilation: [usize; 2],
654        groups: usize,
655    ) -> Self {
656        let w_id = self.adopt(weight);
657        let id = self.handle.with_graph(|g| {
658            g.conv2d(
659                self.id,
660                w_id,
661                kernel_size,
662                stride,
663                padding,
664                dilation,
665                groups,
666            )
667        });
668        Self::new(self.handle.clone(), id)
669    }
670
671    pub fn attention(
672        &self,
673        k: &Tensor,
674        v: &Tensor,
675        num_heads: usize,
676        head_dim: usize,
677        mask_kind: MaskKind,
678    ) -> Self {
679        let k_id = self.adopt(k);
680        let v_id = self.adopt(v);
681        let id = self.handle.with_graph(|g| {
682            let s = rlx_ir::shape::attention_shape(g.shape(self.id));
683            g.attention_kind(self.id, k_id, v_id, num_heads, head_dim, mask_kind, s)
684        });
685        Self::new(self.handle.clone(), id)
686    }
687
688    /// On-chip codebook weight-synthesis matmul (`Op::SynthMatMul`): `y = x·Wᵀ`
689    /// where the weight `[n, k]` is reconstructed inside the matmul from `indices`
690    /// `[n, k/entry_dim]` (u8, fixed quantization structure) + `codebook`
691    /// `[num_entries, entry_dim]` (f32, trained). `x` is `[m, k]`, output `[m, n]`.
692    /// The codebook is a tiny trainable param; the full weight is never stored —
693    /// "functions not data". Backs `rlx-tiny`.
694    pub fn synth_matmul(
695        &self,
696        indices: &Tensor,
697        codebook: &Tensor,
698        entry_dim: u32,
699        num_entries: u32,
700    ) -> Self {
701        let idx_id = self.adopt(indices);
702        let cb_id = self.adopt(codebook);
703        let id = self.handle.with_graph(|g| {
704            let m = g.shape(self.id).dim(0).unwrap_static();
705            let n = g.shape(idx_id).dim(0).unwrap_static();
706            let out = rlx_ir::Shape::new(&[m, n], rlx_ir::DType::F32);
707            g.synth_matmul(
708                self.id,
709                idx_id,
710                cb_id,
711                rlx_ir::SynthKind::Codebook {
712                    entry_dim,
713                    num_entries,
714                },
715                out,
716            )
717        });
718        Self::new(self.handle.clone(), id)
719    }
720
721    /// Reconstruct the dense weight `W [k,n]` from `self` = indices `[n, k/entry_dim]`
722    /// + `codebook [num_entries, entry_dim]` (the same gather the SynthMatMul
723    /// decompose uses, WITHOUT the matmul). Lets a model fold multi-stage VQ + LoRA
724    /// into ONE effective weight (`W_eff = Σ reconstruct + A·Bᵀ`) so `x·W_eff` is a
725    /// single GEMM — dense-speed forward AND a 2-GEMM backward (the multi-stage /
726    /// LoRA gradients collapse to codebook scatters + rank-r products with no batch
727    /// dim). For training/prefill (large m); decode keeps `synth_matmul` in-kernel.
728    pub fn synth_reconstruct(&self, codebook: &Tensor, entry_dim: u32) -> Self {
729        let cb_id = self.adopt(codebook);
730        let id = self.handle.with_graph(|g| {
731            use rlx_ir::{DType, Op, Shape};
732            // DECOMPOSED reconstruction (Cast→Reshape→Gather→Reshape→Transpose →
733            // W[k,n]), NOT the native `Op::SynthReconstruct` fused kernel. MEASURED
734            // (both `[k,n]` and `[n,k]` native layouts): the fused kernel wins the
735            // FORWARD (~35 vs 47ms — one dispatch vs cast+gather+reshape) but LOSES
736            // the backward by more (~+20ms), because the opaque op hides structure
737            // from the backward CSE + transpose-simplification that dedups the
738            // per-stage `grad_W` transposes and makes `dx=dy·w_bt` free. Net-worse
739            // for TRAINING → keep the transparent primitives here. The native op +
740            // kernel + `LowerSynthReconstruct` oracle stay in-tree (dormant) for a
741            // future INFERENCE-only path (forward win, no backward to regress).
742            let d = entry_dim as usize;
743            let idx_shape = g.shape(self.id);
744            let n = idx_shape.dim(0).unwrap_static();
745            let kb = idx_shape.dim(1).unwrap_static();
746            let (k, p) = (kb * d, n * kb);
747            let idx_i64 = g.add_node(
748                Op::Cast { to: DType::I64 },
749                vec![self.id],
750                Shape::new(&[n, kb], DType::I64),
751            );
752            let idx_flat = g.add_node(
753                Op::Reshape {
754                    new_shape: vec![p as i64],
755                },
756                vec![idx_i64],
757                Shape::new(&[p], DType::I64),
758            );
759            let rows = g.add_node(
760                Op::Gather { axis: 0 },
761                vec![cb_id, idx_flat],
762                Shape::new(&[p, d], DType::F32),
763            );
764            let w_bt = g.add_node(
765                Op::Reshape {
766                    new_shape: vec![n as i64, k as i64],
767                },
768                vec![rows],
769                Shape::new(&[n, k], DType::F32),
770            );
771            g.add_node(
772                Op::Transpose { perm: vec![1, 0] },
773                vec![w_bt],
774                Shape::new(&[k, n], DType::F32),
775            )
776        });
777        Self::new(self.handle.clone(), id)
778    }
779
780    /// KAN learnable per-channel spline activation (`Op::SplineActivation`): each
781    /// channel `c` of the last axis gets its own univariate function, a Gaussian-RBF
782    /// expansion `Σ_g coeff[c,g]·exp(-((x−center_g)·inv_h)²)`. `coeff` is
783    /// `[C, num_basis]` (trained); output matches `self`. A learnable activation
784    /// replacing a fixed GELU/ReLU. Backs `rlx-tiny`.
785    pub fn spline_activation(
786        &self,
787        coeff: &Tensor,
788        num_basis: u32,
789        grid_min: f32,
790        grid_max: f32,
791    ) -> Self {
792        let c_id = self.adopt(coeff);
793        let id = self
794            .handle
795            .with_graph(|g| g.spline_activation(self.id, c_id, num_basis, grid_min, grid_max));
796        Self::new(self.handle.clone(), id)
797    }
798
799    /// HuggingFace-style linear `x · Wᵀ + b` where `w` is stored `[out, in]`,
800    /// as a single fused matmul+bias node (`Op::FusedMatMulBiasAct`). The
801    /// weight transpose is emitted in-graph so callers bind the raw `[out, in]`
802    /// checkpoint tensor directly (no host pre-transpose). Backs the `rlx!`
803    /// `linear(x, w, b)` / `x @t w` sugar.
804    pub fn linear(&self, w: &Tensor, b: &Tensor) -> Self {
805        self.linear_act(w, b, None)
806    }
807
808    /// [`linear`](Self::linear) with a fused elementwise activation applied to
809    /// the result — `act(x · Wᵀ + b)` in one op. Backs `gelu(linear(..))` etc.
810    pub fn linear_act(&self, w: &Tensor, b: &Tensor, act: Option<Activation>) -> Self {
811        let w_id = self.adopt(w);
812        let b_id = self.adopt(b);
813        let id = self.handle.with_graph(|g| {
814            let rank = g.shape(w_id).rank();
815            let wt = g.transpose_(w_id, vec![rank - 1, rank - 2]);
816            let s = rlx_ir::shape::matmul_shape(g.shape(self.id), g.shape(wt))
817                .expect("linear shape inference");
818            g.fused_matmul_bias_act(self.id, wt, b_id, act, s)
819        });
820        Self::new(self.handle.clone(), id)
821    }
822
823    /// Matrix multiply against a transposed right operand: `self · rhsᵀ`
824    /// (`x @t w` in the DSL). Avoids a separate `.t()` at call sites; the
825    /// transpose is emitted in-graph.
826    pub fn matmul_t(&self, rhs: &Tensor) -> Self {
827        self.matmul(&rhs.t())
828    }
829
830    /// Scaled dot-product attention with an explicit `[batch, key_len]` padding
831    /// mask (`1.0` = valid, `<0.5` = ignored) — the 4-input `MaskKind::Custom`
832    /// form that [`attention`](Self::attention) (kernel-synthesized masks) can't
833    /// express. Backs the `rlx!` `attention(q, k, v, mask, heads, head_dim)`
834    /// sugar.
835    pub fn attention_masked(
836        &self,
837        k: &Tensor,
838        v: &Tensor,
839        mask: &Tensor,
840        num_heads: usize,
841        head_dim: usize,
842    ) -> Self {
843        let k_id = self.adopt(k);
844        let v_id = self.adopt(v);
845        let m_id = self.adopt(mask);
846        let id = self.handle.with_graph(|g| {
847            let s = rlx_ir::shape::attention_shape(g.shape(self.id));
848            g.attention(self.id, k_id, v_id, m_id, num_heads, head_dim, s)
849        });
850        Self::new(self.handle.clone(), id)
851    }
852
853    pub fn rope(&self, cos: &Tensor, sin: &Tensor, head_dim: usize) -> Self {
854        let cos_id = self.adopt(cos);
855        let sin_id = self.adopt(sin);
856        let id = self
857            .handle
858            .with_graph(|g| g.rope(self.id, cos_id, sin_id, head_dim));
859        Self::new(self.handle.clone(), id)
860    }
861
862    /// Select `on_true` where `self` (a mask) is true, else `on_false`. Each
863    /// branch is a `&Tensor` or a scalar (`mask.where_(&x, 0.0)`), so the
864    /// `rlx!` DSL's `select(mask, x, 0.0)` lowers here directly. A scalar
865    /// branch is promoted to the other branch's dtype (else `F32`).
866    pub fn where_<'t, 'f>(
867        &self,
868        on_true: impl Into<BinaryRhs<'t>>,
869        on_false: impl Into<BinaryRhs<'f>>,
870    ) -> Self {
871        let t = on_true.into();
872        let f = on_false.into();
873        let hint = if let BinaryRhs::Tensor(x) = &t {
874            x.dtype()
875        } else if let BinaryRhs::Tensor(x) = &f {
876            x.dtype()
877        } else {
878            DType::F32
879        };
880        let t_id = self.where_branch(t, hint);
881        let f_id = self.where_branch(f, hint);
882        let id = self.handle.with_graph(|g| {
883            let s = rlx_ir::shape::binary_shape(g.shape(t_id), g.shape(f_id))
884                .expect("where shape inference");
885            g.add_node(Op::Where, vec![self.id, t_id, f_id], s)
886        });
887        Self::new(self.handle.clone(), id)
888    }
889
890    /// Resolve a `where_` branch to a node id in this graph — adopting a tensor
891    /// or promoting a scalar to `hint`'s dtype.
892    fn where_branch(&self, rhs: BinaryRhs<'_>, hint: DType) -> NodeId {
893        match rhs {
894            BinaryRhs::Tensor(t) => self.adopt(t),
895            BinaryRhs::Scalar(s) => self.handle.with_graph(|g| promote_scalar(g, s, hint)),
896        }
897    }
898
899    /// Replace entries of `self` where `mask` is true with `value` (NumPy
900    /// `masked_fill` / the attention-mask primitive: `scores.masked_fill(causal,
901    /// f32::NEG_INFINITY)` then `softmax`). Requires a static shape.
902    pub fn masked_fill(&self, mask: &Tensor, value: f64) -> Self {
903        let dims: Vec<usize> = self
904            .shape()
905            .dims()
906            .iter()
907            .map(|d| match d {
908                Dim::Static(n) => *n,
909                Dim::Dynamic(_) => panic!("masked_fill requires a static shape"),
910            })
911            .collect();
912        let fill = Tensor::full(dims, value as f32);
913        mask.where_(&fill, self)
914    }
915
916    /// Elementwise maximum (accepts a `&Tensor` or a scalar).
917    pub fn maximum<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
918        self.map_binary_op(rhs.into(), BinaryOp::Max)
919    }
920
921    /// Elementwise minimum (accepts a `&Tensor` or a scalar).
922    pub fn minimum<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
923        self.map_binary_op(rhs.into(), BinaryOp::Min)
924    }
925
926    /// Elementwise `atan2(self, rhs)` — quadrant-aware arctangent.
927    pub fn atan2<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
928        self.map_binary_op(rhs.into(), BinaryOp::Atan2)
929    }
930
931    /// Elementwise `self % rhs` (C `fmod`; accepts a `&Tensor` or a scalar).
932    pub fn rem<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
933        self.map_binary_op(rhs.into(), BinaryOp::Mod)
934    }
935
936    /// Clamp values to `[min, max]`.
937    pub fn clamp(&self, min: f64, max: f64) -> Self {
938        self.clamp_min(min).clamp_max(max)
939    }
940
941    /// Lower-bound values at `min`.
942    pub fn clamp_min(&self, min: f64) -> Self {
943        self.map_binary_op(BinaryRhs::Scalar(min.into()), BinaryOp::Max)
944    }
945
946    /// Upper-bound values at `max`.
947    pub fn clamp_max(&self, max: f64) -> Self {
948        self.map_binary_op(BinaryRhs::Scalar(max.into()), BinaryOp::Min)
949    }
950
951    /// Elementwise power `self ^ rhs` (accepts a `&Tensor` or a scalar).
952    pub fn pow<'r>(&self, rhs: impl Into<BinaryRhs<'r>>) -> Self {
953        self.map_binary_op(rhs.into(), BinaryOp::Pow)
954    }
955
956    pub fn pow_scalar(&self, exp: f64) -> Self {
957        self.map_binary_op(BinaryRhs::Scalar(exp.into()), BinaryOp::Pow)
958    }
959}
960
961unary_ops! {
962    /// Rectified linear unit `max(x, 0)`.
963    relu => Relu,
964    /// Gaussian error linear unit (exact).
965    gelu => Gelu,
966    /// GELU, tanh approximation.
967    gelu_approx => GeluApprox,
968    /// Sigmoid-weighted linear unit `x · σ(x)` (a.k.a. SwiGLU gate).
969    silu => Silu,
970    /// Hyperbolic tangent.
971    tanh => Tanh,
972    /// Natural exponential `e^x`.
973    exp => Exp,
974    /// Square root.
975    sqrt => Sqrt,
976    /// Logistic sigmoid `1 / (1 + e^-x)`.
977    sigmoid => Sigmoid,
978    /// Natural logarithm.
979    log => Log,
980    /// Reciprocal square root `1 / sqrt(x)`.
981    rsqrt => Rsqrt,
982    /// Absolute value.
983    abs => Abs,
984    /// Sine.
985    sin => Sin,
986    /// Cosine.
987    cos => Cos,
988    /// Tangent.
989    tan => Tan,
990    /// Arctangent.
991    atan => Atan,
992    /// Round to nearest (half-to-even); straight-through gradient.
993    round => Round,
994    /// Reciprocal `1 / x`.
995    recip => Recip,
996    /// `floor(x)` (piecewise-constant gradient).
997    floor => Floor,
998    /// `ceil(x)`.
999    ceil => Ceil,
1000    /// `sign(x)` = -1/0/+1.
1001    sign => Sign,
1002    /// Softplus `ln(1 + eˣ)`.
1003    softplus => Softplus,
1004    /// ELU (α=1): `x if x>0 else eˣ-1`.
1005    elu => Elu,
1006    /// Gauss error function `erf(x)`.
1007    erf => Erf,
1008    /// HardSwish `x·clamp(x+3,0,6)/6`.
1009    hard_swish => HardSwish,
1010    /// HardSigmoid `clamp(x/6+0.5,0,1)`.
1011    hard_sigmoid => HardSigmoid,
1012    /// Mish `x·tanh(softplus(x))`.
1013    mish => Mish,
1014    /// Softsign `x/(1+|x|)`.
1015    softsign => Softsign,
1016    /// LogSigmoid `log(σ(x))`.
1017    log_sigmoid => LogSigmoid,
1018}
1019
1020cmp_ops! {
1021    /// Elementwise `==`.
1022    eq => Eq,
1023    /// Elementwise `!=`.
1024    ne => Ne,
1025    /// Elementwise `<`.
1026    lt => Lt,
1027    /// Elementwise `<=`.
1028    le => Le,
1029    /// Elementwise `>`.
1030    gt => Gt,
1031    /// Elementwise `>=`.
1032    ge => Ge,
1033}
1034
1035reduce_ops! {
1036    /// Sum over `axes`.
1037    sum => Sum,
1038    /// Mean over `axes`.
1039    mean => Mean,
1040    /// Maximum over `axes`.
1041    max => Max,
1042    /// Minimum over `axes`.
1043    min => Min,
1044    /// Product over `axes`.
1045    prod => Prod,
1046}
1047
1048/// Generate typed little-endian readback methods (`name => (T, DType, width)`):
1049/// realize, assert the tensor dtype, then decode fixed-width POD elements.
1050/// `f32` (the default) and `bool` (multi-encoding) are hand-rolled separately.
1051#[cfg(feature = "eval")]
1052macro_rules! pod_readers {
1053    ($($(#[$m:meta])* $name:ident => ($ty:ty, $dt:ident, $width:literal)),+ $(,)?) => {
1054        $(
1055            $(#[$m])*
1056            pub fn $name(&self) -> Vec<$ty> {
1057                let (bytes, dt) = self.eval_typed();
1058                assert_eq!(
1059                    dt, DType::$dt,
1060                    concat!(stringify!($name), ": tensor dtype is {:?}"), dt,
1061                );
1062                bytes
1063                    .chunks_exact($width)
1064                    .map(|c| <$ty>::from_le_bytes(c.try_into().unwrap()))
1065                    .collect()
1066            }
1067        )+
1068    };
1069}
1070
1071/// Materialization — compile this tensor's graph through `rlx_runtime` and
1072/// read the result back to host memory. The graph stays lazy until one of
1073/// these is called, so the compiler fuses and memory-plans the whole
1074/// expression first. Available with the `eval` feature.
1075#[cfg(feature = "eval")]
1076impl Tensor {
1077    /// Compile + run and copy the output to a `Vec<f32>`. The device is chosen
1078    /// automatically — the fastest backend compiled into this build that can
1079    /// run the graph (CPU when nothing faster is available).
1080    pub fn to_vec(&self) -> Vec<f32> {
1081        self.to_vec_on(self.auto_device())
1082    }
1083
1084    /// Compile + run on an explicit device.
1085    pub fn to_vec_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
1086        // A raw constant's realized value is its own host data — device-independent,
1087        // so return it directly and skip backend compilation. This is both an
1088        // optimization (no GPU round-trip for a constant) and a robustness fix: the
1089        // MLX backend crashes when asked to compile a constant-only trace.
1090        if let Some((bytes, DType::F32)) = self.constant_payload() {
1091            return bytes
1092                .chunks_exact(4)
1093                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
1094                .collect();
1095        }
1096        let compiled = self.compiled_on(device);
1097        let mut outputs = compiled.borrow_mut().run(&[]);
1098        outputs.pop().expect("eval produced no output")
1099    }
1100
1101    /// If this tensor's output node is a raw `Op::Constant` that physically holds its
1102    /// full contiguous payload, returns `(bytes, dtype)` — its realized value with no
1103    /// computation to run. (Views/slices of a constant are *not* raw constants, so
1104    /// they fall through to normal evaluation.)
1105    fn constant_payload(&self) -> Option<(Vec<u8>, DType)> {
1106        self.handle.with_graph(|g| {
1107            let node = g.node(self.id);
1108            if let Op::Constant { data } = &node.op {
1109                if node.shape.size_bytes() == Some(data.len()) {
1110                    return Some((data.clone(), node.shape.dtype()));
1111                }
1112            }
1113            None
1114        })
1115    }
1116
1117    /// Fastest available backend that can run this tensor's graph (no clone).
1118    fn auto_device(&self) -> rlx_runtime::Device {
1119        self.handle
1120            .with_graph(|g| rlx_runtime::fastest_device_for(g))
1121    }
1122
1123    /// Compiled graph for this tensor's output — borrows the shared graph for
1124    /// the cache fingerprint, cloning only on a miss (zero-copy on hits).
1125    fn compiled_on(
1126        &self,
1127        device: rlx_runtime::Device,
1128    ) -> std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>> {
1129        self.handle
1130            .with_graph(|g| crate::cache::compiled_output(g, self.id, device))
1131    }
1132
1133    /// Force evaluation — alias for [`to_vec`](Self::to_vec). Building a
1134    /// `Tensor` allocates **nothing** and runs **nothing**; it only records IR.
1135    /// `realize` is the single point where the accumulated graph is compiled
1136    /// (fused + memory-planned) and executed. Use it to make the lazy model
1137    /// explicit at call sites.
1138    pub fn realize(&self) -> Vec<f32> {
1139        self.to_vec()
1140    }
1141
1142    /// [`realize`](Self::realize) on an explicit device.
1143    pub fn realize_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
1144        self.to_vec_on(device)
1145    }
1146
1147    /// Realize a scalar (rank-0 or single-element) tensor to one `f32`.
1148    pub fn item(&self) -> f32 {
1149        let v = self.to_vec();
1150        assert_eq!(v.len(), 1, "item(): tensor has {} elements, not 1", v.len());
1151        v[0]
1152    }
1153
1154    pod_readers! {
1155        /// Realize and read back as `f64` (tensor dtype must be `F64`).
1156        to_vec_f64 => (f64, F64, 8),
1157        /// Realize and read back as `i64` (tensor dtype must be `I64`).
1158        to_vec_i64 => (i64, I64, 8),
1159        /// Realize and read back as `i32` (tensor dtype must be `I32`).
1160        to_vec_i32 => (i32, I32, 4),
1161    }
1162
1163    /// Realize and read back a `Bool` tensor. Robust to the backend storing
1164    /// bools 1-byte or f32-encoded (an element is `true` iff any of its bytes
1165    /// is non-zero).
1166    pub fn to_vec_bool(&self) -> Vec<bool> {
1167        let (bytes, dt) = self.eval_typed();
1168        assert_eq!(dt, DType::Bool, "to_vec_bool: tensor dtype is {dt:?}");
1169        let n = self.numel();
1170        assert!(n > 0 && bytes.len() % n == 0, "to_vec_bool: ragged output");
1171        let w = bytes.len() / n;
1172        bytes
1173            .chunks_exact(w)
1174            .map(|c| c.iter().any(|&b| b != 0))
1175            .collect()
1176    }
1177
1178    /// Compile + run, returning the output's raw little-endian bytes + dtype.
1179    /// Device is auto-selected; backs the typed `to_vec_*` readers.
1180    fn eval_typed(&self) -> (Vec<u8>, DType) {
1181        // Raw constant → its bytes are the result (see `to_vec_on`).
1182        if let Some(ct) = self.constant_payload() {
1183            return ct;
1184        }
1185        let compiled = self.compiled_on(self.auto_device());
1186        let mut outputs = compiled.borrow_mut().run_typed(&[]);
1187        outputs.pop().expect("eval produced no output")
1188    }
1189
1190    /// Realize and pretty-print with shape + dtype, NumPy/ndarray-style nesting.
1191    /// `println!("{}", t)` does the same (with the `eval` feature).
1192    pub fn show(&self) -> String {
1193        let dims = self.dims();
1194        let data = self.to_vec();
1195        format!(
1196            "Tensor{dims:?} {:?}\n{}",
1197            self.dtype(),
1198            fmt_nested(&data, &dims)
1199        )
1200    }
1201
1202    /// Select a device fluently: `t.on(Device::Metal).to_vec()`.
1203    pub fn on(&self, device: rlx_runtime::Device) -> Materialize<'_> {
1204        Materialize {
1205            tensor: self,
1206            device,
1207        }
1208    }
1209}
1210
1211/// Fluent device selector returned by [`Tensor::on`].
1212#[cfg(feature = "eval")]
1213pub struct Materialize<'a> {
1214    tensor: &'a Tensor,
1215    device: rlx_runtime::Device,
1216}
1217
1218#[cfg(feature = "eval")]
1219impl Materialize<'_> {
1220    /// Compile + run on the selected device and copy the output to host.
1221    pub fn to_vec(self) -> Vec<f32> {
1222        self.tensor.to_vec_on(self.device)
1223    }
1224}
1225
1226/// Reverse-mode autodiff. Available with the `grad` feature; pair with `eval`
1227/// to read the resulting gradient values.
1228#[cfg(feature = "autodiff")]
1229impl Tensor {
1230    /// Gradients of this tensor (treated as the loss) w.r.t. each `wrt`,
1231    /// returned as symbolic [`Tensor`]s in the same order as `wrt`.
1232    ///
1233    /// The seed `∂self/∂self = 1` is baked in as a constant (ones, matching
1234    /// `self`'s shape — so a non-scalar `self` differentiates `sum(self)`),
1235    /// making each returned gradient a self-contained graph you can
1236    /// `to_vec()` or compose further.
1237    ///
1238    /// ```ignore
1239    /// let loss = (&a * &b).sum([0], false);
1240    /// let g = loss.grad(&[&a, &b]);   // [∂/∂a, ∂/∂b]
1241    /// assert_eq!(g[0].to_vec(), b.to_vec());
1242    /// ```
1243    pub fn grad(&self, wrt: &[&Tensor]) -> Vec<Tensor> {
1244        // Resolve each `wrt` to its node id *within this loss's graph*.
1245        // Memoized adoption guarantees these are the exact nodes the loss was
1246        // built from, so gradient actually flows (not to a stale duplicate).
1247        let wrt_ids: Vec<NodeId> = wrt.iter().map(|t| self.adopt(t)).collect();
1248        let loss_id = self.id;
1249        let bwd = self.handle.with_graph(|g| {
1250            let mut forward = g.clone();
1251            forward.set_outputs(vec![loss_id]);
1252            let mut bwd = rlx_autodiff::grad(&forward, &wrt_ids);
1253            bake_unit_seed(&mut bwd);
1254            bwd
1255        });
1256        let handle = GraphHandle::new(bwd);
1257        let outputs = handle.with_graph(|g| g.outputs.clone());
1258        outputs
1259            .into_iter()
1260            .map(|id| Tensor::new(handle.clone(), id))
1261            .collect()
1262    }
1263}
1264
1265/// Replace `rlx_autodiff`'s `d_output` seed input with a constant of ones, so
1266/// the backward graph runs with no external inputs.
1267#[cfg(feature = "autodiff")]
1268pub(crate) fn bake_unit_seed(bwd: &mut rlx_ir::Graph) {
1269    let seed = bwd.nodes().iter().find_map(|n| match &n.op {
1270        Op::Input { name } if name == "d_output" => Some(n.id),
1271        _ => None,
1272    });
1273    if let Some(id) = seed {
1274        let n = bwd.node(id).shape.num_elements().unwrap_or(1);
1275        let data: Vec<u8> = (0..n).flat_map(|_| 1.0f32.to_le_bytes()).collect();
1276        let node = bwd.node_mut(id);
1277        node.op = Op::Constant { data };
1278        node.inputs = Vec::new();
1279    }
1280}
1281
1282impl From<Tensor> for NodeId {
1283    fn from(v: Tensor) -> Self {
1284        v.id
1285    }
1286}
1287
1288impl From<&Tensor> for NodeId {
1289    fn from(v: &Tensor) -> Self {
1290        v.id
1291    }
1292}
1293
1294pub(crate) enum BinaryRhs<'a> {
1295    Tensor(&'a Tensor),
1296    Scalar(Scalar),
1297}
1298
1299impl<'a> BinaryRhs<'a> {
1300    fn scalar(s: Scalar) -> Self {
1301        Self::Scalar(s)
1302    }
1303}
1304
1305impl<'a> From<&'a Tensor> for BinaryRhs<'a> {
1306    fn from(v: &'a Tensor) -> Self {
1307        Self::Tensor(v)
1308    }
1309}
1310
1311macro_rules! impl_scalar_rhs {
1312    ($($ty:ty),+ $(,)?) => {
1313        $(
1314            impl From<$ty> for BinaryRhs<'_> {
1315                fn from(v: $ty) -> Self {
1316                    Self::Scalar(v.into())
1317                }
1318            }
1319        )+
1320    };
1321}
1322
1323impl_scalar_rhs!(bool, i32, i64, f32, f64);
1324
1325macro_rules! impl_tensor_binop {
1326    ($trait:ident, $method:ident, $graph:ident) => {
1327        impl $trait<&Tensor> for &Tensor {
1328            type Output = Tensor;
1329
1330            fn $method(self, rhs: &Tensor) -> Tensor {
1331                self.map_binary(BinaryRhs::Tensor(rhs), |g, a, b| g.$graph(a, b))
1332            }
1333        }
1334
1335        impl $trait<Tensor> for &Tensor {
1336            type Output = Tensor;
1337
1338            fn $method(self, rhs: Tensor) -> Tensor {
1339                self.$method(&rhs)
1340            }
1341        }
1342
1343        impl $trait<f64> for &Tensor {
1344            type Output = Tensor;
1345
1346            fn $method(self, rhs: f64) -> Tensor {
1347                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1348            }
1349        }
1350
1351        impl $trait<f32> for &Tensor {
1352            type Output = Tensor;
1353
1354            fn $method(self, rhs: f32) -> Tensor {
1355                self.$method(rhs as f64)
1356            }
1357        }
1358
1359        impl $trait<i32> for &Tensor {
1360            type Output = Tensor;
1361
1362            fn $method(self, rhs: i32) -> Tensor {
1363                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1364            }
1365        }
1366
1367        impl $trait<&Tensor> for Tensor {
1368            type Output = Tensor;
1369
1370            fn $method(self, rhs: &Tensor) -> Tensor {
1371                (&self).$method(rhs)
1372            }
1373        }
1374
1375        impl $trait<Tensor> for Tensor {
1376            type Output = Tensor;
1377
1378            fn $method(self, rhs: Tensor) -> Tensor {
1379                (&self).$method(&rhs)
1380            }
1381        }
1382
1383        impl $trait<f64> for Tensor {
1384            type Output = Tensor;
1385
1386            fn $method(self, rhs: f64) -> Tensor {
1387                (&self).$method(rhs)
1388            }
1389        }
1390
1391        impl $trait<f32> for Tensor {
1392            type Output = Tensor;
1393
1394            fn $method(self, rhs: f32) -> Tensor {
1395                (&self).$method(rhs)
1396            }
1397        }
1398
1399        impl $trait<i32> for Tensor {
1400            type Output = Tensor;
1401
1402            fn $method(self, rhs: i32) -> Tensor {
1403                (&self).$method(rhs)
1404            }
1405        }
1406    };
1407}
1408
1409impl_tensor_binop!(Add, add, add);
1410impl_tensor_binop!(Sub, sub, sub);
1411impl_tensor_binop!(Mul, mul, mul);
1412impl_tensor_binop!(Div, div, div);
1413
1414impl Neg for &Tensor {
1415    type Output = Tensor;
1416
1417    fn neg(self) -> Tensor {
1418        self.map_unary(|g, x| g.neg(x))
1419    }
1420}
1421
1422macro_rules! impl_scalar_left {
1423    ($trait:ident, $method:ident, $graph:ident) => {
1424        impl $trait<&Tensor> for f64 {
1425            type Output = Tensor;
1426
1427            fn $method(self, rhs: &Tensor) -> Tensor {
1428                let id = rhs.handle.with_graph(|g| {
1429                    let lhs_id = promote_scalar(g, self.into(), g.shape(rhs.id).dtype());
1430                    g.$graph(lhs_id, rhs.id)
1431                });
1432                Tensor::new(rhs.handle.clone(), id)
1433            }
1434        }
1435
1436        impl $trait<&Tensor> for f32 {
1437            type Output = Tensor;
1438
1439            fn $method(self, rhs: &Tensor) -> Tensor {
1440                (self as f64).$method(rhs)
1441            }
1442        }
1443
1444        impl $trait<&Tensor> for i32 {
1445            type Output = Tensor;
1446
1447            fn $method(self, rhs: &Tensor) -> Tensor {
1448                (self as i64 as f64).$method(rhs)
1449            }
1450        }
1451    };
1452}
1453
1454impl_scalar_left!(Add, add, add);
1455impl_scalar_left!(Sub, sub, sub);
1456impl_scalar_left!(Mul, mul, mul);
1457impl_scalar_left!(Div, div, div);
1458
1459impl Neg for Tensor {
1460    type Output = Tensor;
1461
1462    fn neg(self) -> Tensor {
1463        (&self).neg()
1464    }
1465}