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