Skip to main content

rlx_ir/ops/
manifold.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//! Riemannian / SPD-manifold layer builders — SPDNet (BiMap / ReEig /
6//! LogEig) and SPD batch-norm.
7//!
8//! These operate on symmetric-positive-definite matrices (covariance
9//! descriptors) and are the geometric-deep-learning counterparts of
10//! Linear / ReLU / flatten / batch-norm. CPU-first (F64); see
11//! `rlx_cpu::spd` for the compute bodies and `Op::BiMap` &co for the
12//! math.
13
14use crate::{Graph, NodeId, Op, Shape, SpdMatFn};
15
16impl Graph {
17    /// BiMap (bilinear mapping) SPDNet layer: `Y = W · X · Wᵀ`.
18    /// `w` is `[m, n]` (semi-orthogonal, constrained by the optimizer);
19    /// `x` is `[n, n]` symmetric SPD. Output `Y` is `[m, m]`.
20    pub fn bimap(&mut self, w: NodeId, x: NodeId) -> NodeId {
21        let ws = self.node(w).shape.clone();
22        let m = ws.dim(0).unwrap_static();
23        let dtype = ws.dtype();
24        self.push(Op::BiMap, vec![w, x], Shape::new(&[m, m], dtype), None)
25    }
26
27    /// ReEig (eigenvalue rectification) SPDNet nonlinearity:
28    /// `Y = U · max(ε, Σ) · Uᵀ`. `x` is `[n, n]` symmetric SPD; output
29    /// `Y` is the same shape. The SPD analogue of ReLU.
30    ///
31    /// The underlying op emits a packed `[Y, λ, U]` buffer (so the
32    /// backward reuses the eigendecomposition); this builder narrows out
33    /// the `[n, n]` `Y` view.
34    pub fn reeig(&mut self, x: NodeId, eps: f32) -> NodeId {
35        self.spectral_layer(Op::ReEig { eps }, x)
36    }
37
38    /// LogEig SPDNet layer: `Y = logm(X) = U · log(Σ) · Uᵀ`. Maps the
39    /// SPD manifold to the tangent space at the identity. `x` is
40    /// `[n, n]`; output `Y` is the same shape (packed-buffer detail as in
41    /// [`Graph::reeig`]).
42    pub fn logeig(&mut self, x: NodeId, eps: f32) -> NodeId {
43        self.spectral_layer(Op::LogEig { eps }, x)
44    }
45
46    /// Shared builder for the packed spectral layers (ReEig / LogEig):
47    /// push the op (output `[2n²+n]` = `Y ∥ λ ∥ U`), then narrow +
48    /// reshape the leading `Y` block to `[n, n]`.
49    fn spectral_layer(&mut self, op: Op, x: NodeId) -> NodeId {
50        let xs = self.node(x).shape.clone();
51        let n = xs.dim(0).unwrap_static();
52        let dtype = xs.dtype();
53        let packed = self.push(op, vec![x], Shape::new(&[2 * n * n + n], dtype), None);
54        let y_flat = self.add_node(
55            Op::Narrow {
56                axis: 0,
57                start: 0,
58                len: n * n,
59            },
60            vec![packed],
61            Shape::new(&[n * n], dtype),
62        );
63        self.add_node(
64            Op::Reshape {
65                new_shape: vec![n as i64, n as i64],
66            },
67            vec![y_flat],
68            Shape::new(&[n, n], dtype),
69        )
70    }
71
72    /// Batch Fréchet / Karcher mean of a stack of SPD matrices under
73    /// the AIRM metric. `x` is `[batch, n, n]`; output is `[n, n]`.
74    /// Non-differentiable (a detached batch statistic).
75    pub fn spd_karcher_mean(&mut self, x: NodeId, iters: u32, tol: f32) -> NodeId {
76        let xs = self.node(x).shape.clone();
77        let n = xs.dim(1).unwrap_static();
78        self.push(
79            Op::SpdKarcherMean { iters, tol },
80            vec![x],
81            Shape::new(&[n, n], xs.dtype()),
82            None,
83        )
84    }
85
86    /// Weighted batch Fréchet / Karcher mean under the AIRM metric — the
87    /// barycentre `argmin_M Σᵢ wᵢ δ²(M, Cᵢ)`. `x` is `[batch, n, n]`,
88    /// `weights` is `[batch]`; output is `[n, n]`. The weighted counterpart of
89    /// [`Graph::spd_karcher_mean`] (the true AIRM barycentre a barycentric-OT
90    /// projection / weighted-MDM / soft-clustering needs). Non-differentiable
91    /// (a detached prototype).
92    pub fn spd_karcher_mean_weighted(
93        &mut self,
94        x: NodeId,
95        weights: NodeId,
96        iters: u32,
97        tol: f32,
98    ) -> NodeId {
99        let xs = self.node(x).shape.clone();
100        let n = xs.dim(1).unwrap_static();
101        self.push(
102            Op::SpdKarcherMeanWeighted { iters, tol },
103            vec![x, weights],
104            Shape::new(&[n, n], xs.dtype()),
105            None,
106        )
107    }
108
109    /// AIRM Riemannian logarithm at an arbitrary base point:
110    /// `Log_P(X) = P^{1/2} logm(P^{-1/2} X P^{-1/2}) P^{1/2}`. `base` and `x`
111    /// are `[n, n]` SPD; output is the `[n, n]` tangent vector at `base`.
112    pub fn spd_log_map(&mut self, base: NodeId, x: NodeId) -> NodeId {
113        let shape = self.node(x).shape.clone();
114        self.push(Op::SpdLogMap, vec![base, x], shape, None)
115    }
116
117    /// AIRM Riemannian exponential at an arbitrary base point:
118    /// `Exp_P(V) = P^{1/2} expm(P^{-1/2} V P^{-1/2}) P^{1/2}`. `base` is
119    /// `[n, n]` SPD, `v` is a `[n, n]` symmetric tangent vector; output is the
120    /// `[n, n]` SPD point. Inverse of [`Graph::spd_log_map`].
121    pub fn spd_exp_map(&mut self, base: NodeId, v: NodeId) -> NodeId {
122        let shape = self.node(v).shape.clone();
123        self.push(Op::SpdExpMap, vec![base, v], shape, None)
124    }
125
126    /// AIRM parallel transport of a tangent vector between base points:
127    /// `Γ_{P→Q}(V) = E V Eᵀ`, `E = (Q P^{-1})^{1/2}`. `from`/`to` are `[n, n]`
128    /// SPD, `v` is a `[n, n]` symmetric tangent vector at `from`; output is the
129    /// transported `[n, n]` tangent vector at `to`.
130    pub fn spd_parallel_transport(&mut self, from: NodeId, to: NodeId, v: NodeId) -> NodeId {
131        let shape = self.node(v).shape.clone();
132        self.push(Op::SpdParallelTransport, vec![from, to, v], shape, None)
133    }
134
135    /// Batched SPD spectral matrix function — applies `kind`
136    /// (logm / expm / sqrtm / invsqrtm) to each matrix of `x` `[batch, n, n]`;
137    /// output matches `x`. The graph-level, any-backend counterpart of
138    /// `rlx_cpu::spd::{logm,expm,sqrtm,invsqrtm}_batch`; see the
139    /// [`Graph::spd_logm_batch`] &co convenience wrappers.
140    pub fn spd_matrix_fn_batch(&mut self, x: NodeId, kind: SpdMatFn) -> NodeId {
141        let shape = self.node(x).shape.clone();
142        self.push(Op::SpdMatrixFnBatch { kind }, vec![x], shape, None)
143    }
144
145    /// Batched matrix logarithm — [`Graph::spd_matrix_fn_batch`] with [`SpdMatFn::Logm`].
146    pub fn spd_logm_batch(&mut self, x: NodeId) -> NodeId {
147        self.spd_matrix_fn_batch(x, SpdMatFn::Logm)
148    }
149    /// Batched matrix exponential — [`Graph::spd_matrix_fn_batch`] with [`SpdMatFn::Expm`].
150    pub fn spd_expm_batch(&mut self, x: NodeId) -> NodeId {
151        self.spd_matrix_fn_batch(x, SpdMatFn::Expm)
152    }
153    /// Batched matrix square root — [`Graph::spd_matrix_fn_batch`] with [`SpdMatFn::Sqrtm`].
154    pub fn spd_sqrtm_batch(&mut self, x: NodeId) -> NodeId {
155        self.spd_matrix_fn_batch(x, SpdMatFn::Sqrtm)
156    }
157    /// Batched inverse matrix square root — [`Graph::spd_matrix_fn_batch`] with [`SpdMatFn::Invsqrtm`].
158    pub fn spd_invsqrtm_batch(&mut self, x: NodeId) -> NodeId {
159        self.spd_matrix_fn_batch(x, SpdMatFn::Invsqrtm)
160    }
161
162    /// Symmetric **eigendecomposition** `A = U diag(λ) Uᵀ`. `x` is `[n, n]`
163    /// (symmetric); returns `(λ [n]` ascending, `U [n, n]` with column `j` =
164    /// eigenvector `j)`. Differentiable (full symmetric-eigendecomposition
165    /// adjoint). Emits [`Op::Eigh`] (packed `[λ ∥ U]`) and narrows the two views.
166    pub fn eigh(&mut self, x: NodeId) -> (NodeId, NodeId) {
167        let xs = self.node(x).shape.clone();
168        let n = xs.dim(0).unwrap_static();
169        let dtype = xs.dtype();
170        let packed = self.push(Op::Eigh, vec![x], Shape::new(&[n * n + n], dtype), None);
171        let lam = self.add_node(
172            Op::Narrow {
173                axis: 0,
174                start: 0,
175                len: n,
176            },
177            vec![packed],
178            Shape::new(&[n], dtype),
179        );
180        let u_flat = self.add_node(
181            Op::Narrow {
182                axis: 0,
183                start: n,
184                len: n * n,
185            },
186            vec![packed],
187            Shape::new(&[n * n], dtype),
188        );
189        let u = self.add_node(
190            Op::Reshape {
191                new_shape: vec![n as i64, n as i64],
192            },
193            vec![u_flat],
194            Shape::new(&[n, n], dtype),
195        );
196        (lam, u)
197    }
198
199    /// Batched symmetric eigendecomposition. `x` is `[batch, n, n]`; returns
200    /// `(λ [batch, n], U [batch, n, n])`. Emits [`Op::EighBatch`].
201    pub fn eigh_batch(&mut self, x: NodeId) -> (NodeId, NodeId) {
202        let xs = self.node(x).shape.clone();
203        let b = xs.dim(0).unwrap_static();
204        let n = xs.dim(1).unwrap_static();
205        let dtype = xs.dtype();
206        let packed = self.push(
207            Op::EighBatch,
208            vec![x],
209            Shape::new(&[b, n * n + n], dtype),
210            None,
211        );
212        let lam = self.add_node(
213            Op::Narrow {
214                axis: 1,
215                start: 0,
216                len: n,
217            },
218            vec![packed],
219            Shape::new(&[b, n], dtype),
220        );
221        let u_flat = self.add_node(
222            Op::Narrow {
223                axis: 1,
224                start: n,
225                len: n * n,
226            },
227            vec![packed],
228            Shape::new(&[b, n * n], dtype),
229        );
230        let u = self.add_node(
231            Op::Reshape {
232                new_shape: vec![b as i64, n as i64, n as i64],
233            },
234            vec![u_flat],
235            Shape::new(&[b, n, n], dtype),
236        );
237        (lam, u)
238    }
239
240    /// SPD batch-norm affine transport (raw op):
241    /// `Y_i = G^{1/2} (M^{-1/2} X_i M^{-1/2}) G^{1/2}`.
242    /// `x` is `[batch, n, n]`, `mean` (detached) and `g` (learnable) are
243    /// `[n, n]`. Output matches `x`. `mean` receives no gradient; grads
244    /// flow to `x` and `g`. See [`Graph::spd_batch_norm`] for the
245    /// training-mode wrapper that computes `mean` from the batch.
246    pub fn spd_batch_norm_transport(
247        &mut self,
248        x: NodeId,
249        mean: NodeId,
250        g: NodeId,
251        eps: f32,
252    ) -> NodeId {
253        let shape = self.node(x).shape.clone();
254        self.push(Op::SpdBatchNorm { eps }, vec![x, mean, g], shape, None)
255    }
256
257    /// Training-mode SPD batch-norm: computes the batch Fréchet mean,
258    /// centers + biases by the learnable SPD `g`, and returns
259    /// `(y, batch_mean)`. The caller updates the running mean out of band
260    /// (e.g. `rlx_cpu::spd::geodesic_interp(running, batch_mean, momentum)`)
261    /// — a non-differentiable buffer, exactly like Euclidean batch-norm.
262    /// For inference, call [`Graph::spd_batch_norm_transport`] directly
263    /// with the stored running mean.
264    ///
265    /// `x`: `[batch, n, n]`, `g`: `[n, n]`. `iters`/`tol` bound the
266    /// Karcher-mean iteration.
267    pub fn spd_batch_norm(
268        &mut self,
269        x: NodeId,
270        g: NodeId,
271        eps: f32,
272        iters: u32,
273        tol: f32,
274    ) -> (NodeId, NodeId) {
275        let mean = self.spd_karcher_mean(x, iters, tol);
276        let y = self.spd_batch_norm_transport(x, mean, g, eps);
277        (y, mean)
278    }
279}