Skip to main content

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}