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}