gam_sae/encode.rs
1//! Kantorovich-certified encode atlas (issue #1010).
2//!
3//! Encoding a row `x ∈ ℝᵖ` against a frozen multi-atom dictionary is the joint
4//! coordinate problem
5//!
6//! ```text
7//! min_{t_1,…,t_K} ½‖x − Σ_k z_k B_kᵀΦ_k(t_k)‖² + Σ_k prior_k(t_k).
8//! ```
9//!
10//! [`joint_encode_refine_row`] solves that objective with the shared residual.
11//! The per-atom atlas below is an initializer and a standalone single-atom
12//! projection facility; composing its independent optima is not a multi-atom
13//! encode. With the amplitude `z_k` and decoder block `B_k` held fixed, Newton on
14//! a single-atom field `F(t) = ∇f_k(t)` converges from a start `t₀` into the unique
15//! root in a certified ball whenever the **Newton–Kantorovich** quantity
16//!
17//! ```text
18//! h = β · η · L ≤ ½, β = ‖F'(t₀)⁻¹‖, η = ‖F'(t₀)⁻¹ F(t₀)‖,
19//! ```
20//!
21//! where `L` is a Lipschitz constant of `F'` (the Hessian of `f_k`) on a region
22//! containing the Newton iterates. `h` is CHECKABLE per row in `O(q³)`
23//! (`q = latent_dim`, tiny), so each fast-path encode carries its own
24//! exactness certificate.
25//!
26//! ## The closed-form Hessian-Lipschitz constant `L`
27//!
28//! Write `m(t) = z·BᵀΦ(t) ∈ ℝᵖ` (the reconstruction) and `r(t) = m(t) − x`.
29//! Then `f = ½‖r‖² + prior` and, differentiating three times,
30//!
31//! ```text
32//! ∇³f = 3·sym(J_mᵀ : ∇²m) + ⟨r, ∇³m⟩ + ∇³prior,
33//! ```
34//!
35//! so an operator-norm bound on the chart is
36//!
37//! ```text
38//! L ≤ 3·‖J_m‖·‖∇²m‖ + ‖r‖·‖∇³m‖ + L_prior,
39//! ```
40//!
41//! with `‖∂^g m‖ ≤ |z|·(Σ_m ‖B_{m,:}‖)·B_g`, where `B_g = sup_chart max_m
42//! ‖∂^g Φ_m‖` is the per-column jet sup of the basis family — closed form per
43//! family ([`BasisHessianLipschitz`]). `‖r‖` is bounded by `‖x‖ +
44//! |z|·(Σ_m‖B_{m,:}‖)·B_0`. The ARD/von-Mises prior `L_prior` is a closed-form
45//! constant from the prior strength. Every bound is conservative (an
46//! over-estimate of `L` only SHRINKS the certified radius — it can never
47//! certify a row that does not converge).
48//!
49//! ## Pipeline
50//!
51//! 1. **Offline, per atom** ([`EncodeAtlas::build`]): chart centers `t_c` on the
52//! atom's coordinate grid (the SHAPE_BAND grid idiom), each with a certified
53//! Newton radius `R_c` solved from the Kantorovich inequality at the
54//! worst-case in-chart start.
55//! 2. **Online, per row** ([`EncodeAtlas::certified_encode_row`]): route to the
56//! nearest chart, start from its distilled IFT predictor, take one or two
57//! Newton steps, then the `h ≤ ½` check AT the start point is the per-row
58//! certificate.
59//! 3. **Uncertified tail**: rows whose start fails `h ≤ ½` are FLAGGED (counted
60//! in [`EncodeResult::encode_uncertified_count`]) and must be routed by the
61//! caller to the existing exact multi-start solve. No approximation enters
62//! silently.
63
64use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
65use opt::constants::{ARMIJO_C1, BACKTRACK_CONTRACTION};
66use opt::{AcceptedStep, BacktrackConfig, backtracking_line_search};
67
68use crate::candidate_index::{AtomFrameSketch, SaeCandidateIndex, auto_candidate_budget};
69use crate::manifold::{
70 AffineCoordinateEvaluator, CylinderHarmonicEvaluator, DuchonCoordinateEvaluator,
71 EuclideanPatchEvaluator, PeriodicHarmonicEvaluator, SaeBasisEvaluator, SaeManifoldAtom,
72 SphereChartEvaluator, TorusHarmonicEvaluator,
73};
74use gam_linalg::faer_ndarray::FaerEigh;
75
76use faer::Side;
77
78/// The Kantorovich convergence threshold `h ≤ ½`. Below this the Newton
79/// iteration is guaranteed to converge quadratically into the unique root in
80/// the certified ball; at or above it the start is uncertified.
81pub const KANTOROVICH_THRESHOLD: f64 = 0.5;
82
83/// Row count at or above which the corpus-rate certified-encode batch
84/// (`certified_encode_batch` / `certified_encode_with_index`) fans its
85/// per-row encodes out over rayon. Below this the per-row Newton + chart
86/// routing is cheap enough that the fan-out overhead does not pay; matched to
87/// the same order as the arrow-Schur `SCHUR_MATVEC_PARALLEL_ROW_MIN` gate so
88/// short batches inside an outer atom-level fan-out stay sequential.
89pub(crate) const ENCODE_BATCH_PARALLEL_ROW_MIN: usize = 256;
90
91/// Minimum frame alignment `‖Uₖᵀd‖/‖d‖ ∈ [0,1]` the routed atom must have for an
92/// index-routed encode to be attempted at all — a FIT-QUALITY floor, NOT a
93/// routing-correctness gate (#1026, corrected by the #1777 exact-routing path).
94///
95/// Routing itself is now EXACT: the index-routed encode picks the atom via
96/// [`SaeCandidateIndex::route_exact`], which returns the GLOBAL argmax of the
97/// routing score (the universal-bound LSH fast path, else a full-scan fallback) —
98/// so there is no "missed-better-ungathered-atom" hole left for this constant to
99/// patch. What remains is a different, honest question: even the globally-best
100/// atom may align only weakly with a row (no atom in the dictionary fits it). A
101/// finite alignment below this floor means the best available atom is a poor fit,
102/// so the row is flagged and routed to the exact multi-start fallback rather than
103/// encoded against an atom it barely belongs to. (Previously this same comparison
104/// double-served as a recall proxy; that role is gone — `route_exact` guarantees
105/// recall — leaving only the fit-quality role described here.)
106pub(crate) const CANDIDATE_ROUTING_MIN_ALIGNMENT: f64 = 0.5;
107
108/// Number of nearest charts the CERTIFIED encode refines in before returning the
109/// lowest-reconstruction-error certified result. A single nearest chart is not
110/// globally sound where the decoded manifold folds near itself (both competing
111/// basins' charts reconstruct near the fold, so both rank among the nearest by
112/// ambient distance); refining the top few captures the global basin. For a
113/// unimodal atom all candidates converge to the same root, so K>1 is a no-op.
114pub(crate) const CERTIFIED_ROUTING_TOPK: usize = 4;
115
116/// Newton refinement convergence floor. Once a refinement step's length `‖δ‖`
117/// falls below this (relative to the coordinate scale `1 + ‖t‖`), the iterate has
118/// reached the certified root to f64 resolution: applying the step cannot move `t`
119/// meaningfully, and the remaining fixed-budget steps only re-accumulate round-off.
120/// Stopping there is STRICTLY more accurate than draining a fixed step budget on a
121/// well-conditioned quadratic Newton tail, and it removes that tail's per-step
122/// `evaluate` + `second_jet` cost (the dominant per-row encode work). The batched
123/// and per-row encodes share this rule, so they stay bit-identical.
124pub(crate) const NEWTON_REFINE_CONVERGED_EPS: f64 = 1.0e-12;
125
126/// Global-minimum short-circuit floor for top-K certified routing. The
127/// reconstruction error `‖x − z·m(t)‖` is bounded below by 0, so a certified
128/// candidate whose residual already sits at the ambient noise floor
129/// (`≤ this · (1 + ‖x‖)`) is provably the global optimum over the charts — no
130/// competing chart can reach a strictly lower residual. The remaining candidates'
131/// refinement is then skipped. Conservative (a genuine second basin of the same
132/// target reconstructs the SAME point, so returning the first is a valid encode).
133pub(crate) const CERTIFIED_GLOBAL_MIN_RECON_FLOOR: f64 = 1.0e-11;
134
135/// A chart region on an atom's latent coordinate: a center `t_c` plus a
136/// certified in-chart radius. Over the ball `‖t − t_c‖ ≤ radius` the jet sup
137/// bounds returned by [`BasisHessianLipschitz`] hold, so the Kantorovich
138/// constant `L` computed from them is valid for any start in the ball.
139///
140/// For radial (Duchon) families the chart also carries the minimum kernel-center
141/// distance `exclusion_r_min` (a lower bound on `‖t − c_k‖` over the chart) that
142/// bounds the otherwise-singular `1/r` radial tails (issue #1010).
143#[derive(Debug, Clone)]
144pub struct ChartRegion {
145 /// Chart center coordinate `t_c` (length = latent_dim).
146 pub center: Array1<f64>,
147 /// In-chart radius in the coordinate metric.
148 pub radius: f64,
149 /// For radial (Duchon) families: a lower bound on `‖t − c_k‖` over the
150 /// chart, across every kernel center `c_k`. `None` for non-radial families.
151 pub exclusion_r_min: Option<f64>,
152 /// For radial (Duchon) families: an upper bound on `‖t − c_k‖` over the
153 /// chart, across every kernel center `c_k`. `None` for non-radial families.
154 pub radial_r_max: Option<f64>,
155}
156
157impl ChartRegion {
158 pub fn new(center: Array1<f64>, radius: f64) -> Self {
159 Self {
160 center,
161 radius,
162 exclusion_r_min: None,
163 radial_r_max: None,
164 }
165 }
166
167 pub fn with_radial_bounds(mut self, r_min: f64, r_max: f64) -> Self {
168 self.exclusion_r_min = Some(r_min);
169 self.radial_r_max = Some(r_max);
170 self
171 }
172
173 /// A jet-sup certificate is only meaningful over a genuine region. Even
174 /// families whose bounds are manifold-global constants (the sup over any
175 /// chart equals the global sup) must refuse a malformed chart rather than
176 /// certify garbage geometry.
177 pub(crate) fn assert_valid(&self) {
178 assert!(
179 self.radius.is_finite()
180 && self.radius >= 0.0
181 && self.center.iter().all(|c| c.is_finite()),
182 "ChartRegion must have a finite center and a finite non-negative radius"
183 );
184 }
185}
186
187/// Per-column sup-norm bounds on the first three coordinate jets of a basis
188/// family `Φ(t)`, valid over a stated [`ChartRegion`] (issue #1010). These are
189/// the analytic ingredients of the Hessian-Lipschitz constant `L` — see the
190/// module docs for the assembly. `value_sup` bounds `max_m |Φ_m|`,
191/// `jacobian_sup`/`hessian_sup`/`third_sup` bound `max_m ‖∂^g Φ_m‖`.
192pub trait BasisHessianLipschitz {
193 fn value_sup(&self, chart: &ChartRegion) -> f64;
194 fn jacobian_sup(&self, chart: &ChartRegion) -> f64;
195 fn hessian_sup(&self, chart: &ChartRegion) -> f64;
196 fn third_sup(&self, chart: &ChartRegion) -> f64;
197}
198
199/// Sup over the circle of the `g`-th derivative of any single harmonic column
200/// of a `num_basis`-wide Fourier basis `[1, sin(2π h t), cos(2π h t), …]`:
201/// `(2π·H)^g` for the top harmonic `H = (num_basis − 1)/2`. The constant column
202/// contributes `0` for `g ≥ 1`, so the top harmonic dominates; the bound is
203/// global (the trig magnitudes are `≤ 1` everywhere, independent of the chart).
204pub(crate) fn harmonic_jet_sup(num_basis: usize, order: u32) -> f64 {
205 let top_harmonic = num_basis.saturating_sub(1) / 2;
206 let omega = std::f64::consts::TAU * top_harmonic as f64;
207 omega.powi(order as i32)
208}
209
210impl BasisHessianLipschitz for PeriodicHarmonicEvaluator {
211 fn value_sup(&self, chart: &ChartRegion) -> f64 {
212 chart.assert_valid();
213 1.0
214 }
215 fn jacobian_sup(&self, chart: &ChartRegion) -> f64 {
216 chart.assert_valid();
217 harmonic_jet_sup(self.num_basis, 1)
218 }
219 fn hessian_sup(&self, chart: &ChartRegion) -> f64 {
220 chart.assert_valid();
221 harmonic_jet_sup(self.num_basis, 2)
222 }
223 fn third_sup(&self, chart: &ChartRegion) -> f64 {
224 chart.assert_valid();
225 harmonic_jet_sup(self.num_basis, 3)
226 }
227}
228
229impl BasisHessianLipschitz for TorusHarmonicEvaluator {
230 /// Tensor product of per-axis circle harmonics. A torus basis column is a
231 /// product of single-axis harmonics, each bounded as in the circle case.
232 /// The `g`-th coordinate jet routes `g` derivative operators across the
233 /// `latent_dim` factors (Leibniz); each routing contributes a product of
234 /// per-axis derivative magnitudes. A per-column sup is therefore bounded by
235 /// the top single-axis frequency to the `g`-th power times the number of
236 /// such routings (`latent_dim^g`, the count of operator-to-axis maps).
237 fn value_sup(&self, chart: &ChartRegion) -> f64 {
238 chart.assert_valid();
239 1.0
240 }
241 fn jacobian_sup(&self, chart: &ChartRegion) -> f64 {
242 chart.assert_valid();
243 torus_jet_sup(self.num_harmonics(), self.latent_dim(), 1)
244 }
245 fn hessian_sup(&self, chart: &ChartRegion) -> f64 {
246 chart.assert_valid();
247 torus_jet_sup(self.num_harmonics(), self.latent_dim(), 2)
248 }
249 fn third_sup(&self, chart: &ChartRegion) -> f64 {
250 chart.assert_valid();
251 torus_jet_sup(self.num_harmonics(), self.latent_dim(), 3)
252 }
253}
254
255/// Per-column `g`-th jet sup for the torus harmonic basis: `(2π·H)^g ·
256/// latent_dim^g`, where `H = num_harmonics` is the top per-axis frequency and
257/// `latent_dim^g` over-counts the Leibniz routings of `g` operators across the
258/// product factors (a conservative bound — each routing's per-axis magnitude is
259/// `≤ (2π H)^{#ops on that axis}`, and the products telescope to `(2π H)^g`).
260pub(crate) fn torus_jet_sup(num_harmonics: usize, latent_dim: usize, order: u32) -> f64 {
261 let omega = std::f64::consts::TAU * num_harmonics as f64;
262 omega.powi(order as i32) * (latent_dim as f64).powi(order as i32)
263}
264
265impl BasisHessianLipschitz for SphereChartEvaluator {
266 /// The 7-column lat/lon chart `[1, x, y, z, xy, yz, xz]` with
267 /// `x = cos(lat)cos(lon)`, `y = cos(lat)sin(lon)`, `z = sin(lat)`. Each of
268 /// `x, y, z` is a product of two unit-frequency trig factors, so its `g`-th
269 /// coordinate jet is a sum of `2^g` products of `{sin,cos}` (each `≤ 1`):
270 /// magnitude `≤ 2^g` for `g ≥ 1`, `≤ 1` for `g = 0`. The bilinear columns
271 /// `xy, yz, xz` are products of two such coordinates; by Leibniz over the
272 /// product, their `g`-th jet is bounded by `Σ_{i=0}^{g} C(g,i)·(2^i)·(2^{g−i})
273 /// = (2+2)^g = 4^g` (using `‖∂^i u‖ ≤ 2^i`, `|u| ≤ 1`). The bilinear columns
274 /// dominate, so the per-column sup is `4^g` (`g ≥ 1`). Bounds are global
275 /// constants — the chart box `lat ∈ [-π/2, π/2]` does not enlarge them.
276 fn value_sup(&self, chart: &ChartRegion) -> f64 {
277 chart.assert_valid();
278 1.0
279 }
280 fn jacobian_sup(&self, chart: &ChartRegion) -> f64 {
281 chart.assert_valid();
282 4.0
283 }
284 fn hessian_sup(&self, chart: &ChartRegion) -> f64 {
285 chart.assert_valid();
286 16.0
287 }
288 fn third_sup(&self, chart: &ChartRegion) -> f64 {
289 chart.assert_valid();
290 64.0
291 }
292}
293
294impl BasisHessianLipschitz for AffineCoordinateEvaluator {
295 /// The affine basis `[1, t₁, …, t_d]` is degree ≤ 1: its first jet has unit
296 /// columns, and all second and third jets vanish. The value sup is
297 /// `max(1, ‖t‖)` over the chart, bounded by `1 + ‖t_c‖ + radius`.
298 fn value_sup(&self, chart: &ChartRegion) -> f64 {
299 let center_norm = chart.center.dot(&chart.center).sqrt();
300 1.0 + center_norm + chart.radius
301 }
302 fn jacobian_sup(&self, chart: &ChartRegion) -> f64 {
303 chart.assert_valid();
304 1.0
305 }
306 fn hessian_sup(&self, chart: &ChartRegion) -> f64 {
307 chart.assert_valid();
308 0.0
309 }
310 fn third_sup(&self, chart: &ChartRegion) -> f64 {
311 chart.assert_valid();
312 0.0
313 }
314}
315
316impl BasisHessianLipschitz for EuclideanPatchEvaluator {
317 /// Monomials of total degree ≤ `max_degree` in `t ∈ ℝ^d`. Over the ball of
318 /// radius `R` about `t_c`, each coordinate is bounded by `ρ = ‖t_c‖∞ + R`.
319 /// A monomial `t^α` with `|α| = q` has `g`-th partials bounded (crudely) by
320 /// the descending-factorial coefficient `q·(q−1)···(q−g+1) ≤ q^g` times
321 /// `ρ^{max(q−g,0)}`, and there are at most `d^g` partial routings, so the
322 /// per-column `g`-th jet sup is `≤ d^g · D^g · ρ^{max(D−g,0)}` with
323 /// `D = max_degree`. Conservative; D is small for patch evaluators.
324 fn value_sup(&self, chart: &ChartRegion) -> f64 {
325 let rho = patch_rho(chart);
326 let d = self.max_degree as i32;
327 rho.powi(d).max(1.0)
328 }
329 fn jacobian_sup(&self, chart: &ChartRegion) -> f64 {
330 patch_jet_sup(self.latent_dim, self.max_degree, chart, 1)
331 }
332 fn hessian_sup(&self, chart: &ChartRegion) -> f64 {
333 patch_jet_sup(self.latent_dim, self.max_degree, chart, 2)
334 }
335 fn third_sup(&self, chart: &ChartRegion) -> f64 {
336 patch_jet_sup(self.latent_dim, self.max_degree, chart, 3)
337 }
338}
339
340impl BasisHessianLipschitz for CylinderHarmonicEvaluator {
341 /// Cylinder `S¹ × ℝ` product basis `Φ_{c,l} = c(t₀)·l(t₁)`, the circle
342 /// (periodic harmonic) factor on axis 0 crossed with the monomial line
343 /// factor on axis 1. Because the two factors depend on disjoint coordinates,
344 /// the order-`g` coordinate jet in any cell is exactly
345 /// `c^{(k₀)}(t₀)·l^{(k₁)}(t₁)` with `k₀ + k₁ = g`, so the per-column sup is
346 /// the max over the split `k₀ + k₁ = g` of the product of the two per-axis
347 /// per-order sups: the circle factor contributes `1` at order 0 and
348 /// `(2π·H)^{k₀}` at order `k₀ ≥ 1` (trig magnitudes `≤ 1`); the line factor
349 /// contributes the monomial-patch sup `D^{k₁}·ρ^{max(D−k₁,0)}` (`D = line
350 /// degree`, `ρ = ‖t_c‖∞ + radius`). Bounds are global in the periodic axis
351 /// and chart-local in the line axis.
352 fn value_sup(&self, chart: &ChartRegion) -> f64 {
353 cylinder_jet_sup(self.circle_harmonics, self.line_degree, chart, 0)
354 }
355 fn jacobian_sup(&self, chart: &ChartRegion) -> f64 {
356 cylinder_jet_sup(self.circle_harmonics, self.line_degree, chart, 1)
357 }
358 fn hessian_sup(&self, chart: &ChartRegion) -> f64 {
359 cylinder_jet_sup(self.circle_harmonics, self.line_degree, chart, 2)
360 }
361 fn third_sup(&self, chart: &ChartRegion) -> f64 {
362 cylinder_jet_sup(self.circle_harmonics, self.line_degree, chart, 3)
363 }
364}
365
366/// Per-column order-`g` jet sup of the cylinder product basis: the max over
367/// `k₀ + k₁ = g` of `circle_axis_sup(k₀) · line_axis_sup(k₁)`, where the circle
368/// axis sup is `(2π·H)^{k₀}` (`1` at `k₀ = 0`) and the line axis sup is the
369/// monomial-patch bound `D^{k₁}·ρ^{max(D−k₁,0)}` (`1` at `k₁ = 0`). See the
370/// [`CylinderHarmonicEvaluator`] doc comment for the derivation.
371pub(crate) fn cylinder_jet_sup(
372 circle_harmonics: usize,
373 line_degree: usize,
374 chart: &ChartRegion,
375 order: u32,
376) -> f64 {
377 let omega = std::f64::consts::TAU * circle_harmonics as f64;
378 let big_d = line_degree as f64;
379 let rho = patch_rho(chart);
380 let mut best = 0.0_f64;
381 for k0 in 0..=order {
382 let k1 = order - k0;
383 let circle = if k0 == 0 { 1.0 } else { omega.powi(k0 as i32) };
384 let line = if k1 == 0 {
385 rho.powi(line_degree as i32).max(1.0)
386 } else {
387 let residual = line_degree.saturating_sub(k1 as usize) as i32;
388 // `.max(1.0)` as in `patch_jet_sup`: for ρ < 1 a lower-degree line
389 // monomial dominates the k1-th derivative, so the bare `ρ^residual`
390 // underestimates the line-factor sup. The value case (k1==0) already
391 // clamps; this completes it for the derivative orders.
392 big_d.powi(k1 as i32) * rho.powi(residual).max(1.0)
393 };
394 best = best.max(circle * line);
395 }
396 best
397}
398
399/// Sup-norm radius `ρ = ‖t_c‖∞ + radius` of the chart (the coordinate magnitude
400/// bound used by the monomial-patch jet bounds).
401pub(crate) fn patch_rho(chart: &ChartRegion) -> f64 {
402 let center_inf = chart
403 .center
404 .iter()
405 .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
406 center_inf + chart.radius
407}
408
409/// Per-column `g`-th jet sup for a monomial patch of max degree `D` in `d`
410/// coordinates over the chart: `d^g · D^g · ρ^{max(D−g,0)}` (see the
411/// [`EuclideanPatchEvaluator`] doc comment for the derivation).
412pub(crate) fn patch_jet_sup(
413 latent_dim: usize,
414 max_degree: usize,
415 chart: &ChartRegion,
416 order: u32,
417) -> f64 {
418 let d = latent_dim as f64;
419 let big_d = max_degree as f64;
420 let rho = patch_rho(chart);
421 let residual_degree = max_degree.saturating_sub(order as usize) as i32;
422 // `.max(1.0)`: for ρ < 1 (small charts near the origin) the g-th jet sup is NOT
423 // dominated by the max-degree monomial `t^D` (whose g-th derivative ~ ρ^{D-g}
424 // shrinks with ρ) but by a LOWER-degree monomial whose g-th derivative is a
425 // larger constant — e.g. {1,t,t²}'s jacobian sup is the linear term's constant
426 // `1`, which exceeds `2ρ` when ρ < ½. Without the clamp the bound underestimates
427 // the true sup (numerically: D=3, ρ=0.1, g=1 → formula 0.03 vs true 1.0), which
428 // would make the certificate's Lipschitz `L` too small → a FALSE certificate.
429 // `D^g · max(ρ^{D-g}, 1)` upper-bounds `max_{q∈[g,D]} (q!/(q-g)!)·ρ^{q-g}` for
430 // all ρ (the `q=g` term gives `g! ≤ D^g`, the `q=D` term gives `≤ D^g·ρ^{D-g}`).
431 d.powi(order as i32) * big_d.powi(order as i32) * rho.powi(residual_degree).max(1.0)
432}
433
434impl BasisHessianLipschitz for DuchonCoordinateEvaluator {
435 /// Radial-kernel basis `Φ_m(t) = φ(r_m)`, `r_m = ‖t − c_m‖`, plus a
436 /// polynomial nullspace block. For the cubic Duchon kernel `φ(r) = r³` the
437 /// radial derivatives are `φ' = 3r²`, `φ'' = 6r`, `φ''' = 6`. The chain rule
438 /// to coordinate jets introduces `1/r` factors through the unit radial
439 /// direction `u = (t − c)/r` and the projector `(I − uuᵀ)/r`, so over a
440 /// chart the jets are bounded by combining the radial-derivative magnitudes
441 /// at the worst-case radius with the inverse-radius tail at the chart's
442 /// EXCLUSION radius `r_min` (the closest a chart point gets to any center):
443 ///
444 /// ```text
445 /// ‖∇φ‖ ≤ |φ'| ≤ 3 r_max²
446 /// ‖∇²φ‖ ≤ |φ''| + |φ'|/r ≤ 6 r_max + 3 r_max²/r_min
447 /// ‖∇³φ‖ ≤ |φ'''| + 3|φ''|/r + 3|φ'|/r² ≤ 6 + 18 r_max/r_min + 9 r_max²/r_min²
448 /// ```
449 ///
450 /// (the `1/r`, `1/r²` tails are bounded by `1/r_min`, `1/r_min²`). The
451 /// polynomial nullspace block is degree ≤ `order`; its jets are bounded like
452 /// the monomial patch with `D = order`. The per-column sup is the max of the
453 /// kernel and polynomial bounds. The `r³` kernel is itself `C²` (no
454 /// singularity) so these tails are conservative but finite for any
455 /// `r_min > 0`; the atlas refines charts to keep `r_min` bounded away from 0.
456 fn value_sup(&self, chart: &ChartRegion) -> f64 {
457 let r_max = chart.radial_r_max.unwrap_or(chart.radius);
458 let poly = duchon_poly_jet_sup(self.centers.ncols(), self.order_degree(), chart, 0);
459 (r_max.powi(3)).max(poly)
460 }
461 fn jacobian_sup(&self, chart: &ChartRegion) -> f64 {
462 let r_max = chart.radial_r_max.unwrap_or(chart.radius);
463 let kernel = 3.0 * r_max * r_max;
464 let poly = duchon_poly_jet_sup(self.centers.ncols(), self.order_degree(), chart, 1);
465 kernel.max(poly)
466 }
467 fn hessian_sup(&self, chart: &ChartRegion) -> f64 {
468 let r_max = chart.radial_r_max.unwrap_or(chart.radius);
469 let r_min = chart
470 .exclusion_r_min
471 .unwrap_or(chart.radius)
472 .max(f64::MIN_POSITIVE);
473 let kernel = 6.0 * r_max + 3.0 * r_max * r_max / r_min;
474 let poly = duchon_poly_jet_sup(self.centers.ncols(), self.order_degree(), chart, 2);
475 kernel.max(poly)
476 }
477 fn third_sup(&self, chart: &ChartRegion) -> f64 {
478 let r_max = chart.radial_r_max.unwrap_or(chart.radius);
479 let r_min = chart
480 .exclusion_r_min
481 .unwrap_or(chart.radius)
482 .max(f64::MIN_POSITIVE);
483 let kernel = 6.0 + 18.0 * r_max / r_min + 9.0 * r_max * r_max / (r_min * r_min);
484 let poly = duchon_poly_jet_sup(self.centers.ncols(), self.order_degree(), chart, 3);
485 kernel.max(poly)
486 }
487}
488
489/// Polynomial-block degree of a Duchon nullspace order, used to bound the
490/// nullspace columns like a monomial patch.
491trait DuchonOrderDegree {
492 fn order_degree(&self) -> usize;
493}
494
495impl DuchonOrderDegree for DuchonCoordinateEvaluator {
496 fn order_degree(&self) -> usize {
497 match self.order {
498 gam_terms::basis::DuchonNullspaceOrder::Zero => 0,
499 gam_terms::basis::DuchonNullspaceOrder::Linear => 1,
500 gam_terms::basis::DuchonNullspaceOrder::Degree(d) => d,
501 }
502 }
503}
504
505/// Per-column `g`-th jet sup of the Duchon polynomial nullspace block, treated
506/// as a monomial patch of degree `order_degree`.
507pub(crate) fn duchon_poly_jet_sup(
508 latent_dim: usize,
509 order_degree: usize,
510 chart: &ChartRegion,
511 order: u32,
512) -> f64 {
513 if order_degree == 0 {
514 return if order == 0 { 1.0 } else { 0.0 };
515 }
516 patch_jet_sup(latent_dim, order_degree, chart, order)
517}
518
519/// Decoder magnitude `Σ_m ‖B_{m,:}‖₂` of an atom's frozen decoder block: the
520/// factor that converts a per-column `Φ`-jet sup `B_g` into a reconstruction
521/// jet sup `‖∂^g m‖ ≤ |z|·decoder_row_norm_sum·B_g`.
522pub(crate) fn decoder_row_norm_sum(decoder: ArrayView2<'_, f64>) -> f64 {
523 let mut acc = 0.0;
524 for row in decoder.rows() {
525 acc += row.dot(&row).sqrt();
526 }
527 acc
528}
529
530#[derive(Debug, Clone, Copy)]
531pub(crate) struct ReconstructionJetSups {
532 pub(crate) value: f64,
533 pub(crate) jacobian: f64,
534 pub(crate) hessian: f64,
535 pub(crate) third: f64,
536}
537
538pub(crate) fn pair_trig_decoder_sup(
539 sin_row: ArrayView1<'_, f64>,
540 cos_row: ArrayView1<'_, f64>,
541) -> f64 {
542 let aa = sin_row.dot(&sin_row);
543 let bb = cos_row.dot(&cos_row);
544 let ab = sin_row.dot(&cos_row);
545 let trace = aa + bb;
546 let disc = ((aa - bb) * (aa - bb) + 4.0 * ab * ab).sqrt();
547 (0.5 * (trace + disc)).sqrt()
548}
549
550/// Per-harmonic reconstruction jet sups of a periodic atom. `decoder` MUST be
551/// the FULL-width decoder on the standard `[1, sin 2πt, cos 2πt, …]` inner
552/// basis: the row pairing below identifies rows `(2h−1, 2h)` as the harmonic-`h`
553/// `(sin, cos)` pair and prices them at `ω = 2πh`. A #1117 rank-reduced decoder
554/// `B̃ = Qᵀ B` has NO such row meaning (each reduced row mixes all harmonics),
555/// so callers re-expand through `Q` first — see [`reconstruction_jet_sups`].
556pub(crate) fn periodic_reconstruction_jet_sups(
557 decoder: ArrayView2<'_, f64>,
558) -> ReconstructionJetSups {
559 let mut value = 0.0;
560 let mut jacobian = 0.0;
561 let mut hessian = 0.0;
562 let mut third = 0.0;
563 if decoder.nrows() > 0 {
564 value += decoder.row(0).dot(&decoder.row(0)).sqrt();
565 }
566 let harmonics = decoder.nrows().saturating_sub(1) / 2;
567 for h in 1..=harmonics {
568 let sin_idx = 2 * h - 1;
569 let cos_idx = 2 * h;
570 let amp = pair_trig_decoder_sup(decoder.row(sin_idx), decoder.row(cos_idx));
571 let omega = std::f64::consts::TAU * h as f64;
572 value += amp;
573 jacobian += omega * amp;
574 hessian += omega.powi(2) * amp;
575 third += omega.powi(3) * amp;
576 }
577 for row in (1 + 2 * harmonics)..decoder.nrows() {
578 let amp = decoder.row(row).dot(&decoder.row(row)).sqrt();
579 value += amp;
580 let omega = std::f64::consts::TAU * harmonics.max(1) as f64;
581 jacobian += omega * amp;
582 hessian += omega.powi(2) * amp;
583 third += omega.powi(3) * amp;
584 }
585 ReconstructionJetSups {
586 value,
587 jacobian,
588 hessian,
589 third,
590 }
591}
592
593pub(crate) fn reconstruction_jet_sups(
594 atom: &SaeManifoldAtom,
595 sups: JetSups,
596) -> ReconstructionJetSups {
597 // `sups` bounds the FULL-width family (see `family_jet_sups`), so the
598 // decoder it pairs with must be the full-width pre-image `B = Q B̃` when the
599 // atom was #1117 rank-reduced. The reconstruction is identical through that
600 // frame (`Φ̃ B̃ = Φ (Q B̃)`), so the bound is exact-in-structure; pairing the
601 // full sups with the reduced `B̃` instead would price periodic rows at the
602 // wrong harmonic and mismatch the family the sups were taken over.
603 let full_decoder = atom
604 .reduced_column_map
605 .is_some()
606 .then(|| atom.full_width_decoder());
607 let decoder = full_decoder
608 .as_ref()
609 .map_or_else(|| atom.decoder_coefficients.view(), |b| b.view());
610 if matches!(
611 atom.basis_kind(),
612 crate::manifold::SaeAtomBasisKind::Periodic
613 ) {
614 periodic_reconstruction_jet_sups(decoder)
615 } else {
616 let decoder_norm_sum = decoder_row_norm_sum(decoder);
617 ReconstructionJetSups {
618 value: decoder_norm_sum * sups.value,
619 jacobian: decoder_norm_sum * sups.jacobian,
620 hessian: decoder_norm_sum * sups.hessian,
621 third: decoder_norm_sum * sups.third,
622 }
623 }
624}
625
626/// The Hessian-Lipschitz constant `L` of the per-row encode objective `f_k` on
627/// a chart, assembled in closed form from the basis jet sups and the decoder /
628/// amplitude / target magnitudes. See the module docs for the derivation:
629///
630/// ```text
631/// L ≤ 3·‖J_m‖·‖∇²m‖ + ‖r‖·‖∇³m‖ + L_prior,
632/// ‖∂^g m‖ ≤ |z|·S_B·B_g, S_B = Σ_m ‖B_{m,:}‖,
633/// ‖r‖ ≤ ‖x‖ + |z|·S_B·B_0,
634/// ```
635///
636/// `prior_lipschitz` is the caller-supplied closed-form `L_prior` of the
637/// ARD/von-Mises coordinate prior (`0.0` if no prior is active on the encode).
638pub(crate) fn hessian_lipschitz_constant(
639 recon_sups: ReconstructionJetSups,
640 amplitude: f64,
641 target_norm: f64,
642 prior_lipschitz: f64,
643) -> f64 {
644 let z = amplitude.abs();
645 let m_jac = z * recon_sups.jacobian;
646 let m_hess = z * recon_sups.hessian;
647 let m_third = z * recon_sups.third;
648 let recon_value = z * recon_sups.value;
649 let r_norm = target_norm + recon_value;
650 3.0 * m_jac * m_hess + r_norm * m_third + prior_lipschitz
651}
652
653/// One offline-certified chart: a center, its Kantorovich constants, and the
654/// certified Newton-convergence radius `R_c` solved from `h = β·η·L ≤ ½` at the
655/// worst-case in-chart start.
656#[derive(Debug, Clone)]
657pub struct CertifiedChart {
658 pub region: ChartRegion,
659 /// Closed-form Hessian-Lipschitz constant `L` over the chart.
660 pub lipschitz: f64,
661 /// `β = ‖F'(t_c)⁻¹‖` at the chart center (worst-case in-chart start uses
662 /// the center's curvature; the radius is solved so the certificate holds for
663 /// any start in the ball).
664 pub beta_center: f64,
665 /// Certified Newton radius: starts within `radius` of `t_c` satisfy `h ≤ ½`.
666 pub certified_radius: f64,
667 /// Distilled amortized-encoder Jacobian for this chart (#1026 ladder item 3).
668 ///
669 /// The exact encode map `x ↦ t` solves `F(t; x) = J_m(t)ᵀ(m(t) − x) = 0`. By
670 /// the implicit function theorem its derivative at the converged root is
671 /// `dt/dx = −(∂_t F)⁻¹ (∂_x F) = H⁻¹ J_m` (since `∂_x F = −J_m`), so the
672 /// first-order Taylor expansion of the encode map about this chart's center
673 /// `t_c` is the closed-form AFFINE predictor
674 ///
675 /// ```text
676 /// t(x) ≈ t_c + (1/z) · A₁ · (x − z · m₁(t_c)), A₁ = (J₁ᵀJ₁ + ridge·I)⁻¹ J₁,
677 /// ```
678 ///
679 /// with `J₁ = Bᵀ J_Φ(t_c)` and `m₁(t_c) = BᵀΦ(t_c)` the AMPLITUDE-1
680 /// reconstruction jets (the amplitude `z` factors out analytically, so the
681 /// stored Jacobian is amplitude-free). This is the DISTILLED amortized
682 /// encoder of the #1026 thread: the per-row Hessian factorization + Newton
683 /// iteration is moved OFFLINE into this `d × p` matrix, leaving a single
684 /// `O(d·p)` mat-vec online — no per-row eigendecomposition, no second-jet
685 /// evaluation. The Kantorovich certificate is still evaluated AT the
686 /// predicted start, so the amortized prediction is trusted iff `h ≤ ½` and an
687 /// uncertified row still routes to the exact multi-start solve (the encoder
688 /// approximates inference, the certificate keeps it honest — the thread's
689 /// "encoder + certificate-gated exact fallback" deployment). `None` when the
690 /// center's Gauss–Newton block is singular (no certifiable amortization).
691 pub amortized_jacobian: Option<Array2<f64>>,
692 /// Amplitude-1 chart-center reconstruction `m₁(t_c) = BᵀΦ(t_c)` (length `p`),
693 /// the anchor the amortized predictor expands the encode map around.
694 pub recon_center: Array1<f64>,
695 /// Precomputed affine-predictor CONSTANT term `base = t_c − A₁·m₁(t_c)` (length
696 /// `d`), so the online amortized encode of a row `x` at amplitude `z` is the
697 /// single mat-vec `t̂ = base + (1/z)·A₁·x` with NO per-row `A₁·m₁` recompute.
698 /// Hoisting this atom-static term offline is what lets the massive-K index-routed
699 /// fast paths run a single allocation-free pass over rows (rather than a per-atom
700 /// GEMM sub-batch that degenerates to one row per group when `K ≫ N`). `None`
701 /// exactly when `amortized_jacobian` is `None` (singular Gauss–Newton block).
702 pub amortized_base: Option<Array1<f64>>,
703}
704
705/// The per-atom encode atlas: a set of certified charts covering the atom's
706/// coordinate domain, plus the decoder/amplitude scaling needed to recompute a
707/// per-row certificate online.
708#[derive(Debug, Clone)]
709pub struct AtomEncodeAtlas {
710 pub atom_index: usize,
711 pub latent_dim: usize,
712 pub decoder_norm_sum: f64,
713 pub charts: Vec<CertifiedChart>,
714}
715
716/// Result of a certified encode over a batch of rows, carrying the honesty
717/// flag: how many rows could NOT be certified and were flagged for the exact
718/// multi-start fallback (issue #1010 — no approximation enters silently).
719#[derive(Debug, Clone)]
720pub struct EncodeResult {
721 /// Per-row encoded latent coordinates (`n_rows × latent_dim`).
722 pub coords: Array2<f64>,
723 /// Per-row certificate: `true` ⇒ the row's start satisfied `h ≤ ½` and the
724 /// 1–2 Newton steps are exact-into-the-certified-ball; `false` ⇒ flagged.
725 pub certified: Vec<bool>,
726 /// Count of rows that could not be certified. These ride the payload so the
727 /// caller routes them to the exact multi-start encode — honesty, never
728 /// silent. Equals `certified.iter().filter(|c| !**c).count()`.
729 pub encode_uncertified_count: usize,
730}
731
732/// Result of solving the frozen dictionary's joint coordinate objective over a
733/// batch. `converged[row]` is a numerical first-order stationarity verdict for
734/// the shared-residual objective, not a Newton--Kantorovich certificate.
735#[derive(Debug, Clone)]
736pub struct JointEncodeResult {
737 /// Per-atom coordinate blocks, each shaped `n_rows × latent_dim_k`.
738 pub coords: Vec<Array2<f64>>,
739 /// Joint row solve reached the first-order tolerance.
740 pub converged: Vec<bool>,
741 /// Exact tally of `false` entries in `converged`.
742 pub unconverged_count: usize,
743}
744
745impl JointEncodeResult {
746 pub(crate) fn new(coords: Vec<Array2<f64>>, converged: Vec<bool>) -> Self {
747 let unconverged_count = converged.iter().filter(|ok| !**ok).count();
748 Self {
749 coords,
750 converged,
751 unconverged_count,
752 }
753 }
754}
755
756impl EncodeResult {
757 pub(crate) fn from_rows(coords: Array2<f64>, certified: Vec<bool>) -> Self {
758 let encode_uncertified_count = certified.iter().filter(|c| !**c).count();
759 Self {
760 coords,
761 certified,
762 encode_uncertified_count,
763 }
764 }
765}
766
767/// The honest cost breakdown of the encode tax (reviewer condition #3). Every
768/// (row, atom) encode lands in exactly one of three tiers, in ascending cost:
769///
770/// 1. **amortized-certified** — the one-mat-vec distilled predictor's start
771/// already satisfies the Kantorovich `h ≤ ½` certificate. Cheapest.
772/// 2. **Newton-rescued** — the amortized start is uncertified, but the
773/// certified IFT-warm-start Newton encode lands a certified root. Middling.
774/// 3. **multi-start fallback** — neither certifies, so the row rides the exact
775/// multi-start solve. This is the true cost MULTIPLIER at scale, and its
776/// fraction GROWS with atom similarity / co-activation interference (the
777/// per-row joint `(t, a)` landscape multiplies basins that no per-atom
778/// certificate covers). Reporting it is what keeps the encode-tax story
779/// honest — an SAE's one-matmul encode has no analogue of this tail.
780///
781/// The tiers partition the (row, atom) grid: `amortized_certified +
782/// newton_rescued + multistart_fallback == n_rows · n_atoms`.
783#[derive(Debug, Clone, Default)]
784pub struct FallbackTelemetry {
785 pub n_rows: usize,
786 pub n_atoms: usize,
787 /// (row, atom) encodes certified by the cheap amortized predictor.
788 pub amortized_certified: usize,
789 /// (row, atom) encodes the amortized predictor missed but the certified
790 /// Newton warm-start rescued.
791 pub newton_rescued: usize,
792 /// (row, atom) encodes neither tier certified — routed to the exact
793 /// multi-start solve.
794 pub multistart_fallback: usize,
795}
796
797impl FallbackTelemetry {
798 /// Total (row, atom) encodes accounted for.
799 #[must_use]
800 pub fn total(&self) -> usize {
801 self.n_rows * self.n_atoms
802 }
803
804 /// Fraction of encodes the cheap amortized predictor certified outright.
805 #[must_use]
806 pub fn amortized_fraction(&self) -> f64 {
807 let t = self.total();
808 if t == 0 {
809 0.0
810 } else {
811 self.amortized_certified as f64 / t as f64
812 }
813 }
814
815 /// Fraction of encodes rescued by the certified Newton warm-start.
816 #[must_use]
817 pub fn newton_fraction(&self) -> f64 {
818 let t = self.total();
819 if t == 0 {
820 0.0
821 } else {
822 self.newton_rescued as f64 / t as f64
823 }
824 }
825
826 /// Fraction of encodes that fell through to the exact multi-start solve —
827 /// the encode-tax cost multiplier.
828 #[must_use]
829 pub fn multistart_fraction(&self) -> f64 {
830 let t = self.total();
831 if t == 0 {
832 0.0
833 } else {
834 self.multistart_fallback as f64 / t as f64
835 }
836 }
837
838 /// Fold another atom's tallies into this one (n_rows is shared across atoms;
839 /// n_atoms and the tier counts accumulate). Lets a caller aggregate the
840 /// per-atom telemetry of [`EncodeAtlas::encode_atom_with_fallback_telemetry`]
841 /// into one dictionary-wide breakdown.
842 pub fn accumulate(&mut self, other: &FallbackTelemetry) {
843 self.n_rows = other.n_rows;
844 self.n_atoms += other.n_atoms;
845 self.amortized_certified += other.amortized_certified;
846 self.newton_rescued += other.newton_rescued;
847 self.multistart_fallback += other.multistart_fallback;
848 }
849}
850
851/// Per-row Kantorovich certificate at a start `t₀` for one atom encode.
852#[derive(Debug, Clone, Copy)]
853pub struct RowCertificate {
854 pub beta: f64,
855 pub eta: f64,
856 pub lipschitz: f64,
857 /// `h = β·η·L`. The row is certified iff `h ≤ ½`.
858 pub h: f64,
859}
860
861impl RowCertificate {
862 pub fn certified(&self) -> bool {
863 self.h.is_finite() && self.h <= KANTOROVICH_THRESHOLD
864 }
865}
866
867#[derive(Debug, Clone)]
868struct CertifiedEncodeProbe {
869 coord: Array1<f64>,
870 final_cert: RowCertificate,
871}
872
873/// Canonical flat-axis polynomial degree of a cylinder `S¹ × ℝ` atom — the
874/// degree the topology-race builder ([`gam_solve::structure_harvest`]) uses
875/// for the line axis (`CylinderHarmonicEvaluator::new(_, 2)`). The encode atlas
876/// recovers the circle harmonic count from the basis width using this degree, so
877/// the two must agree.
878pub(crate) const SAE_CYLINDER_LINE_DEGREE: usize = 2;
879
880/// Build a basis-family handle for one atom from its [`SaeManifoldAtom`]. The
881/// atlas needs to evaluate the jet sups, which live on the concrete evaluator
882/// types; the atom carries the evaluator as `Arc<dyn SaeBasisEvaluator>`, so we
883/// reconstruct the family bound from the atom's basis kind + width + centers.
884///
885/// The width used is the FULL inner-basis width [`SaeManifoldAtom::full_basis_size`],
886/// never the stored (possibly #1117 rank-reduced) [`SaeManifoldAtom::basis_size`].
887/// After [`SaeManifoldAtom::reduce_basis_to_subspace`] the live columns are
888/// Q-mixtures `Φ̃ = Φ Q` of the fixed-width family, so a family rebuilt at the
889/// REDUCED width bounds the wrong function space — a 5-wide periodic atom reduced
890/// to `r = 3` would be bounded as a single-harmonic family (under-estimating the
891/// `g`-th jet by `2^g`), and a degree-2 patch reduced to `r = 2` would be bounded
892/// as affine (`L = 0` ⇒ every start "certifies": a FALSE Kantorovich
893/// certificate, violating the module invariant that every bound over-estimates).
894/// The sups returned here bound the FULL-width family; [`reconstruction_jet_sups`]
895/// pairs them with the full-width decoder pre-image `B = Q B̃`, against which the
896/// reconstruction is IDENTICAL (`Φ̃ B̃ = Φ (Q B̃)`), so the certificate frame never
897/// sees the reduction and stays sound.
898pub(crate) fn family_jet_sups(
899 atom: &SaeManifoldAtom,
900 chart: &ChartRegion,
901) -> Result<JetSups, String> {
902 use crate::manifold::SaeAtomBasisKind::*;
903 let m = atom.full_basis_size();
904 let d = atom.latent_dim();
905 let sups = match atom.basis_kind() {
906 Periodic => {
907 let ev = PeriodicHarmonicEvaluator::new(m)?;
908 JetSups::from_family(&ev, chart)
909 }
910 Torus => {
911 // Torus basis width is `(2H+1)^d`; recover the per-axis harmonic
912 // count `H` from `axis_m = m^(1/d)` rather than a sum formula.
913 let axis_m = integer_root(m, d.max(1));
914 let num_harmonics = axis_m.saturating_sub(1) / 2;
915 let ev = TorusHarmonicEvaluator::new(d, num_harmonics.max(1))?;
916 JetSups::from_family(&ev, chart)
917 }
918 Sphere => {
919 let ev = SphereChartEvaluator;
920 JetSups::from_family(&ev, chart)
921 }
922 ProjectivePlane | KleinBottle => {
923 return Err(
924 "EncodeAtlas: quotient spectral jet sup requires a plan-native bound; route this atom through exact analytic encode"
925 .to_string(),
926 );
927 }
928 Cylinder => {
929 // Cylinder width is `(2H+1)·(D+1)` with the canonical flat-axis
930 // degree `D = SAE_CYLINDER_LINE_DEGREE` (the harvest convention).
931 // Recover the per-axis circle harmonic count `H` from
932 // `2H+1 = m/(D+1)`.
933 let ml = SAE_CYLINDER_LINE_DEGREE + 1;
934 if d != 2 || ml == 0 || m % ml != 0 {
935 return Err(format!(
936 "EncodeAtlas: Cylinder atom requires latent_dim == 2 and width divisible by {ml}; got dim={d}, m={m}"
937 ));
938 }
939 let axis_mc = m / ml;
940 let h = axis_mc.saturating_sub(1) / 2;
941 let ev = CylinderHarmonicEvaluator::new(h.max(1), SAE_CYLINDER_LINE_DEGREE)?;
942 JetSups::from_family(&ev, chart)
943 }
944 Mobius => {
945 return Err(
946 "EncodeAtlas: Mobius jet bounds require its persisted harmonic and width \
947 degrees; use the atom's exact analytic jets"
948 .to_string(),
949 );
950 }
951 Linear | EuclideanPatch | Poincare => {
952 // The patch width fixes max_degree implicitly; bound by a degree that
953 // covers the column count (conservative). Degree d-patch column count
954 // grows fast; we recover the smallest degree whose patch is ≥ m.
955 // Poincare atoms use the same tangent-coordinate polynomial decoder;
956 // their intrinsic smoothness differs in the penalty, not in Phi(t).
957 let degree = euclidean_patch_degree(d, m);
958 let ev = EuclideanPatchEvaluator::new(d, degree)?;
959 JetSups::from_family(&ev, chart)
960 }
961 Duchon => {
962 // UNSOUND — DO NOT TRUST for a certificate (F2/F3). This bound
963 // hard-codes cubic `φ(r) = r³` radial jets and a single origin center
964 // (`duchon_centers_from_atom`), but the real Duchon kernel is the
965 // polyharmonic `c·r^(2m−d)` (with log variants) over the atom's
966 // data-placed centers — so it can UNDER-estimate L and issue a FALSE
967 // certificate (the module's own warning). The atom does not expose its
968 // real order / center matrix / scaling to this crate, so no sound bound
969 // is available. `build_atom_atlas_from_centers` therefore REFUSES to
970 // certify Duchon atoms (emits uncertified charts → exact-encode
971 // fallback) and never reaches this arm; it is retained only so the
972 // family dispatch is total. If a future change threads the real
973 // order/centers here, replace this with the true `φ_{m,d}` jet bounds.
974 let centers = duchon_centers_from_atom(atom);
975 let conservative_m = m.max(1);
976 let ev = DuchonCoordinateEvaluator::new(centers, conservative_m)?;
977 JetSups::from_family(&ev, chart)
978 }
979 Precomputed(name) => {
980 return Err(format!(
981 "EncodeAtlas: precomputed basis '{name}' has no closed-form jet sup; route to exact encode"
982 ));
983 }
984 // A finite-set (indicator) atom is piecewise constant — it has no
985 // continuous jet to bound, so there is no Kantorovich chart; route to the
986 // exact encode like any other non-differentiable basis.
987 FiniteSet => {
988 return Err(
989 "EncodeAtlas: finite-set (indicator) basis has no closed-form jet sup; \
990 route to exact encode"
991 .to_string(),
992 );
993 }
994 };
995 Ok(sups)
996}
997
998/// Smallest monomial-patch degree whose column count covers `m` basis columns.
999pub(crate) fn euclidean_patch_degree(latent_dim: usize, m: usize) -> usize {
1000 // Column count of a degree-D patch in d vars is C(d+D, D). Grow D until it
1001 // covers m; cap at m so a degenerate width still terminates.
1002 let mut degree = 0usize;
1003 while patch_column_count(latent_dim, degree) < m && degree < m {
1004 degree += 1;
1005 }
1006 degree
1007}
1008
1009/// Largest integer `a` with `a^k ≤ n` (the floor of the `k`-th root). Used to
1010/// recover the per-axis harmonic width `axis_m` from a torus basis width
1011/// `m = axis_m^d`.
1012pub(crate) fn integer_root(n: usize, k: usize) -> usize {
1013 if k == 0 {
1014 return 1;
1015 }
1016 if k == 1 {
1017 return n;
1018 }
1019 let mut a = 1usize;
1020 loop {
1021 let next = a + 1;
1022 let mut pow: u128 = 1;
1023 let mut overflow = false;
1024 for _ in 0..k {
1025 pow = pow.saturating_mul(next as u128);
1026 if pow > n as u128 {
1027 overflow = true;
1028 break;
1029 }
1030 }
1031 if overflow {
1032 return a;
1033 }
1034 a = next;
1035 }
1036}
1037
1038pub(crate) fn patch_column_count(latent_dim: usize, degree: usize) -> usize {
1039 // C(d + D, D)
1040 let mut num = 1u128;
1041 let mut den = 1u128;
1042 for i in 1..=degree {
1043 num *= (latent_dim + i) as u128;
1044 den *= i as u128;
1045 }
1046 (num / den) as usize
1047}
1048
1049/// Recover Duchon centers from an atom: when the evaluator is unavailable the
1050/// atlas falls back to the atom's own latent-coordinate hull as the center set,
1051/// which only affects the radial-tail bound conservatively.
1052pub(crate) fn duchon_centers_from_atom(atom: &SaeManifoldAtom) -> Array2<f64> {
1053 // One center at the origin in latent_dim space is a sound conservative
1054 // default: the chart's own r_min / r_max bracket the true radial range.
1055 Array2::<f64>::zeros((1, atom.latent_dim().max(1)))
1056}
1057
1058/// The four per-column jet sups of a basis family over a chart.
1059#[derive(Debug, Clone, Copy)]
1060pub(crate) struct JetSups {
1061 pub(crate) value: f64,
1062 pub(crate) jacobian: f64,
1063 pub(crate) hessian: f64,
1064 pub(crate) third: f64,
1065}
1066
1067impl JetSups {
1068 pub(crate) fn from_family<B: BasisHessianLipschitz>(family: &B, chart: &ChartRegion) -> Self {
1069 Self {
1070 value: family.value_sup(chart),
1071 jacobian: family.jacobian_sup(chart),
1072 hessian: family.hessian_sup(chart),
1073 third: family.third_sup(chart),
1074 }
1075 }
1076}
1077
1078/// Evaluate one atom's encode objective gradient `F(t) = ∇f_k(t)` and the FULL
1079/// Hessian `F'(t) = ∇²f_k(t)` at a single coordinate `t`, for a single target
1080/// row `x` and fixed amplitude `z`. With `m(t) = z·BᵀΦ(t)`, `r = m − x`,
1081/// `J_m = z·Bᵀ J_Φ`:
1082///
1083/// ```text
1084/// g_t[a] = J_m[a] · r (= ∇f)
1085/// H_tt[a,b] = J_m[a] · J_m[b] + r · ∂²m/∂t_a∂t_b (= ∇²f, FULL Hessian)
1086/// ```
1087///
1088/// The certificate uses the FULL Hessian rather than the Gauss-Newton block
1089/// `J_mᵀ J_m`. This is the principled choice for Newton–Kantorovich: the
1090/// theorem certifies convergence of Newton on `F = ∇f` to the unique nearby
1091/// ROOT of `∇f`, but a root of `∇f` can be a maximum. The full Hessian is
1092/// positive-definite exactly on the genuine-minimum basin, so requiring
1093/// `λ_min(H) > 0` (finite `β`) is what flags a start that would otherwise let
1094/// Gauss-Newton march into the wrong root (e.g. the circle antipode, a local
1095/// max where `∇f = 0` but the full curvature is negative). The residual term
1096/// needs the basis second jet `∂²Φ/∂t²`; an evaluator without one returns
1097/// `None`, and the row is flagged (no silent Gauss-Newton fallback).
1098///
1099/// The Hessian returned is the TRUE `∇²f_k` — no Levenberg ridge is added
1100/// (F2). The Kantorovich certificate (`row_certificate`) and its `λ_min(H) > 0`
1101/// saddle gate must see the genuine field: a ridged `H + λI` certifies neither
1102/// the original objective nor a consistently regularized one (a
1103/// locally-constant reconstruction has `H = 0`, whose ridged `λI` would falsely
1104/// certify a non-isolated, non-unique root). Ridge stays only in the
1105/// UNCERTIFIED amortized predictor (`center_amortized_jacobian`/`center_beta`).
1106pub fn encode_grad_hess(
1107 atom: &SaeManifoldAtom,
1108 evaluator: &dyn SaeBasisEvaluator,
1109 t: ArrayView1<'_, f64>,
1110 x: ArrayView1<'_, f64>,
1111 amplitude: f64,
1112) -> Result<Option<(Array1<f64>, Array2<f64>)>, String> {
1113 // The bare Euclidean, prior-free objective — the historical field, bit-identical
1114 // to the metric-free encode (see [`encode_grad_hess_core`], `EncodeObjective`).
1115 encode_grad_hess_core(
1116 atom,
1117 evaluator,
1118 t,
1119 x,
1120 amplitude,
1121 &EncodeObjective::euclidean(),
1122 )
1123}
1124
1125/// The TRUE per-row encode objective's non-Euclidean ingredients (F3), so the
1126/// Kantorovich certificate certifies the SAME functional the fit optimized `t`
1127/// against — not a bare Euclidean stand-in.
1128///
1129/// The fit's per-row data loss is generalized least squares `½ rᵀ M_n r`
1130/// (`r = m(t) − x`) under a per-row output metric `M_n = U_n U_nᵀ`
1131/// ([`gam_problem::RowMetric`]), plus a per-axis latent coordinate prior
1132/// `Σ_a ArdAxisPrior(α_a, t_a)` (the ARD Gaussian / von-Mises energy the fit
1133/// placed on the coordinate). The metric-free encode drops BOTH, so its certified
1134/// root solves a different problem whenever a non-identity metric or an active
1135/// prior is present. `EncodeObjective` threads them back in:
1136///
1137/// * `metric_factor` — the per-row factor `U_n ∈ ℝ^{p×rank}`; `None` ⇒ `M = I`.
1138/// Whitening reduces to `M r = U(Uᵀr)` and `Jᵀ M J = (UᵀJ)ᵀ(UᵀJ)`.
1139/// * `prior_alpha` — per-axis ARD precision `α_a` (the caller folds in any row
1140/// weight); `None`/`0` ⇒ no prior on that axis. The period is read from the
1141/// atom's basis kind so a periodic axis uses the von-Mises energy.
1142/// * `metric_norm_bound` — a GLOBAL upper bound `max_n ‖M_n‖` used to scale the
1143/// offline chart Lipschitz (the per-row `M_n` enters `β, η` online; the
1144/// certificate needs a valid `L` upper bound, and `L_data` scales by `‖M‖`).
1145///
1146/// [`EncodeObjective::euclidean`] (all `None`, bound `1`) reproduces the metric-
1147/// free field bit-for-bit, so every existing caller is unchanged.
1148#[derive(Clone, Copy)]
1149pub struct EncodeObjective<'a> {
1150 /// Per-row output-metric factor `U ∈ ℝ^{p×rank}` (`M = U Uᵀ`). `None` ⇒ `I`.
1151 pub metric_factor: Option<ArrayView2<'a, f64>>,
1152 /// Per-axis ARD precision `α_a` (row weight folded in). `None` ⇒ no prior.
1153 pub prior_alpha: Option<&'a [f64]>,
1154 /// Global bound `max_n ‖M_n‖` scaling the offline chart Lipschitz. `1.0` for
1155 /// the Euclidean objective.
1156 pub metric_norm_bound: f64,
1157}
1158
1159impl<'a> EncodeObjective<'a> {
1160 /// The bare Euclidean, prior-free objective — every code path is bit-identical
1161 /// to the metric-free encode under this value.
1162 pub fn euclidean() -> Self {
1163 Self {
1164 metric_factor: None,
1165 prior_alpha: None,
1166 metric_norm_bound: 1.0,
1167 }
1168 }
1169
1170 /// Closed-form Lipschitz contribution of the latent prior's third derivative.
1171 /// The Gaussian (non-periodic) prior is quadratic, so `prior''' ≡ 0`; the
1172 /// von-Mises (periodic) prior has `hess = α cos(κt)` ⇒ `third = −α κ sin(κt)`,
1173 /// bounded by `α·κ` with `κ = 2π/period`. Summed over axes (a conservative
1174 /// bound on the diagonal third-order tensor's operator norm — over-estimating
1175 /// `L` only shrinks the certified radius, never certifies a divergent start).
1176 fn prior_lipschitz(&self, atom: &SaeManifoldAtom) -> f64 {
1177 let Some(alpha) = self.prior_alpha else {
1178 return 0.0;
1179 };
1180 let mut l = 0.0;
1181 for axis in 0..atom.latent_dim().min(alpha.len()) {
1182 if let Some(period) = latent_axis_period(atom, axis) {
1183 let kappa = std::f64::consts::TAU / period;
1184 l += alpha[axis].abs() * kappa;
1185 }
1186 }
1187 l
1188 }
1189
1190 /// The chart's Kantorovich Lipschitz for the TRUE objective: the stored
1191 /// Euclidean data-term bound `data_lipschitz` scaled by the global metric
1192 /// operator-norm bound (`½ rᵀM r`'s Hessian-Lipschitz is `‖M‖·L_data`), plus
1193 /// the prior's third-derivative bound. Reduces to `data_lipschitz` exactly for
1194 /// [`Self::euclidean`] (`1·L + 0`).
1195 fn effective_lipschitz(&self, atom: &SaeManifoldAtom, data_lipschitz: f64) -> f64 {
1196 self.metric_norm_bound * data_lipschitz + self.prior_lipschitz(atom)
1197 }
1198}
1199
1200/// Apply the per-row output metric `M = U Uᵀ` to a residual/tangent vector:
1201/// `M v = U (Uᵀ v)`, `U ∈ ℝ^{p×rank}`. `O(p·rank)`, never the dense `p×p`.
1202fn apply_row_metric(u: ArrayView2<'_, f64>, v: ArrayView1<'_, f64>) -> Array1<f64> {
1203 let utv = u.t().dot(&v); // Uᵀ v ∈ ℝ^rank
1204 u.dot(&utv) // U (Uᵀ v) ∈ ℝ^p
1205}
1206
1207/// Maximum Gauss--Newton iterations for the frozen-dictionary joint row solve.
1208const JOINT_ENCODE_MAX_ITER: usize = 64;
1209const JOINT_ENCODE_GRAD_TOL: f64 = 1.0e-10;
1210const JOINT_ENCODE_STEP_TOL: f64 = 1.0e-12;
1211
1212/// Floor on the Levenberg--Marquardt damping `λ` for the joint row solve. The
1213/// damping is carried and decayed *across* outer Gauss--Newton iterations
1214/// (warm-started at this value on entry, then raised on rejection and lowered on
1215/// acceptance), so it is a stateful trust-region parameter rather than an
1216/// `escalate_ridge` schedule — it is deliberately kept hand-rolled. This is the
1217/// initial `λ` seed at the smallest scale that still perturbs the Hessian.
1218const JOINT_ENCODE_DAMPING_FLOOR: f64 = 1.0e-10;
1219
1220/// Multiplicative growth applied to the LM damping `λ` whenever a damped step is
1221/// rejected (unfactorable, non-descent, or Armijo-exhausted). Numerically equal
1222/// to [`opt::constants::RIDGE_GROWTH`], but this loop is stateful across outer
1223/// iterations and is intentionally not routed through `escalate_ridge`.
1224const JOINT_ENCODE_DAMPING_GROWTH: f64 = 10.0;
1225
1226/// Multiplicative decay applied to the LM damping `λ` after an accepted step, so
1227/// the next outer iteration starts from a looser trust region. Kept above the
1228/// `f64::EPSILON · diag_scale` floor at the use site.
1229const JOINT_ENCODE_DAMPING_DECAY: f64 = 3.0;
1230
1231/// Maximum number of damping-escalation attempts within a single outer
1232/// Gauss--Newton iteration before the row is declared non-improvable.
1233const JOINT_ENCODE_DAMPING_MAX_ATTEMPTS: usize = 12;
1234
1235/// Maximum number of Armijo backtracking halvings for the inner line search on
1236/// each damped step. Passed as `max_steps` to the shared
1237/// [`opt::backtracking_line_search`] primitive.
1238const JOINT_ENCODE_ARMIJO_MAX_STEPS: usize = 24;
1239
1240fn joint_data_value_grad_hess(
1241 jac: ArrayView2<'_, f64>,
1242 residual: ArrayView1<'_, f64>,
1243 metric_factor: Option<ArrayView2<'_, f64>>,
1244) -> (f64, Array1<f64>, Array2<f64>) {
1245 let q = jac.nrows();
1246 let p = jac.ncols();
1247 let weighted_residual = match metric_factor.as_ref() {
1248 Some(u) => apply_row_metric(u.view(), residual),
1249 None => residual.to_owned(),
1250 };
1251 let value = 0.5 * residual.dot(&weighted_residual);
1252 let grad = jac.dot(&weighted_residual);
1253 let weighted_jac = match metric_factor.as_ref() {
1254 Some(u) => {
1255 let mut out = Array2::<f64>::zeros((q, p));
1256 for axis in 0..q {
1257 out.row_mut(axis)
1258 .assign(&apply_row_metric(u.view(), jac.row(axis)));
1259 }
1260 out
1261 }
1262 None => jac.to_owned(),
1263 };
1264 let hess = jac.dot(&weighted_jac.t());
1265 (value, grad, hess)
1266}
1267
1268/// Value, exact gradient, and positive-semidefinite Gauss--Newton curvature for
1269/// the shared-residual multi-atom objective. The gradient is exact; the PSD
1270/// curvature is used only to choose a descent step, so damping changes neither
1271/// the objective nor its stationary points.
1272fn joint_encode_value_grad_hess(
1273 atoms: &[SaeManifoldAtom],
1274 coords: &[Array1<f64>],
1275 x: ArrayView1<'_, f64>,
1276 amplitudes: ArrayView1<'_, f64>,
1277 metric_factor: Option<ArrayView2<'_, f64>>,
1278) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
1279 let k_atoms = atoms.len();
1280 if coords.len() != k_atoms || amplitudes.len() != k_atoms {
1281 return Err(format!(
1282 "joint encode: {} atoms require {} coordinate blocks and amplitudes; got {} and {}",
1283 k_atoms,
1284 k_atoms,
1285 coords.len(),
1286 amplitudes.len()
1287 ));
1288 }
1289 let p = x.len();
1290 if let Some(u) = metric_factor.as_ref() {
1291 if u.nrows() != p {
1292 return Err(format!(
1293 "joint encode: metric factor has {} rows but target has {p} outputs",
1294 u.nrows()
1295 ));
1296 }
1297 }
1298
1299 let mut offsets = Vec::with_capacity(k_atoms + 1);
1300 offsets.push(0usize);
1301 for (atom_idx, atom) in atoms.iter().enumerate() {
1302 if atom.output_dim() != p {
1303 return Err(format!(
1304 "joint encode: atom {atom_idx} output_dim {} != target width {p}",
1305 atom.output_dim()
1306 ));
1307 }
1308 if coords[atom_idx].len() != atom.latent_dim() {
1309 return Err(format!(
1310 "joint encode: atom {atom_idx} coordinate length {} != latent_dim {}",
1311 coords[atom_idx].len(),
1312 atom.latent_dim()
1313 ));
1314 }
1315 offsets.push(offsets[atom_idx] + atom.latent_dim());
1316 }
1317 let q = *offsets.last().unwrap_or(&0);
1318 let mut recon = Array1::<f64>::zeros(p);
1319 let mut jac = Array2::<f64>::zeros((q, p));
1320
1321 for (atom_idx, atom) in atoms.iter().enumerate() {
1322 let z = amplitudes[atom_idx];
1323 if !z.is_finite() {
1324 return Err(format!("joint encode: amplitude[{atom_idx}] is not finite"));
1325 }
1326 let Some(evaluator) = atom.basis_evaluator.as_ref() else {
1327 return Err(format!(
1328 "joint encode: atom {atom_idx} has no basis evaluator for its live coordinate block"
1329 ));
1330 };
1331 let d = atom.latent_dim();
1332 let m = atom.basis_size();
1333 let coord = coords[atom_idx]
1334 .view()
1335 .to_shape((1, d))
1336 .map_err(|e| format!("joint encode: atom {atom_idx} coordinate reshape: {e}"))?
1337 .to_owned();
1338 let (phi, dphi) = evaluator.evaluate(coord.view())?;
1339 if phi.dim() != (1, m) || dphi.dim() != (1, m, d) {
1340 return Err(format!(
1341 "joint encode: atom {atom_idx} evaluator returned phi {:?}, jet {:?}; expected (1,{m}) and (1,{m},{d})",
1342 phi.dim(),
1343 dphi.dim()
1344 ));
1345 }
1346 let start = offsets[atom_idx];
1347 for basis_col in 0..m {
1348 let phi_v = phi[[0, basis_col]];
1349 for out in 0..p {
1350 let b = atom.decoder_coefficients[[basis_col, out]];
1351 recon[out] += z * phi_v * b;
1352 for axis in 0..d {
1353 jac[[start + axis, out]] += z * dphi[[0, basis_col, axis]] * b;
1354 }
1355 }
1356 }
1357 }
1358
1359 let residual = &recon - &x;
1360 let (mut value, mut grad, mut hess) =
1361 joint_data_value_grad_hess(jac.view(), residual.view(), metric_factor.clone());
1362
1363 for (atom_idx, atom) in atoms.iter().enumerate() {
1364 let Some(alpha) = atom.ard_precisions.as_deref() else {
1365 continue;
1366 };
1367 let start = offsets[atom_idx];
1368 for axis in 0..atom.latent_dim().min(alpha.len()) {
1369 if alpha[axis] == 0.0 {
1370 continue;
1371 }
1372 let prior = crate::manifold::ArdAxisPrior::eval(
1373 alpha[axis],
1374 coords[atom_idx][axis],
1375 latent_axis_period(atom, axis),
1376 );
1377 value += prior.value;
1378 grad[start + axis] += prior.grad;
1379 hess[[start + axis, start + axis]] += prior.psd_majorizer_hess();
1380 }
1381 }
1382 Ok((value, grad, hess))
1383}
1384
1385fn joint_encode_damped_step(
1386 hess: ArrayView2<'_, f64>,
1387 grad: ArrayView1<'_, f64>,
1388 damping: f64,
1389) -> Result<Option<Array1<f64>>, String> {
1390 let q = grad.len();
1391 if q == 0 {
1392 return Ok(Some(Array1::zeros(0)));
1393 }
1394 let mut system = Array2::<f64>::zeros((q, q));
1395 for i in 0..q {
1396 for j in 0..q {
1397 system[[i, j]] = 0.5 * (hess[[i, j]] + hess[[j, i]]);
1398 }
1399 system[[i, i]] += damping;
1400 }
1401 let (evals, evecs) = system
1402 .eigh(Side::Lower)
1403 .map_err(|e| format!("joint encode: damped eigensolve failed: {e:?}"))?;
1404 if evals.iter().any(|&v| !(v.is_finite() && v > 0.0)) {
1405 return Ok(None);
1406 }
1407 let mut step = Array1::<f64>::zeros(q);
1408 for (col, &lambda) in evals.iter().enumerate() {
1409 let v = evecs.column(col);
1410 let coefficient = -v.dot(&grad) / lambda;
1411 for row in 0..q {
1412 step[row] += coefficient * v[row];
1413 }
1414 }
1415 if step.iter().any(|v| !v.is_finite()) {
1416 Ok(None)
1417 } else {
1418 Ok(Some(step))
1419 }
1420}
1421
1422fn joint_encode_add_step(
1423 atoms: &[SaeManifoldAtom],
1424 coords: &[Array1<f64>],
1425 step: ArrayView1<'_, f64>,
1426 scale: f64,
1427) -> Vec<Array1<f64>> {
1428 let mut out = Vec::with_capacity(atoms.len());
1429 let mut offset = 0usize;
1430 for (atom_idx, atom) in atoms.iter().enumerate() {
1431 let mut next = coords[atom_idx].clone();
1432 for axis in 0..atom.latent_dim() {
1433 next[axis] += scale * step[offset + axis];
1434 if let Some(period) = latent_axis_period(atom, axis) {
1435 next[axis] = next[axis].rem_euclid(period);
1436 }
1437 }
1438 offset += atom.latent_dim();
1439 out.push(next);
1440 }
1441 out
1442}
1443
1444/// Refine one row against all co-active atoms using the shared reconstruction
1445/// residual. Independent atlas projections may be supplied as starts, but every
1446/// accepted step and the convergence test use
1447/// `Σ_k z_k B_kᵀΦ_k(t_k) - x`, including all cross-atom Jacobian blocks.
1448pub(crate) fn joint_encode_refine_row(
1449 atoms: &[SaeManifoldAtom],
1450 initial_coords: &[Array1<f64>],
1451 x: ArrayView1<'_, f64>,
1452 amplitudes: ArrayView1<'_, f64>,
1453 metric_factor: Option<ArrayView2<'_, f64>>,
1454) -> Result<(Vec<Array1<f64>>, bool), String> {
1455 let mut coords = initial_coords.to_vec();
1456 let q: usize = atoms.iter().map(SaeManifoldAtom::latent_dim).sum();
1457 if q == 0 {
1458 return Ok((coords, true));
1459 }
1460 let target_scale = 1.0 + x.dot(&x).sqrt();
1461 let mut damping = JOINT_ENCODE_DAMPING_FLOOR;
1462
1463 for _ in 0..JOINT_ENCODE_MAX_ITER {
1464 let (value, grad, hess) =
1465 joint_encode_value_grad_hess(atoms, &coords, x, amplitudes, metric_factor.clone())?;
1466 let grad_norm = grad.dot(&grad).sqrt();
1467 if grad_norm <= JOINT_ENCODE_GRAD_TOL * target_scale {
1468 return Ok((coords, true));
1469 }
1470 let diag_scale = (0..q)
1471 .map(|i| hess[[i, i]].abs())
1472 .fold(0.0_f64, f64::max)
1473 .max(1.0);
1474 damping = damping.max(f64::EPSILON * diag_scale);
1475
1476 let mut accepted = None;
1477 for _ in 0..JOINT_ENCODE_DAMPING_MAX_ATTEMPTS {
1478 let Some(step) = joint_encode_damped_step(hess.view(), grad.view(), damping)? else {
1479 damping *= JOINT_ENCODE_DAMPING_GROWTH;
1480 continue;
1481 };
1482 let directional = grad.dot(&step);
1483 if !(directional.is_finite() && directional < 0.0) {
1484 damping *= JOINT_ENCODE_DAMPING_GROWTH;
1485 continue;
1486 }
1487 // Armijo backtracking on the shared reconstruction objective, migrated
1488 // onto the shared `opt` primitive with bit-for-bit-identical semantics:
1489 // initial step 1.0, `BACKTRACK_CONTRACTION` (×0.5) contraction,
1490 // `JOINT_ENCODE_ARMIJO_MAX_STEPS` (24) trials, and the exact
1491 // sufficient-decrease test `F(t + s·d) ≤ F(t) + c₁·s·∇F·d`
1492 // (c₁ = `ARMIJO_C1` = 1e-4, no roundoff cushion — the pre-migration
1493 // loop had none). `trial(s)` evaluates the candidate coords (always
1494 // well defined, so never `Ok(None)`) and threads them through the
1495 // payload so the accepted trial is returned without recomputation.
1496 let base_value = value;
1497 let step_unit_norm = step.dot(&step).sqrt();
1498 let line_search = backtracking_line_search::<Vec<Array1<f64>>, String>(
1499 BacktrackConfig {
1500 initial_step: 1.0,
1501 contraction: BACKTRACK_CONTRACTION,
1502 max_steps: JOINT_ENCODE_ARMIJO_MAX_STEPS,
1503 },
1504 |line_scale| {
1505 let candidate = joint_encode_add_step(atoms, &coords, step.view(), line_scale);
1506 let (candidate_value, _, _) = joint_encode_value_grad_hess(
1507 atoms,
1508 &candidate,
1509 x,
1510 amplitudes,
1511 metric_factor.clone(),
1512 )?;
1513 Ok(Some((candidate_value, candidate)))
1514 },
1515 |line_scale, candidate_value| {
1516 candidate_value <= base_value + ARMIJO_C1 * line_scale * directional
1517 },
1518 )?;
1519 if let Some(AcceptedStep {
1520 step: line_scale,
1521 payload: candidate,
1522 ..
1523 }) = line_search
1524 {
1525 accepted = Some((candidate, line_scale * step_unit_norm));
1526 }
1527 if accepted.is_some() {
1528 damping = (damping / JOINT_ENCODE_DAMPING_DECAY).max(f64::EPSILON * diag_scale);
1529 break;
1530 }
1531 damping *= JOINT_ENCODE_DAMPING_GROWTH;
1532 }
1533 let Some((next, step_norm)) = accepted else {
1534 return Ok((coords, false));
1535 };
1536 coords = next;
1537 if step_norm <= JOINT_ENCODE_STEP_TOL * target_scale {
1538 let (_, final_grad, _) =
1539 joint_encode_value_grad_hess(atoms, &coords, x, amplitudes, metric_factor.clone())?;
1540 let converged =
1541 final_grad.dot(&final_grad).sqrt() <= JOINT_ENCODE_GRAD_TOL * target_scale;
1542 return Ok((coords, converged));
1543 }
1544 }
1545 let (_, final_grad, _) =
1546 joint_encode_value_grad_hess(atoms, &coords, x, amplitudes, metric_factor)?;
1547 let converged = final_grad.dot(&final_grad).sqrt() <= JOINT_ENCODE_GRAD_TOL * target_scale;
1548 Ok((coords, converged))
1549}
1550
1551/// Objective-aware gradient/Hessian of the certified encode field (F3). With
1552/// [`EncodeObjective::euclidean`] this is bit-for-bit the historical metric-free
1553/// field; with a metric it whitens the residual through `M = U Uᵀ`, and with a
1554/// prior it adds the ARD/von-Mises gradient and (diagonal) Hessian.
1555pub(crate) fn encode_grad_hess_core(
1556 atom: &SaeManifoldAtom,
1557 evaluator: &dyn SaeBasisEvaluator,
1558 t: ArrayView1<'_, f64>,
1559 x: ArrayView1<'_, f64>,
1560 amplitude: f64,
1561 objective: &EncodeObjective<'_>,
1562) -> Result<Option<(Array1<f64>, Array2<f64>)>, String> {
1563 let d = atom.latent_dim();
1564 let p = atom.output_dim();
1565 let m = atom.basis_size();
1566 let coords = t.to_shape((1, d)).map_err(|e| e.to_string())?.to_owned();
1567 let (phi, jet) = evaluator.evaluate(coords.view())?;
1568 if phi.dim() != (1, m) {
1569 return Err(format!(
1570 "encode_grad_hess: evaluator returned phi {:?}, expected (1, {m})",
1571 phi.dim()
1572 ));
1573 }
1574 let decoder = &atom.decoder_coefficients;
1575 // Reconstruction m(t) = z · Bᵀ Φ(t) ∈ ℝᵖ.
1576 let mut recon = Array1::<f64>::zeros(p);
1577 for basis_col in 0..m {
1578 let phi_v = phi[[0, basis_col]];
1579 if phi_v == 0.0 {
1580 continue;
1581 }
1582 for out in 0..p {
1583 recon[out] += amplitude * phi_v * decoder[[basis_col, out]];
1584 }
1585 }
1586 let residual = &recon - &x;
1587 // J_m[axis] = z · Bᵀ (∂Φ/∂t_axis) ∈ ℝᵖ.
1588 let mut jm = Array2::<f64>::zeros((d, p));
1589 for axis in 0..d {
1590 for basis_col in 0..m {
1591 let dphi = jet[[0, basis_col, axis]];
1592 if dphi == 0.0 {
1593 continue;
1594 }
1595 for out in 0..p {
1596 jm[[axis, out]] += amplitude * dphi * decoder[[basis_col, out]];
1597 }
1598 }
1599 }
1600 // The full-Hessian residual term needs ∂²Φ/∂t². No second jet ⇒ no
1601 // certificate (flag), never a silent Gauss-Newton substitute.
1602 let second = match evaluator.second_jet_dyn(coords.view()) {
1603 Some(result) => result?,
1604 None => return Ok(None),
1605 };
1606 // F3 — metric whitening. The certified objective is `½ rᵀ M r`, so the field
1607 // reads the M-weighted residual `M r` and M-weighted image tangents `M J_m[a]`.
1608 // With no metric (`None`) `wr` aliases `residual` and `jb` aliases `jm.row(b)`,
1609 // so the assembly below is the historical Euclidean field bit-for-bit.
1610 let mr_owned;
1611 let wr: &Array1<f64> = match objective.metric_factor {
1612 Some(u) => {
1613 mr_owned = apply_row_metric(u, residual.view());
1614 &mr_owned
1615 }
1616 None => &residual,
1617 };
1618 let mjm: Option<Vec<Array1<f64>>> = objective
1619 .metric_factor
1620 .map(|u| (0..d).map(|a| apply_row_metric(u, jm.row(a))).collect());
1621 // (M-weighted) residual · decoder-row is INDEPENDENT of the (a,b) axes; hoist it
1622 // to one O(m·p) pass so the per-axis curvature term is a cheap O(m) dot.
1623 let mut rd = vec![0.0_f64; m];
1624 for (basis_col, rd_col) in rd.iter_mut().enumerate() {
1625 let mut dot = 0.0;
1626 for out in 0..p {
1627 dot += wr[out] * decoder[[basis_col, out]];
1628 }
1629 *rd_col = dot;
1630 }
1631 // g_t[a] = J_m[a] · (M r) ; H_tt[a,b] = J_m[a]·(M J_m[b]) + (M r)·∂²m/∂t_a∂t_b.
1632 // The full Hessian is symmetric (Gauss-Newton block + symmetric second jet), so
1633 // compute the upper triangle and mirror — half the curvature work.
1634 let mut g = Array1::<f64>::zeros(d);
1635 let mut h = Array2::<f64>::zeros((d, d));
1636 for a in 0..d {
1637 let ja = jm.row(a);
1638 g[a] = ja.dot(wr);
1639 for b in a..d {
1640 // Gauss-Newton block `J_aᵀ M J_b` (Euclidean: `J_aᵀ J_b`).
1641 let mut hab = match &mjm {
1642 Some(v) => ja.dot(&v[b]),
1643 None => ja.dot(&jm.row(b)),
1644 };
1645 // (M-weighted) residual · second-jet curvature: (M r) · ∂²m_{ab},
1646 // ∂²m_{ab}[out] = z · Σ_basis (∂²Φ/∂t_a∂t_b) · B[basis, out].
1647 let mut curv = 0.0;
1648 for basis_col in 0..m {
1649 let d2phi = second[[0, basis_col, a, b]];
1650 if d2phi == 0.0 {
1651 continue;
1652 }
1653 curv += amplitude * d2phi * rd[basis_col];
1654 }
1655 hab += curv;
1656 h[[a, b]] = hab;
1657 h[[b, a]] = hab;
1658 }
1659 }
1660 // F3 — latent coordinate prior. The fit placed an ARD (Gaussian) / von-Mises
1661 // (periodic) prior on `t`; its energy enters the certified objective, so its
1662 // gradient and (per-axis diagonal) Hessian enter the Newton field. Absent for
1663 // `EncodeObjective::euclidean` (no allocation, no arithmetic).
1664 if let Some(alpha) = objective.prior_alpha {
1665 for axis in 0..d.min(alpha.len()) {
1666 let alpha_axis = alpha[axis];
1667 if alpha_axis == 0.0 {
1668 continue;
1669 }
1670 let pr = crate::manifold::ArdAxisPrior::eval(
1671 alpha_axis,
1672 t[axis],
1673 latent_axis_period(atom, axis),
1674 );
1675 g[axis] += pr.grad;
1676 h[[axis, axis]] += pr.hess;
1677 }
1678 }
1679 // NO ridge: the certificate must use the TRUE Hessian (F2). See the doc above.
1680 Ok(Some((g, h)))
1681}
1682
1683/// Operator-norm of `H⁻¹` (i.e. `β = 1/λ_min(H)`) and the Newton step
1684/// `δ = −H⁻¹ g` with `η = ‖δ‖`, from a symmetric PSD `H` and gradient `g`.
1685/// Returns `None` when `H` is numerically singular (λ_min ≤ 0) — an
1686/// uncertifiable start.
1687pub(crate) fn beta_eta_newton(
1688 h: ArrayView2<'_, f64>,
1689 g: ArrayView1<'_, f64>,
1690) -> Result<Option<(f64, f64, Array1<f64>)>, String> {
1691 // Closed-form fast paths for the tiny latent dims that dominate SAE atoms
1692 // (`d = 1, 2`), avoiding a faer eigendecomposition + its heap allocations on
1693 // the hottest per-row Newton inner loop. `β = 1/λ_min(H)`, `δ = −H⁻¹g`, and the
1694 // `λ_min ≤ 0` gate are computed directly; the symmetric-`H` reads mirror
1695 // `eigh(Side::Lower)` (which uses the lower triangle) exactly.
1696 let d = h.nrows();
1697 if d == 1 {
1698 // A coordinate direction is certifiable only when the actual derivative
1699 // F'(t) has strictly positive, resolved curvature.
1700 let h00 = h[[0, 0]];
1701 if !(h00.is_finite() && h00 > 0.0) {
1702 return Ok(None);
1703 }
1704 let delta0 = -g[0] / h00;
1705 let mut delta = Array1::<f64>::zeros(1);
1706 delta[0] = delta0;
1707 return Ok(Some((1.0 / h00, delta0.abs(), delta)));
1708 }
1709 if d == 2 {
1710 // Symmetric H = [[a, b], [b, c]] read from the lower triangle.
1711 let a = h[[0, 0]];
1712 let b = h[[1, 0]];
1713 let c = h[[1, 1]];
1714 let tr = a + c;
1715 let det = a * c - b * b;
1716 // λ_min = ½(tr − √((a−c)² + 4b²)), λ_max = ½(tr + √…); ≥ 0 ⇒ PSD.
1717 let disc = ((a - c) * (a - c) + 4.0 * b * b).max(0.0).sqrt();
1718 let lambda_min = 0.5 * (tr - disc);
1719 let lambda_max = 0.5 * (tr + disc);
1720 let max_abs = lambda_min.abs().max(lambda_max.abs());
1721 if !(lambda_min.is_finite() && lambda_max.is_finite() && max_abs > 0.0) {
1722 return Ok(None);
1723 }
1724 let floor = gam_solve::arrow_schur::SPECTRAL_DEFLATION_REL_FLOOR * max_abs;
1725 // Healthy PD block — smallest eigenvalue clears the numerical null band —
1726 // keeps the closed-form fast path. A negative or unresolved eigenvalue is
1727 // not invertible as F'(t), so ordinary Kantorovich cannot certify it.
1728 if lambda_min > floor {
1729 // δ = −H⁻¹g with H⁻¹ = [[c, −b], [−b, a]] / det (det = λ_min·λ_max > 0).
1730 let inv_det = 1.0 / det;
1731 let g0 = g[0];
1732 let g1 = g[1];
1733 let d0 = -(c * g0 - b * g1) * inv_det;
1734 let d1 = -(a * g1 - b * g0) * inv_det;
1735 if !(d0.is_finite() && d1.is_finite()) {
1736 return Ok(None);
1737 }
1738 let mut delta = Array1::<f64>::zeros(2);
1739 delta[0] = d0;
1740 delta[1] = d1;
1741 let eta = (d0 * d0 + d1 * d1).sqrt();
1742 return Ok(Some((1.0 / lambda_min, eta, delta)));
1743 }
1744 return Ok(None);
1745 }
1746 beta_eta_newton_positive_definite(h, g)
1747}
1748
1749/// Spectral path for `d ≥ 3`. Ordinary Newton--Kantorovich requires the inverse
1750/// of the actual derivative `F'(t) = H`; quotienting or replacing a null
1751/// eigenvalue changes that derivative and therefore cannot certify the returned
1752/// iteration. We consequently accept only a numerically resolved positive-
1753/// definite Hessian and report every singular/indefinite start as uncertified.
1754fn beta_eta_newton_positive_definite(
1755 h: ArrayView2<'_, f64>,
1756 g: ArrayView1<'_, f64>,
1757) -> Result<Option<(f64, f64, Array1<f64>)>, String> {
1758 let d = h.nrows();
1759 // Symmetrise defensively before the eigendecomposition (the assembled Hessian
1760 // is symmetric only up to reduction order), mirroring the evidence routine.
1761 let mut sym = Array2::<f64>::zeros((d, d));
1762 for i in 0..d {
1763 for j in 0..d {
1764 let v = 0.5 * (h[[i, j]] + h[[j, i]]);
1765 if !v.is_finite() {
1766 return Ok(None);
1767 }
1768 sym[[i, j]] = v;
1769 }
1770 }
1771 let (vals, vecs) = sym
1772 .eigh(Side::Lower)
1773 .map_err(|e| format!("beta_eta_newton: eigh failed: {e:?}"))?;
1774 let max_abs = vals.iter().fold(
1775 0.0_f64,
1776 |acc, &v| if v.is_finite() { acc.max(v.abs()) } else { acc },
1777 );
1778 if !(max_abs.is_finite() && max_abs > 0.0) {
1779 return Ok(None);
1780 }
1781 let floor = gam_solve::arrow_schur::SPECTRAL_DEFLATION_REL_FLOOR * max_abs;
1782 if vals
1783 .iter()
1784 .any(|&lambda| !lambda.is_finite() || lambda <= floor)
1785 {
1786 return Ok(None);
1787 }
1788 let lambda_min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
1789 if !(lambda_min.is_finite() && lambda_min > 0.0) {
1790 return Ok(None);
1791 }
1792 let beta = 1.0 / lambda_min;
1793 // Newton step δ = −H⁻¹g via the eigendecomposition of the actual Hessian.
1794 let mut delta = Array1::<f64>::zeros(d);
1795 for (col, &lam) in vals.iter().enumerate() {
1796 let vi = vecs.column(col);
1797 let coeff = vi.dot(&g) / lam;
1798 for row in 0..d {
1799 delta[row] -= coeff * vi[row];
1800 }
1801 }
1802 if delta.iter().any(|v| !v.is_finite()) {
1803 return Ok(None);
1804 }
1805 let eta = delta.dot(&delta).sqrt();
1806 Ok(Some((beta, eta, delta)))
1807}
1808
1809/// Compute the per-row Kantorovich certificate for encoding target row `x`
1810/// against atom `atom` at start coordinate `t₀`, with fixed amplitude `z` and
1811/// the chart's closed-form Lipschitz constant `lipschitz`. Returns the
1812/// certificate AND the Newton step `δ = −H⁻¹ g` so the caller can advance.
1813pub fn row_certificate(
1814 atom: &SaeManifoldAtom,
1815 evaluator: &dyn SaeBasisEvaluator,
1816 t0: ArrayView1<'_, f64>,
1817 x: ArrayView1<'_, f64>,
1818 amplitude: f64,
1819 lipschitz: f64,
1820) -> Result<(RowCertificate, Array1<f64>), String> {
1821 // Euclidean, prior-free objective — bit-identical to the metric-free encode.
1822 row_certificate_core(
1823 atom,
1824 evaluator,
1825 t0,
1826 x,
1827 amplitude,
1828 lipschitz,
1829 &EncodeObjective::euclidean(),
1830 )
1831}
1832
1833/// Objective-aware [`row_certificate`] (F3): the certificate `h = β·η·L` is
1834/// computed from the TRUE objective's gradient/Hessian ([`encode_grad_hess_core`])
1835/// so it certifies the metric- and prior-weighted field. `lipschitz` must already
1836/// be the objective's effective bound ([`EncodeObjective::effective_lipschitz`]).
1837pub(crate) fn row_certificate_core(
1838 atom: &SaeManifoldAtom,
1839 evaluator: &dyn SaeBasisEvaluator,
1840 t0: ArrayView1<'_, f64>,
1841 x: ArrayView1<'_, f64>,
1842 amplitude: f64,
1843 lipschitz: f64,
1844 objective: &EncodeObjective<'_>,
1845) -> Result<(RowCertificate, Array1<f64>), String> {
1846 let uncertified = || {
1847 (
1848 RowCertificate {
1849 beta: f64::INFINITY,
1850 eta: f64::INFINITY,
1851 lipschitz,
1852 h: f64::INFINITY,
1853 },
1854 Array1::<f64>::zeros(atom.latent_dim()),
1855 )
1856 };
1857 // No second jet ⇒ no full Hessian ⇒ uncertifiable (flag).
1858 let Some((g, h)) = encode_grad_hess_core(atom, evaluator, t0, x, amplitude, objective)? else {
1859 return Ok(uncertified());
1860 };
1861 match beta_eta_newton(h.view(), g.view())? {
1862 Some((beta, eta, delta)) => {
1863 let cert = RowCertificate {
1864 beta,
1865 eta,
1866 lipschitz,
1867 h: beta * eta * lipschitz,
1868 };
1869 Ok((cert, delta))
1870 }
1871 // Indefinite / negative-curvature full Hessian: the start is at or past
1872 // a basin boundary (a max/saddle of f), not the minimum basin — flag.
1873 None => Ok(uncertified()),
1874 }
1875}
1876
1877fn uncertified_certificate(lipschitz: f64) -> RowCertificate {
1878 RowCertificate {
1879 beta: f64::INFINITY,
1880 eta: f64::INFINITY,
1881 lipschitz,
1882 h: f64::INFINITY,
1883 }
1884}
1885
1886fn refine_certified_start(
1887 atom: &SaeManifoldAtom,
1888 evaluator: &dyn SaeBasisEvaluator,
1889 mut t: Array1<f64>,
1890 x: ArrayView1<'_, f64>,
1891 amplitude: f64,
1892 lipschitz: f64,
1893 newton_steps: usize,
1894 initial_cert: RowCertificate,
1895 mut delta: Array1<f64>,
1896 chart_center: ArrayView1<'_, f64>,
1897 chart_radius: f64,
1898 objective: &EncodeObjective<'_>,
1899) -> Result<Option<CertifiedEncodeProbe>, String> {
1900 assert!(initial_cert.certified());
1901 let mut final_cert = initial_cert;
1902 for _ in 0..newton_steps {
1903 // Convergence early-exit: the pending Newton step is below the coordinate
1904 // ULP scale, so `t + δ == t` to f64 resolution — the certified root is
1905 // reached and the remaining fixed-budget steps would only re-accumulate
1906 // round-off. This is where the well-conditioned quadratic Newton tail's
1907 // redundant `evaluate` + `second_jet` work is eliminated.
1908 if delta.dot(&delta).sqrt() <= NEWTON_REFINE_CONVERGED_EPS * (1.0 + t.dot(&t).sqrt()) {
1909 break;
1910 }
1911 let next = &t + δ
1912 // SOUNDNESS GUARD — same containment rule as `certify_with_basin_warmup`:
1913 // `lipschitz` is only a valid Hessian-Lipschitz bound inside this chart's
1914 // ball for the chart-local families. A refine iterate that leaves the ball
1915 // would have its certificate recomputed below with an `L` that no longer
1916 // bounds the true geometry there, so `h ≤ ½` would NOT imply Kantorovich
1917 // convergence — and the in-hand certificate at the previous iterate is
1918 // itself suspect (its guarantee needs `L` valid on the Newton sequence's
1919 // ball, part of which now lies outside the chart). Refuse and flag for the
1920 // exact fallback, exactly as the warm-up does. Wrap-aware distance, so
1921 // periodic-seam iterates are measured in the true manifold geometry.
1922 if latent_coordinate_distance(atom, next.view(), chart_center) > chart_radius {
1923 return Ok(None);
1924 }
1925 t = next;
1926 let (cert, next_delta) = row_certificate_core(
1927 atom,
1928 evaluator,
1929 t.view(),
1930 x,
1931 amplitude,
1932 lipschitz,
1933 objective,
1934 )?;
1935 if !cert.certified() {
1936 return Ok(None);
1937 }
1938 final_cert = cert;
1939 delta = next_delta;
1940 }
1941 Ok(Some(CertifiedEncodeProbe {
1942 coord: t,
1943 final_cert,
1944 }))
1945}
1946
1947/// Certify an encode probe from `t_start`, navigating into the Kantorovich basin
1948/// first if needed (#1154/#1026). The Kantorovich quantity `h = β·η·L` scales with
1949/// amplitude through `L`, so at unit amplitude a positive-definite chart-center /
1950/// distilled start can sit OUTSIDE the certified ball (`h > ½`). Rather than
1951/// flagging it uncertified immediately — which made the encoder certify ZERO
1952/// held-out rows at amplitude 1.0 and fall back to the exact solve for everything —
1953/// take plain Newton steps toward the root, re-certifying at each iterate, while
1954/// the Kantorovich quantity `h = β·η·L` keeps CONTRACTING toward the ½ bound. The
1955/// certificate at the landing point is a full Kantorovich guarantee from there
1956/// (`h ≤ ½` ⇒ Newton converges to the in-ball root), so this only ever WIDENS the
1957/// certified set; it never certifies a non-convergent start.
1958///
1959/// Termination is the natural Newton stopping rule — there is no arbitrary step
1960/// budget. The warm-up stops and flags for the exact fallback when either the start
1961/// is not steppable (indefinite / non-finite Hessian — at or past a basin boundary)
1962/// or a step fails to reduce `h` (the iterate is not approaching a certifiable
1963/// in-chart root: its root lies outside this chart's valid Lipschitz region, or the
1964/// start was past the basin — empirically the rows that miss *plateau*, so more
1965/// steps cannot help; the lever there is denser charts, not more iterations). On
1966/// success the start is refined `newton_steps` further by [`refine_certified_start`].
1967/// Minimum per-step *multiplicative* decrease of the Kantorovich `h` the basin
1968/// warm-up requires to keep stepping (FIX #4). A tiny geometric floor: a
1969/// continued step must contract `h` by at least this fraction. Chosen small so
1970/// it never bites a genuinely converging row (Newton in the Kantorovich regime
1971/// is at least geometric and quadratic once `h < 1`, contracting `h` far faster
1972/// than `1/64` per step) while still forcing termination on a plateau.
1973///
1974/// PRICED (#2071): the value only sets the termination bound
1975/// `N < ln(2·h₀) / −ln(1 − c)` (below) — it is a floor on "real progress", not a
1976/// tuning parameter, so any small `c ∈ (0, 1)` is correct; `1/64 = 2^-6` is priced
1977/// for its bound. What breaks at 10×: at `c = 1/6.4` the floor starts rejecting
1978/// slow-but-genuine geometric contractions near the `½` certifiable boundary
1979/// (false plateaus → premature exact fallback); at `c = 1/640` the plateau bound
1980/// loosens ~10× (`N` grows from a few hundred to a few thousand
1981/// `row_certificate` solves on a pathological row). `1/64` keeps the bound at a
1982/// few hundred while leaving a wide margin below Newton's actual contraction.
1983const WARMUP_MIN_MULTIPLICATIVE_DECREASE: f64 = 1.0 / 64.0;
1984
1985/// Kantorovich-quadratic acceptance coefficient for the basin warm-up (FIX #4).
1986/// Once `h < 1` a converging Newton step contracts quadratically (`h_new ≲ κ·h²`);
1987/// accepting that path in addition to the geometric floor makes the
1988/// "genuinely-converging rows are untouched" guarantee explicit. Kept `< 1` so
1989/// the quadratic path is itself a strict contraction (for `h < 1`,
1990/// `κ·h² < κ·h < h`), which preserves the termination bound below.
1991///
1992/// PRICED (#2071): the only constraint the termination proof imposes is `κ < 1`
1993/// (so the quadratic branch stays a strict contraction and cannot defeat the
1994/// geometric bound); `0.5` is the natural centre of `(0, 1)`, a factor-2 margin
1995/// below the `κ = 1` boundary. What breaks at 10×: `κ = 5` violates `κ < 1` and
1996/// the quadratic branch could ACCEPT an expanding step (`κ·h² > h` for
1997/// `h > 1/κ`), breaking termination; `κ = 0.05` merely tightens the quadratic
1998/// acceptance (fewer rows take the quadratic branch, more fall to the geometric
1999/// floor) with no correctness effect. Anything in `(0, 1)` is sound; `0.5` maximises
2000/// the margin.
2001const WARMUP_QUADRATIC_KAPPA: f64 = 0.5;
2002
2003/// Sufficient-decrease test for the basin-warmup loop (FIX #4).
2004///
2005/// Returns `true` while the warm-up should keep stepping. The previous exit rule
2006/// (`h_new >= h_prev` — strict decrease or quit) never fires for an `h`-sequence
2007/// that decreases *monotonically toward a limit above ½*: the increments fall
2008/// below one ulp long before `h` crosses the certifiable ½ bound, so a single
2009/// pathological row could spin ~1e15 full `row_certificate` solves (Hessian
2010/// build + solve) on the encode hot path. We instead require genuine
2011/// *multiplicative* progress each step, which matches Newton's actual behavior:
2012/// a healthy contraction clears the geometric floor by a wide margin (and the
2013/// quadratic path once `h < 1`), while a plateau (`h_new/h_prev → 1`) satisfies
2014/// neither branch and flags to the exact fallback.
2015///
2016/// Termination bound: the warm-up loop runs only while the row is uncertified
2017/// (`h_prev > ½`), and every continued step contracts `h` by at least the factor
2018/// `(1 − WARMUP_MIN_MULTIPLICATIVE_DECREASE)` (the quadratic branch, for `h < 1`,
2019/// contracts by `κ·h_prev < κ < 1`, i.e. even harder). Hence after `N` continued
2020/// steps `h ≤ (1 − c)^N · h₀`, and since the loop stops once `h ≤ ½` we get
2021/// `N < ln(2·h₀) / −ln(1 − c)` — a few hundred iterations at most, versus
2022/// unbounded before. No arbitrary fixed step cap is imposed; the bound is a
2023/// consequence of the contraction requirement, in keeping with the function's
2024/// no-magic-budget design.
2025fn warmup_progress_sufficient(h_new: f64, h_prev: f64) -> bool {
2026 if !(h_new.is_finite() && h_prev.is_finite()) {
2027 return false;
2028 }
2029 // Geometric floor: a real Newton contraction easily clears this.
2030 if h_new <= (1.0 - WARMUP_MIN_MULTIPLICATIVE_DECREASE) * h_prev {
2031 return true;
2032 }
2033 // Kantorovich-quadratic path (only once `h < 1`, where it is a strict
2034 // contraction): makes the no-regression guarantee for converging rows
2035 // explicit without ever admitting a non-contracting (plateau) step.
2036 h_prev < 1.0 && h_new <= WARMUP_QUADRATIC_KAPPA * h_prev * h_prev
2037}
2038
2039/// Whether the basin warm-up must REJECT the just-taken step (flag to the exact
2040/// fallback). FIX (F6): a step that just crossed into the certified region
2041/// (`h ≤ ½`) is ALWAYS accepted, even if its multiplicative decrease was below
2042/// the progress floor (e.g. `h: 0.501 → 0.499`, a legitimate but tiny cross of
2043/// the ½ bound). Only a step that is STILL uncertified AND failed to make
2044/// sufficient multiplicative progress is a plateau that must flag. The old code
2045/// ran the progress test unconditionally, so it could reject an in-hand
2046/// certificate and push the row to the exact fallback for nothing (a false
2047/// negative).
2048fn warmup_should_reject(next_certified: bool, h_new: f64, h_prev: f64) -> bool {
2049 !next_certified && !warmup_progress_sufficient(h_new, h_prev)
2050}
2051
2052fn certify_with_basin_warmup(
2053 atom: &SaeManifoldAtom,
2054 evaluator: &dyn SaeBasisEvaluator,
2055 t_start: Array1<f64>,
2056 x: ArrayView1<'_, f64>,
2057 amplitude: f64,
2058 lipschitz: f64,
2059 newton_steps: usize,
2060 chart_center: ArrayView1<'_, f64>,
2061 chart_radius: f64,
2062 objective: &EncodeObjective<'_>,
2063) -> Result<Option<CertifiedEncodeProbe>, String> {
2064 // SOUNDNESS GUARD: `lipschitz` is the chart's Hessian-Lipschitz sup, which is
2065 // only a valid bound over this chart's ball `‖t − center‖ ≤ radius` for the
2066 // chart-local families (`EuclideanPatch`/`Linear`/`Poincare` monomial patches,
2067 // `Cylinder` line axis, `Duchon` radial kernels). If a warm-up iterate leaves
2068 // that ball, `row_certificate` would compute `h = β·η·L` with an `L` that no
2069 // longer bounds the true geometry there, so `h ≤ ½` would NOT imply Kantorovich
2070 // convergence — a false certificate. (The `h`-contraction check does NOT catch
2071 // this: `h` can decrease monotonically toward an out-of-chart root the whole
2072 // way.) So we keep every certified iterate inside the chart; a row whose root is
2073 // outside this chart flags for the exact fallback — its lever is a denser grid,
2074 // not a step using an invalid `L`. Global-`L` families (periodic/torus/sphere)
2075 // route their points to charts whose centers are near the root, so the guard
2076 // rarely trips for them, and where it does the row was out-of-chart anyway.
2077 let in_chart = |t: &Array1<f64>| -> bool {
2078 // Wrap-aware chart containment. A raw Euclidean latent distance
2079 // `Σ(tᵢ − centerᵢ)²` mis-measures separation across the wrap seam of a
2080 // periodic axis: an iterate at `t = 0.99` against a chart centered at
2081 // `0.01` reads distance `0.98` instead of the true circle distance
2082 // `0.02`, so it is wrongly rejected at the start check and at every step
2083 // check — silently disabling the amortized encoder for a phase-localized
2084 // band of rows on every periodic / torus / cylinder-angle /
2085 // sphere-longitude chart and pushing that band to the multi-start
2086 // fallback. `latent_coordinate_distance` folds each axis onto its
2087 // `latent_axis_period`, so the containment ball is measured in the true
2088 // manifold geometry — exactly the geometry the chart's Lipschitz bound
2089 // `L` is valid over. This only ever moves points that are genuinely
2090 // near the center (small wrapped distance) back INTO the chart where
2091 // `L` holds, so it widens acceptance without weakening the soundness
2092 // guard (an iterate truly far from the center on the circle still fails).
2093 latent_coordinate_distance(atom, t.view(), chart_center) <= chart_radius
2094 };
2095 let mut t = t_start;
2096 // The distilled / chart-center start must itself be in-chart for its certificate
2097 // to be valid; a bad IFT prediction landing outside the chart is uncertifiable.
2098 if !in_chart(&t) {
2099 return Ok(None);
2100 }
2101 let (mut cert, mut delta) = row_certificate_core(
2102 atom,
2103 evaluator,
2104 t.view(),
2105 x,
2106 amplitude,
2107 lipschitz,
2108 objective,
2109 )?;
2110 while !cert.certified() {
2111 // Not steppable (indefinite / non-finite Hessian): flag.
2112 if !(cert.h.is_finite() && cert.beta.is_finite() && cert.eta.is_finite()) {
2113 return Ok(None);
2114 }
2115 let prev_h = cert.h;
2116 let next = &t + δ
2117 // Refuse to step where the chart's `L` is no longer valid (see guard above).
2118 if !in_chart(&next) {
2119 return Ok(None);
2120 }
2121 t = next;
2122 let (next_cert, next_delta) = row_certificate_core(
2123 atom,
2124 evaluator,
2125 t.view(),
2126 x,
2127 amplitude,
2128 lipschitz,
2129 objective,
2130 )?;
2131 cert = next_cert;
2132 delta = next_delta;
2133 // The warm-up only helps while h keeps *multiplicatively* contracting
2134 // toward ½. A plain strict-decrease test (`h >= prev_h`) never fires for
2135 // a sequence that decreases monotonically toward a limit above ½, so it
2136 // could spin ~1e15 `row_certificate` solves for one row; require genuine
2137 // multiplicative progress instead (bounded to a few hundred steps, see
2138 // `warmup_progress_sufficient`). Once a step fails that bar the iterate is
2139 // not converging to a certifiable in-chart root — flag for the exact
2140 // fallback (no arbitrary step budget; the bound falls out of the
2141 // contraction requirement).
2142 //
2143 // F6: only enforce the progress bar while STILL uncertified. If this step
2144 // just crossed `h ≤ ½` (e.g. 0.501 → 0.499, a decrease too small to clear
2145 // the multiplicative floor) the point is already certified — the loop
2146 // guard `while !cert.certified()` will exit and refine it. Running the
2147 // progress test unconditionally would falsely reject an in-hand certificate
2148 // and push the row to the exact fallback for nothing. See
2149 // [`warmup_should_reject`].
2150 if warmup_should_reject(cert.certified(), cert.h, prev_h) {
2151 return Ok(None);
2152 }
2153 }
2154 refine_certified_start(
2155 atom,
2156 evaluator,
2157 t,
2158 x,
2159 amplitude,
2160 lipschitz,
2161 newton_steps,
2162 cert,
2163 delta,
2164 chart_center,
2165 chart_radius,
2166 objective,
2167 )
2168}
2169
2170fn kantorovich_root_radius(cert: RowCertificate) -> f64 {
2171 if !cert.certified() || !(cert.eta.is_finite() && cert.eta >= 0.0) {
2172 return f64::INFINITY;
2173 }
2174 if cert.eta == 0.0 {
2175 return 0.0;
2176 }
2177 if !(cert.h.is_finite() && cert.h >= 0.0) {
2178 return f64::INFINITY;
2179 }
2180 let h = cert.h.min(KANTOROVICH_THRESHOLD);
2181 let discriminant = (1.0 - 2.0 * h).max(0.0).sqrt();
2182 let radius = 2.0 * cert.eta / (1.0 + discriminant);
2183 if radius.is_finite() {
2184 radius
2185 } else {
2186 f64::INFINITY
2187 }
2188}
2189
2190fn distilled_probe_tolerance(
2191 amortized: &CertifiedEncodeProbe,
2192 cold: &CertifiedEncodeProbe,
2193 amplitude: f64,
2194 x: ArrayView1<'_, f64>,
2195) -> f64 {
2196 let certified_radius =
2197 kantorovich_root_radius(amortized.final_cert) + kantorovich_root_radius(cold.final_cert);
2198 let coord_scale = amortized.coord.dot(&amortized.coord).sqrt()
2199 + cold.coord.dot(&cold.coord).sqrt()
2200 + x.dot(&x).sqrt()
2201 + amplitude.abs()
2202 + 1.0;
2203 certified_radius + 1024.0 * f64::EPSILON * coord_scale
2204}
2205
2206fn latent_coordinate_distance(
2207 atom: &SaeManifoldAtom,
2208 lhs: ArrayView1<'_, f64>,
2209 rhs: ArrayView1<'_, f64>,
2210) -> f64 {
2211 let mut acc = 0.0;
2212 for axis in 0..lhs.len().min(rhs.len()) {
2213 let mut diff = (lhs[axis] - rhs[axis]).abs();
2214 if let Some(period) = latent_axis_period(atom, axis) {
2215 let wrapped = diff.rem_euclid(period);
2216 diff = wrapped.min(period - wrapped);
2217 }
2218 acc += diff * diff;
2219 }
2220 acc.sqrt()
2221}
2222
2223fn latent_axis_period(atom: &SaeManifoldAtom, axis: usize) -> Option<f64> {
2224 use crate::manifold::SaeAtomBasisKind::*;
2225 match atom.basis_kind() {
2226 Periodic | Torus => Some(1.0),
2227 Cylinder if axis == 0 => Some(1.0),
2228 Sphere if axis == 1 => Some(std::f64::consts::TAU),
2229 _ => None,
2230 }
2231}
2232
2233/// Configuration for [`EncodeAtlas`] construction and online encode. All fields
2234/// are explicit; the atlas never reads global state and adds no CLI flags.
2235#[derive(Debug, Clone, Copy)]
2236pub struct AtlasConfig {
2237 /// Grid resolution per latent axis for offline chart centers (the
2238 /// SHAPE_BAND grid idiom).
2239 pub grid_resolution: usize,
2240 /// Levenberg ridge floor added to the per-row Gauss-Newton Hessian.
2241 pub ridge: f64,
2242 /// Number of online Newton refinement steps after a certified start (1 or 2
2243 /// per issue #1010).
2244 pub newton_steps: usize,
2245}
2246
2247impl Default for AtlasConfig {
2248 fn default() -> Self {
2249 Self {
2250 grid_resolution: 16,
2251 ridge: 1.0e-9,
2252 newton_steps: 2,
2253 }
2254 }
2255}
2256
2257/// The encode atlas: per-atom certified charts plus the online certified-encode
2258/// driver (issue #1010).
2259#[derive(Debug, Clone)]
2260pub struct EncodeAtlas {
2261 pub atoms: Vec<AtomEncodeAtlas>,
2262 pub config: AtlasConfig,
2263}
2264
2265impl EncodeAtlas {
2266 /// Build the offline atlas over a frozen dictionary: for each atom, lay down
2267 /// chart centers on the atom's coordinate grid and certify a Newton radius
2268 /// from the Kantorovich inequality at the worst-case in-chart start.
2269 ///
2270 /// `amplitude_bound[k]` is the per-atom bound on `|z_k|` used to scale the
2271 /// reconstruction jets (the offline `L` must hold for the largest amplitude
2272 /// the encode can produce); `target_norm_bound` bounds `‖x‖` over the data.
2273 pub fn build(
2274 atoms: &[SaeManifoldAtom],
2275 amplitude_bound: &[f64],
2276 target_norm_bound: f64,
2277 config: AtlasConfig,
2278 ) -> Result<Self, String> {
2279 if amplitude_bound.len() != atoms.len() {
2280 return Err(format!(
2281 "EncodeAtlas::build: amplitude_bound length {} != atom count {}",
2282 amplitude_bound.len(),
2283 atoms.len()
2284 ));
2285 }
2286 let mut atom_atlases = Vec::with_capacity(atoms.len());
2287 for (k, atom) in atoms.iter().enumerate() {
2288 let atlas =
2289 Self::build_atom_atlas(k, atom, amplitude_bound[k], target_norm_bound, &config)?;
2290 atom_atlases.push(atlas);
2291 }
2292 Ok(Self {
2293 atoms: atom_atlases,
2294 config,
2295 })
2296 }
2297
2298 pub(crate) fn build_atom_atlas(
2299 atom_index: usize,
2300 atom: &SaeManifoldAtom,
2301 amplitude_bound: f64,
2302 target_norm_bound: f64,
2303 config: &AtlasConfig,
2304 ) -> Result<AtomEncodeAtlas, String> {
2305 let centers = chart_center_grid(atom, config.grid_resolution);
2306 // Half the inter-center spacing is the natural in-chart radius so the
2307 // charts tile the grid without gaps; refined below if the certificate
2308 // fails at that radius. One uniform radius for the regular grid.
2309 let nominal_radius = chart_nominal_radius(atom, config.grid_resolution);
2310 let radii = vec![nominal_radius; centers.nrows()];
2311 Self::build_atom_atlas_from_centers(
2312 atom_index,
2313 atom,
2314 centers.view(),
2315 &radii,
2316 amplitude_bound,
2317 target_norm_bound,
2318 config,
2319 )
2320 }
2321
2322 /// Build a per-atom atlas from EXPLICIT chart centers with a per-center
2323 /// nominal radius — the geometry-agnostic core shared by the regular-grid
2324 /// [`Self::build_atom_atlas`] and the data-driven [`Self::build_data_driven`].
2325 /// Every chart is certified identically (Kantorovich radius from the in-chart
2326 /// curvature at its center); only the center PLACEMENT and per-center radius
2327 /// differ. `radii[c]` is the nominal in-chart radius for `centers[c]`.
2328 pub(crate) fn build_atom_atlas_from_centers(
2329 atom_index: usize,
2330 atom: &SaeManifoldAtom,
2331 centers: ArrayView2<'_, f64>,
2332 radii: &[f64],
2333 amplitude_bound: f64,
2334 target_norm_bound: f64,
2335 config: &AtlasConfig,
2336 ) -> Result<AtomEncodeAtlas, String> {
2337 let d = atom.latent_dim();
2338 if centers.ncols() != d {
2339 return Err(format!(
2340 "build_atom_atlas_from_centers: centers have {} cols but atom latent_dim is {d}",
2341 centers.ncols()
2342 ));
2343 }
2344 if radii.len() != centers.nrows() {
2345 return Err(format!(
2346 "build_atom_atlas_from_centers: {} radii != {} centers",
2347 radii.len(),
2348 centers.nrows()
2349 ));
2350 }
2351 // Full-width frame (matches `family_jet_sups` / `reconstruction_jet_sups`):
2352 // the atlas's stored decoder scaling must pair with the full-width family
2353 // sups, so a #1117 rank-reduced atom contributes `Σ‖(Q B̃)_{m,:}‖`, not the
2354 // reduced-row sum. Identical for an un-reduced atom.
2355 let decoder_norm_sum = decoder_row_norm_sum(atom.full_width_decoder().view());
2356 let mut charts = Vec::with_capacity(centers.nrows());
2357 // HONEST REFUSAL for Duchon atoms (F2/F3): the closed-form Hessian-Lipschitz
2358 // bound available here (`family_jet_sups` Duchon arm) hard-codes cubic-r³
2359 // jets and a single origin center, but the real Duchon kernel is the
2360 // polyharmonic `c·r^(2m−d)` (with log variants) over data-placed centers.
2361 // That bound can UNDER-estimate L → a FALSE certificate (the module's own
2362 // warning; underestimating L is the dangerous direction). The atom does not
2363 // expose its real order/centers/scaling to this crate, so no sound bound is
2364 // available — refuse rather than fabricate. Every Duchon chart is emitted
2365 // UNCERTIFIED (`certified_radius = 0`, no amortized predictor), so routing
2366 // skips it and every Duchon row flags for the exact multi-start encode.
2367 let duchon_uncertifiable =
2368 matches!(atom.basis_kind(), crate::manifold::SaeAtomBasisKind::Duchon);
2369 for c in 0..centers.nrows() {
2370 let center = centers.row(c).to_owned();
2371 let nominal_radius = radii[c];
2372 let region = chart_region(atom, center.clone(), nominal_radius);
2373 if duchon_uncertifiable {
2374 charts.push(CertifiedChart {
2375 region,
2376 lipschitz: f64::INFINITY,
2377 beta_center: f64::INFINITY,
2378 certified_radius: 0.0,
2379 amortized_jacobian: None,
2380 recon_center: Array1::<f64>::zeros(atom.output_dim()),
2381 amortized_base: None,
2382 });
2383 continue;
2384 }
2385 let sups = family_jet_sups(atom, ®ion)?;
2386 let recon_sups = reconstruction_jet_sups(atom, sups);
2387 let lipschitz =
2388 hessian_lipschitz_constant(recon_sups, amplitude_bound, target_norm_bound, 0.0);
2389 // β at the chart center bounds the worst-case in-chart curvature
2390 // (the Gauss-Newton Hessian is continuous; the certified radius is
2391 // solved so the certificate is robust to the start within the ball).
2392 let beta_center = match center_beta(atom, ¢er, config.ridge) {
2393 Some(b) => b,
2394 None => {
2395 // Degenerate center curvature: no certifiable chart here, and
2396 // no amortized Jacobian (the same singular Gauss–Newton block).
2397 charts.push(CertifiedChart {
2398 region,
2399 lipschitz,
2400 beta_center: f64::INFINITY,
2401 certified_radius: 0.0,
2402 amortized_jacobian: None,
2403 recon_center: Array1::<f64>::zeros(atom.output_dim()),
2404 amortized_base: None,
2405 });
2406 continue;
2407 }
2408 };
2409 // Distill the amortized-encoder Jacobian at this center (#1026 ladder
2410 // item 3): the IFT derivative of the encode map, precomputed offline
2411 // so the online encode is one mat-vec. A finite `beta_center` (above)
2412 // means the Gauss–Newton block is non-singular, so this succeeds
2413 // alongside it; the pair travels together on the chart.
2414 let (amortized_jacobian, recon_center) =
2415 match center_amortized_jacobian(atom, ¢er, config.ridge) {
2416 Some((a1, m1)) => (Some(a1), m1),
2417 None => (None, Array1::<f64>::zeros(atom.output_dim())),
2418 };
2419 // Certified radius from h = β·η·L ≤ ½ with η ≤ R (Newton step length
2420 // is bounded by the start distance to the root, itself ≤ chart
2421 // radius at worst): R_c = ½ / (β·L), capped at the nominal radius.
2422 let certified_radius = if lipschitz > 0.0 && beta_center.is_finite() {
2423 (0.5 / (beta_center * lipschitz)).min(region.radius)
2424 } else {
2425 region.radius
2426 };
2427 // Precompute the affine-predictor constant `base = t_c − A₁·m₁` (atom-
2428 // static), so the online encode is a single `base + (1/z)·A₁·x` mat-vec.
2429 let amortized_base = amortized_jacobian
2430 .as_ref()
2431 .map(|a1| ¢er - &a1.dot(&recon_center));
2432 charts.push(CertifiedChart {
2433 region,
2434 lipschitz,
2435 beta_center,
2436 certified_radius,
2437 amortized_jacobian,
2438 recon_center,
2439 amortized_base,
2440 });
2441 }
2442 Ok(AtomEncodeAtlas {
2443 atom_index,
2444 latent_dim: d,
2445 decoder_norm_sum,
2446 charts,
2447 })
2448 }
2449
2450 /// Build the atlas with DATA-DRIVEN chart placement: instead of a dense
2451 /// `resolution^d` product grid (exponential in latent dim `d`, so the regular
2452 /// [`Self::build`] is forced to coarse, poorly-certified charts for `d ≥ 3`),
2453 /// place a bounded number of charts AT the data's own latent coordinates. The
2454 /// chart count is then `O(max_charts)` regardless of `d`, and every chart sits
2455 /// where data actually lands (small in-chart residual → certifies), so
2456 /// higher-dimensional atoms — which reconstruct real activations far better per
2457 /// parameter — become affordable and well-covered.
2458 ///
2459 /// `coords[k]` is atom `k`'s `n × d_k` latent coordinates (the seed coords, or
2460 /// a previous encode's output). Charts are chosen by greedy farthest-point
2461 /// sampling over those coords (deterministic, coverage-maximizing), capped at
2462 /// `max_charts`. Each chart's nominal radius is half the distance to its
2463 /// nearest neighbor center, so the charts tile the local data density. The
2464 /// per-chart Kantorovich certification is IDENTICAL to the regular grid — only
2465 /// the center placement differs.
2466 pub fn build_data_driven(
2467 atoms: &[SaeManifoldAtom],
2468 coords: &[Array2<f64>],
2469 amplitude_bound: &[f64],
2470 target_norm_bound: f64,
2471 max_charts: usize,
2472 config: AtlasConfig,
2473 ) -> Result<Self, String> {
2474 if amplitude_bound.len() != atoms.len() || coords.len() != atoms.len() {
2475 return Err(format!(
2476 "build_data_driven: amplitude_bound {} / coords {} must match atom count {}",
2477 amplitude_bound.len(),
2478 coords.len(),
2479 atoms.len()
2480 ));
2481 }
2482 let mut atom_atlases = Vec::with_capacity(atoms.len());
2483 for (k, atom) in atoms.iter().enumerate() {
2484 let (centers, radii) =
2485 data_driven_chart_centers(atom, coords[k].view(), max_charts.max(1))?;
2486 let atlas = Self::build_atom_atlas_from_centers(
2487 k,
2488 atom,
2489 centers.view(),
2490 &radii,
2491 amplitude_bound[k],
2492 target_norm_bound,
2493 &config,
2494 )?;
2495 atom_atlases.push(atlas);
2496 }
2497 Ok(Self {
2498 atoms: atom_atlases,
2499 config,
2500 })
2501 }
2502
2503 fn refine_certified_encode_start(
2504 &self,
2505 atom: &SaeManifoldAtom,
2506 evaluator: &dyn SaeBasisEvaluator,
2507 chart: &CertifiedChart,
2508 t: Array1<f64>,
2509 x: ArrayView1<'_, f64>,
2510 amplitude: f64,
2511 objective: &EncodeObjective<'_>,
2512 ) -> Result<(Array1<f64>, RowCertificate), String> {
2513 // Certify from the warm start, navigating into the Kantorovich basin first
2514 // if the unit-amplitude start has h > ½ (see `certify_with_basin_warmup`).
2515 // The Lipschitz is the objective's EFFECTIVE bound (F3): the stored
2516 // Euclidean data-term `L` scaled by the metric operator-norm bound plus the
2517 // prior's third-derivative bound. Reduces to `chart.lipschitz` exactly for
2518 // the Euclidean objective, so the metric-free path is unchanged.
2519 let lipschitz = objective.effective_lipschitz(atom, chart.lipschitz);
2520 let Some(probe) = certify_with_basin_warmup(
2521 atom,
2522 evaluator,
2523 t,
2524 x,
2525 amplitude,
2526 lipschitz,
2527 self.config.newton_steps,
2528 chart.region.center.view(),
2529 chart.region.radius,
2530 objective,
2531 )?
2532 else {
2533 return Ok((
2534 Array1::<f64>::zeros(atom.latent_dim()),
2535 uncertified_certificate(chart.lipschitz),
2536 ));
2537 };
2538 // F5: pair the REFINED coordinate with the certificate evaluated AT that
2539 // refined landing point (`final_cert`), not the pre-refinement
2540 // `initial_cert`. Returning `initial_cert` describes a different (earlier)
2541 // iterate's β/η/h — its Kantorovich root-radius overstates the refined
2542 // point's distance to the root.
2543 Ok((probe.coord, probe.final_cert))
2544 }
2545
2546 /// Online certified encode of one target row `x` against one atom `k` with
2547 /// fixed amplitude `z`. Routes to the nearest chart, starts from that chart's
2548 /// distilled IFT warm start, runs `config.newton_steps` Newton steps, and
2549 /// returns the encoded coordinate with its certificate. An uncertified start
2550 /// (no chart, no distilled Jacobian, non-positive amplitude, or `h > ½`)
2551 /// flags the row for the exact multi-start caller.
2552 pub fn certified_encode_row(
2553 &self,
2554 atom: &SaeManifoldAtom,
2555 atom_index: usize,
2556 x: ArrayView1<'_, f64>,
2557 amplitude: f64,
2558 ) -> Result<(Array1<f64>, RowCertificate), String> {
2559 // The bare Euclidean, prior-free objective — bit-identical to the metric-
2560 // free certified encode.
2561 self.certified_encode_row_with_objective(
2562 atom,
2563 atom_index,
2564 x,
2565 amplitude,
2566 &EncodeObjective::euclidean(),
2567 )
2568 }
2569
2570 /// [`Self::certified_encode_row`] against the TRUE encode objective (F3): the
2571 /// Newton–Kantorovich certificate is computed under the fit's per-row output
2572 /// metric and latent coordinate prior ([`EncodeObjective`]), so the certified
2573 /// root is the minimizer of the SAME generalized-least-squares-plus-prior
2574 /// functional the fit used — not a bare Euclidean stand-in that certifies a
2575 /// different problem. The metric operator-norm bound scales the offline chart
2576 /// Lipschitz; the per-row metric and prior enter `β, η` and the candidate-
2577 /// ranking SSE guard online. `EncodeObjective::euclidean()` reproduces the
2578 /// metric-free path exactly.
2579 pub fn certified_encode_row_with_objective(
2580 &self,
2581 atom: &SaeManifoldAtom,
2582 atom_index: usize,
2583 x: ArrayView1<'_, f64>,
2584 amplitude: f64,
2585 objective: &EncodeObjective<'_>,
2586 ) -> Result<(Array1<f64>, RowCertificate), String> {
2587 let atom_atlas = self
2588 .atoms
2589 .get(atom_index)
2590 .ok_or_else(|| format!("certified_encode_row: atom {atom_index} not in atlas"))?;
2591 let d = atom.latent_dim();
2592 // A per-row metric factor `U` must be `p × rank` (`M = U Uᵀ` acts on the
2593 // p-dim output). A shape mismatch is a caller bug — surface it rather than
2594 // silently certifying a wrong (or panicking) whitening.
2595 if let Some(u) = objective.metric_factor {
2596 if u.nrows() != atom.output_dim() {
2597 return Err(format!(
2598 "certified_encode_row_with_objective: metric factor has {} rows but atom output_dim is {}",
2599 u.nrows(),
2600 atom.output_dim()
2601 ));
2602 }
2603 }
2604 // A missing basis evaluator means the amortized/cold predictor cannot fire
2605 // for this atom (e.g. a frozen-baseline or first-build atom that never
2606 // attached a distilled evaluator). That is exactly the "cannot certify"
2607 // state — flag the row uncertified (zeros coords, ∞ certificate) so the
2608 // upstream exact multi-start solve owns it, never a hard error that aborts
2609 // the whole criterion. Mirrors the no-chart / singular-Jacobian branches.
2610 let Some(evaluator) = atom.basis_evaluator.as_ref().cloned() else {
2611 return Ok((
2612 Array1::<f64>::zeros(d),
2613 RowCertificate {
2614 beta: f64::INFINITY,
2615 eta: f64::INFINITY,
2616 lipschitz: f64::INFINITY,
2617 h: f64::INFINITY,
2618 },
2619 ));
2620 };
2621
2622 // Route to the nearest chart centers by AMBIENT reconstruction distance.
2623 // A single nearest chart is NOT globally sound on self-approaching atoms:
2624 // where the decoded manifold folds near itself (two distant latent points
2625 // map near the same output), the nearest-center chart can certify into the
2626 // locally-worse basin while another chart holds the GLOBAL minimum (both
2627 // branches' charts reconstruct near the crossing, so both are near in
2628 // ambient distance). The certificate is honest about LOCAL convergence but
2629 // cannot see the better far basin. So we refine in the top-K nearest charts
2630 // and keep the lowest-reconstruction-error CERTIFIED result. For a unimodal
2631 // atom every candidate chart converges to the same root, so this is a no-op
2632 // (first-wins tie → the nearest chart), preserving the existing behavior.
2633 let candidates = nearest_charts_topk(atom_atlas, x, amplitude, CERTIFIED_ROUTING_TOPK);
2634 if candidates.is_empty() {
2635 return Ok((
2636 Array1::<f64>::zeros(d),
2637 RowCertificate {
2638 beta: f64::INFINITY,
2639 eta: f64::INFINITY,
2640 lipschitz: f64::INFINITY,
2641 h: f64::INFINITY,
2642 },
2643 ));
2644 }
2645 // Best CERTIFIED result by reconstruction error, plus the nearest chart's
2646 // result as the uncertified fallback (preserving the prior return when no
2647 // candidate certifies — the nearest chart owns the flagged row).
2648 let mut best: Option<(Array1<f64>, RowCertificate, f64)> = None;
2649 let mut nearest_fallback: Option<(Array1<f64>, RowCertificate)> = None;
2650 for chart_idx in candidates {
2651 let chart = &atom_atlas.charts[chart_idx];
2652 let Some(t) = amortized_warm_start(chart, x, amplitude) else {
2653 if nearest_fallback.is_none() {
2654 nearest_fallback = Some((
2655 Array1::<f64>::zeros(d),
2656 uncertified_certificate(chart.lipschitz),
2657 ));
2658 }
2659 continue;
2660 };
2661 let (coord, cert) = self.refine_certified_encode_start(
2662 atom,
2663 evaluator.as_ref(),
2664 chart,
2665 t,
2666 x,
2667 amplitude,
2668 objective,
2669 )?;
2670 if nearest_fallback.is_none() {
2671 nearest_fallback = Some((coord.clone(), cert.clone()));
2672 }
2673 if cert.certified() {
2674 let err = encode_reconstruction_error_core(
2675 atom,
2676 evaluator.as_ref(),
2677 coord.view(),
2678 x,
2679 amplitude,
2680 objective,
2681 );
2682 if best.as_ref().map(|(_, _, e)| err < *e).unwrap_or(true) {
2683 best = Some((coord, cert, err));
2684 }
2685 // Global-minimum short-circuit: reconstruction error ≥ 0, so a
2686 // certified candidate already at the ambient noise floor is provably
2687 // the global optimum over the remaining charts — stop refining them.
2688 if let Some((_, _, e)) = best.as_ref() {
2689 if *e <= CERTIFIED_GLOBAL_MIN_RECON_FLOOR * (1.0 + x.dot(&x).sqrt()) {
2690 break;
2691 }
2692 }
2693 }
2694 }
2695 match best {
2696 Some((coord, cert, _)) => Ok((coord, cert)),
2697 None => Ok(nearest_fallback.unwrap_or_else(|| {
2698 (
2699 Array1::<f64>::zeros(d),
2700 RowCertificate {
2701 beta: f64::INFINITY,
2702 eta: f64::INFINITY,
2703 lipschitz: f64::INFINITY,
2704 h: f64::INFINITY,
2705 },
2706 )
2707 })),
2708 }
2709 }
2710
2711 /// Amortized (distilled) encode of one target row `x` against one atom `k`
2712 /// with fixed amplitude `z` (#1026 ladder item 3).
2713 ///
2714 /// Routes to the nearest chart, then predicts the latent coordinate in CLOSED
2715 /// FORM from that chart's precomputed implicit-function-theorem Jacobian:
2716 ///
2717 /// ```text
2718 /// t̂ = t_c + (1/z) · A₁ · (x − z · m₁(t_c)),
2719 /// ```
2720 ///
2721 /// a single `O(d·p)` mat-vec — no per-row Hessian factorization or
2722 /// eigendecomposition, which is the amortization. The Kantorovich
2723 /// certificate is then evaluated AT the predicted start `t̂` with the chart's
2724 /// closed-form Lipschitz constant. A prediction is accepted only when that
2725 /// certificate holds, an independent cold chart-center probe also certifies,
2726 /// and the two refined coordinates agree within the two probes' final
2727 /// Kantorovich root-radius bounds. This keeps the distilled path honest
2728 /// without letting the exact probe reuse the distilled warm start it is
2729 /// auditing. A chart without a distilled Jacobian (singular Gauss–Newton
2730 /// block) flags the row.
2731 pub fn amortized_encode_row(
2732 &self,
2733 atom: &SaeManifoldAtom,
2734 atom_index: usize,
2735 x: ArrayView1<'_, f64>,
2736 amplitude: f64,
2737 ) -> Result<(Array1<f64>, RowCertificate), String> {
2738 // Euclidean, prior-free objective — bit-identical to the metric-free path.
2739 self.amortized_encode_row_with_objective(
2740 atom,
2741 atom_index,
2742 x,
2743 amplitude,
2744 &EncodeObjective::euclidean(),
2745 )
2746 }
2747
2748 /// [`Self::amortized_encode_row`] against the TRUE encode objective (F3): the
2749 /// distilled predictor's warm start is Euclidean (its `A₁` is the Euclidean
2750 /// Gauss–Newton block), but BOTH Kantorovich probes certify under the supplied
2751 /// metric + prior objective, with the chart Lipschitz taken as the objective's
2752 /// effective bound. So the fast path is preserved (one mat-vec warm start) while
2753 /// the certificate — and therefore the trust/fallback decision — is honest about
2754 /// the metric-and-prior objective the fit optimized. `EncodeObjective::euclidean`
2755 /// reproduces the metric-free distilled path exactly.
2756 pub fn amortized_encode_row_with_objective(
2757 &self,
2758 atom: &SaeManifoldAtom,
2759 atom_index: usize,
2760 x: ArrayView1<'_, f64>,
2761 amplitude: f64,
2762 objective: &EncodeObjective<'_>,
2763 ) -> Result<(Array1<f64>, RowCertificate), String> {
2764 let atom_atlas = self
2765 .atoms
2766 .get(atom_index)
2767 .ok_or_else(|| format!("amortized_encode_row: atom {atom_index} not in atlas"))?;
2768 let d = atom.latent_dim();
2769 let uncertified = || {
2770 (
2771 Array1::<f64>::zeros(d),
2772 RowCertificate {
2773 beta: f64::INFINITY,
2774 eta: f64::INFINITY,
2775 lipschitz: f64::INFINITY,
2776 h: f64::INFINITY,
2777 },
2778 )
2779 };
2780 // A missing basis evaluator means the distilled predictor cannot fire for
2781 // this atom — flag the row uncertified (the exact upstream solve owns it)
2782 // rather than erroring, exactly as the no-chart / singular-Jacobian /
2783 // non-positive-amplitude branches below do. Never a silent wrong encode,
2784 // never a hard abort of the criterion.
2785 let Some(evaluator) = atom.basis_evaluator.as_ref().cloned() else {
2786 return Ok(uncertified());
2787 };
2788 let Some((chart_idx, _)) = nearest_chart(atom_atlas, x, amplitude) else {
2789 return Ok(uncertified());
2790 };
2791 let chart = &atom_atlas.charts[chart_idx];
2792 // Closed-form predicted start t̂ = t_c + (1/z)·A₁·(x − z·m₁). `None` when
2793 // the chart's Gauss–Newton block was singular (no distilled Jacobian, so
2794 // the amortized predictor cannot fire) or the amplitude is not strictly
2795 // positive and finite (a near-inactive atom, where the amplitude-divided
2796 // map is undefined) — either way flag for the exact fallback, never a
2797 // silent wrong encode.
2798 let Some(t_hat) = amortized_warm_start(chart, x, amplitude) else {
2799 return Ok(uncertified());
2800 };
2801 // Effective Kantorovich Lipschitz for the TRUE objective (F3): the stored
2802 // Euclidean data-term bound scaled by the metric operator-norm bound plus
2803 // the prior's third-derivative bound. Reduces to `chart.lipschitz` exactly
2804 // for `EncodeObjective::euclidean`, so the metric-free distilled path is
2805 // unchanged.
2806 let lipschitz = objective.effective_lipschitz(atom, chart.lipschitz);
2807 // Evaluate the SAME Kantorovich certificate at the predicted start. The
2808 // amortized prediction is trusted only if this certificate holds AND an
2809 // independent cold chart-center probe certifies and agrees below the
2810 // two probes' final Kantorovich root-radius bounds. This avoids the
2811 // self-referential gate where the "exact" probe is warm-started by the
2812 // same distilled prediction it is supposed to audit.
2813 let Some(amortized_probe) = certify_with_basin_warmup(
2814 atom,
2815 evaluator.as_ref(),
2816 t_hat,
2817 x,
2818 amplitude,
2819 lipschitz,
2820 self.config.newton_steps,
2821 chart.region.center.view(),
2822 chart.region.radius,
2823 objective,
2824 )?
2825 else {
2826 return Ok((Array1::<f64>::zeros(d), uncertified_certificate(lipschitz)));
2827 };
2828
2829 let cold_start = chart.region.center.clone();
2830 let Some(cold_probe) = certify_with_basin_warmup(
2831 atom,
2832 evaluator.as_ref(),
2833 cold_start,
2834 x,
2835 amplitude,
2836 lipschitz,
2837 self.config.newton_steps,
2838 chart.region.center.view(),
2839 chart.region.radius,
2840 objective,
2841 )?
2842 else {
2843 return Ok((amortized_probe.coord, uncertified_certificate(lipschitz)));
2844 };
2845
2846 let gap =
2847 latent_coordinate_distance(atom, amortized_probe.coord.view(), cold_probe.coord.view());
2848 let tolerance = distilled_probe_tolerance(&amortized_probe, &cold_probe, amplitude, x);
2849 if !(gap.is_finite() && gap <= tolerance) {
2850 return Ok((amortized_probe.coord, uncertified_certificate(lipschitz)));
2851 }
2852 // F5: return the certificate at the refined landing coordinate
2853 // (`final_cert`), consistent with the coord actually returned and with the
2854 // `distilled_probe_tolerance` gate above (which already reads `final_cert`
2855 // via `kantorovich_root_radius`). `initial_cert` is a stale earlier iterate.
2856 Ok((amortized_probe.coord, amortized_probe.final_cert))
2857 }
2858
2859 /// Batched amortized (distilled) encode over many rows against one atom
2860 /// (#1026 ladder item 3, corpus-rate). Each row uses the closed-form
2861 /// per-chart Jacobian predictor and carries its own Kantorovich certificate;
2862 /// uncertified rows are flagged in [`EncodeResult::encode_uncertified_count`]
2863 /// for the exact multi-start fallback. Row-independent against the frozen
2864 /// dictionary, so the batch fans out over rows (deterministic row-order
2865 /// assembly, bit-identical run-to-run), staying sequential inside a rayon
2866 /// worker to avoid nested oversubscription.
2867 pub fn amortized_encode_batch(
2868 &self,
2869 atom: &SaeManifoldAtom,
2870 atom_index: usize,
2871 targets: ArrayView2<'_, f64>,
2872 amplitudes: ArrayView1<'_, f64>,
2873 ) -> Result<EncodeResult, String> {
2874 let n = targets.nrows();
2875 if amplitudes.len() != n {
2876 return Err(format!(
2877 "amortized_encode_batch: amplitudes len {} != rows {n}",
2878 amplitudes.len()
2879 ));
2880 }
2881 let d = atom.latent_dim();
2882 let encode_rows =
2883 |range: std::ops::Range<usize>| -> Result<Vec<(Array1<f64>, bool)>, String> {
2884 range
2885 .map(|row| {
2886 let (t, cert) = self.amortized_encode_row(
2887 atom,
2888 atom_index,
2889 targets.row(row),
2890 amplitudes[row],
2891 )?;
2892 Ok((t, cert.certified()))
2893 })
2894 .collect()
2895 };
2896 let rows: Vec<(Array1<f64>, bool)> =
2897 if n >= ENCODE_BATCH_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
2898 use rayon::prelude::*;
2899 const CHUNK: usize = 256;
2900 let n_chunks = n.div_ceil(CHUNK);
2901 let chunked: Vec<Vec<(Array1<f64>, bool)>> = (0..n_chunks)
2902 .into_par_iter()
2903 .map(|c| {
2904 let start = c * CHUNK;
2905 let end = (start + CHUNK).min(n);
2906 encode_rows(start..end)
2907 })
2908 .collect::<Result<_, _>>()?;
2909 chunked.into_iter().flatten().collect()
2910 } else {
2911 encode_rows(0..n)?
2912 };
2913 let mut coords = Array2::<f64>::zeros((n, d));
2914 let mut certified = Vec::with_capacity(n);
2915 for (row, (t, cert)) in rows.into_iter().enumerate() {
2916 coords.row_mut(row).assign(&t);
2917 certified.push(cert);
2918 }
2919 Ok(EncodeResult::from_rows(coords, certified))
2920 }
2921
2922 /// Encode one atom's rows through the full three-tier fallback cascade and
2923 /// report the cost breakdown ([`FallbackTelemetry`], reviewer condition #3).
2924 ///
2925 /// Each row is tried cheapest-first: the amortized one-mat-vec predictor,
2926 /// then (if uncertified) the certified IFT-warm-start Newton encode, then (if
2927 /// still uncertified) it is counted for the exact multi-start solve. The
2928 /// returned coords carry the best CERTIFIED encode reached; a multi-start row
2929 /// keeps the Newton iterate as its (uncertified) coordinate so the caller can
2930 /// still decode it, exactly as [`super::SaeManifoldTerm::amortized_encode_target`]
2931 /// does — the honesty flag rides `certified`.
2932 ///
2933 /// This is the instrumented analogue of [`Self::amortized_encode_batch`] +
2934 /// the per-row Newton rescue: it does the SAME work, and additionally counts
2935 /// which tier certified each encode so the multi-start-fallback fraction (the
2936 /// encode-tax multiplier) is measurable.
2937 pub fn encode_atom_with_fallback_telemetry(
2938 &self,
2939 atom: &SaeManifoldAtom,
2940 atom_index: usize,
2941 targets: ArrayView2<'_, f64>,
2942 amplitudes: ArrayView1<'_, f64>,
2943 ) -> Result<(EncodeResult, FallbackTelemetry), String> {
2944 let n = targets.nrows();
2945 if amplitudes.len() != n {
2946 return Err(format!(
2947 "encode_atom_with_fallback_telemetry: amplitudes len {} != rows {n}",
2948 amplitudes.len()
2949 ));
2950 }
2951 let amortized = self.amortized_encode_batch(atom, atom_index, targets, amplitudes)?;
2952 let mut coords = amortized.coords;
2953 let mut certified = amortized.certified;
2954 let mut telemetry = FallbackTelemetry {
2955 n_rows: n,
2956 n_atoms: 1,
2957 ..FallbackTelemetry::default()
2958 };
2959 for row in 0..n {
2960 if certified[row] {
2961 telemetry.amortized_certified += 1;
2962 continue;
2963 }
2964 // The amortized predictor missed: try the certified Newton warm-start.
2965 let (t, cert) =
2966 self.certified_encode_row(atom, atom_index, targets.row(row), amplitudes[row])?;
2967 // Keep the Newton iterate regardless (it is a better start than the
2968 // amortized one even when uncertified, and a multi-start row still
2969 // needs a decodable coordinate).
2970 coords.row_mut(row).assign(&t);
2971 if cert.certified() {
2972 certified[row] = true;
2973 telemetry.newton_rescued += 1;
2974 } else {
2975 telemetry.multistart_fallback += 1;
2976 }
2977 }
2978 Ok((EncodeResult::from_rows(coords, certified), telemetry))
2979 }
2980
2981 /// Batched certified encode over many rows against one atom (the #988
2982 /// throughput consumer). Each row carries its own certificate; uncertified
2983 /// rows are flagged in [`EncodeResult::encode_uncertified_count`] for the
2984 /// exact multi-start fallback.
2985 pub fn certified_encode_batch(
2986 &self,
2987 atom: &SaeManifoldAtom,
2988 atom_index: usize,
2989 targets: ArrayView2<'_, f64>,
2990 amplitudes: ArrayView1<'_, f64>,
2991 ) -> Result<EncodeResult, String> {
2992 let n = targets.nrows();
2993 if amplitudes.len() != n {
2994 return Err(format!(
2995 "certified_encode_batch: amplitudes len {} != rows {n}",
2996 amplitudes.len()
2997 ));
2998 }
2999 let d = atom.latent_dim();
3000 // Per-row encode is independent against a frozen dictionary (#1010), so
3001 // the corpus-rate batch fans out over rows (#1026 amortized-encoder leg /
3002 // #977 Stage-3 corpus encode). Each row produces an owned `(t, certified)`
3003 // pair; results are assembled back in row order so the output is
3004 // bit-identical run-to-run regardless of thread scheduling. Stay
3005 // sequential inside a rayon worker (e.g. when an outer atom-level fan-out
3006 // owns the pool) to avoid nested oversubscription. The first row that
3007 // fails to encode propagates its error deterministically.
3008 let encode_rows =
3009 |range: std::ops::Range<usize>| -> Result<Vec<(Array1<f64>, bool)>, String> {
3010 range
3011 .map(|row| {
3012 let (t, cert) = self.certified_encode_row(
3013 atom,
3014 atom_index,
3015 targets.row(row),
3016 amplitudes[row],
3017 )?;
3018 Ok((t, cert.certified()))
3019 })
3020 .collect()
3021 };
3022 let rows: Vec<(Array1<f64>, bool)> =
3023 if n >= ENCODE_BATCH_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
3024 use rayon::prelude::*;
3025 const CHUNK: usize = 256;
3026 let n_chunks = n.div_ceil(CHUNK);
3027 let chunked: Vec<Vec<(Array1<f64>, bool)>> = (0..n_chunks)
3028 .into_par_iter()
3029 .map(|c| {
3030 let start = c * CHUNK;
3031 let end = (start + CHUNK).min(n);
3032 encode_rows(start..end)
3033 })
3034 .collect::<Result<_, _>>()?;
3035 chunked.into_iter().flatten().collect()
3036 } else {
3037 encode_rows(0..n)?
3038 };
3039 let mut coords = Array2::<f64>::zeros((n, d));
3040 let mut certified = Vec::with_capacity(n);
3041 for (row, (t, cert)) in rows.into_iter().enumerate() {
3042 coords.row_mut(row).assign(&t);
3043 certified.push(cert);
3044 }
3045 Ok(EncodeResult::from_rows(coords, certified))
3046 }
3047
3048 /// Batched GEMM "fast" amortized encode — the traditional-encoder forward
3049 /// pass, WITH manifolds. For every row this applies the SAME closed-form
3050 /// affine predictor as [`amortized_warm_start`]
3051 /// (`t̂ = t_c + (1/z)·A₁·(x − z·m₁)`), but routed and applied as batched
3052 /// matrix products instead of a per-row loop wrapped in the Kantorovich
3053 /// certificate + basin warmup. NO per-row certificate is taken: this is the
3054 /// speed mode (the certified `*_encode_*` paths remain the accuracy mode).
3055 ///
3056 /// Cost is GEMM-bound: one `(n × p)·(p × d)` decode-distance product for
3057 /// nearest-chart routing (skipped for single-chart atoms) plus, per chart,
3058 /// one `(n_c × p)·(p × d)` predictor product — i.e. `≈ X·Wᵀ`, exactly a
3059 /// dense SAE encoder's forward map.
3060 ///
3061 /// Degenerate rows are handled exactly as `amortized_warm_start` flags them
3062 /// (returns `None` ⇒ zeroed coord here): a missing basis evaluator, a chart
3063 /// whose Gauss–Newton block was singular (`amortized_jacobian == None`), or a
3064 /// non-finite / non-positive amplitude. Those rows are zeroed (never a panic,
3065 /// never a silent wrong encode), and their indices are returned in the
3066 /// `valid` mask so the caller can route them to the exact path if desired.
3067 ///
3068 /// Returns `(coords, valid)` where `coords` is `n × d` and `valid[row]` is
3069 /// `true` iff the amortized predictor fired for that row.
3070 pub fn amortized_encode_batch_fast(
3071 &self,
3072 atom: &SaeManifoldAtom,
3073 atom_index: usize,
3074 x: ArrayView2<'_, f64>,
3075 amplitudes: ArrayView1<'_, f64>,
3076 ) -> Result<(Array2<f64>, Vec<bool>), String> {
3077 let n = x.nrows();
3078 let p = atom.output_dim();
3079 let d = atom.latent_dim();
3080 if x.ncols() != p {
3081 return Err(format!(
3082 "amortized_encode_batch_fast: x has {} cols but atom output dim is {p}",
3083 x.ncols()
3084 ));
3085 }
3086 if amplitudes.len() != n {
3087 return Err(format!(
3088 "amortized_encode_batch_fast: amplitudes len {} != rows {n}",
3089 amplitudes.len()
3090 ));
3091 }
3092 let atom_atlas = self.atoms.get(atom_index).ok_or_else(|| {
3093 format!("amortized_encode_batch_fast: atom {atom_index} not in atlas")
3094 })?;
3095 let mut coords = Array2::<f64>::zeros((n, d));
3096 let mut valid = vec![false; n];
3097
3098 // A missing basis evaluator means this atom never had a well-formed atlas
3099 // built here — treat every row as uncertified (zeroed), exactly like the
3100 // per-row `amortized_encode_row` no-evaluator branch. (The predictor below
3101 // uses only cached atlas data, so no evaluator call is made online.)
3102 if atom.basis_evaluator.is_none() {
3103 return Ok((coords, valid));
3104 }
3105
3106 // ── Routing recon-centers (cached, no online basis evaluation). ────────
3107 // Routing sends a row to the chart whose center reconstruction
3108 // `m(t_c) = BᵀΦ(t_c)` is closest in ‖·‖². Those center reconstructions are
3109 // OFFLINE-cached in `chart.recon_center` (bit-identical to re-evaluating the
3110 // basis at the fixed centers — same φ·decoder accumulation). Gather the cache
3111 // instead of calling `evaluator.evaluate` on every invocation: that per-call
3112 // chart-center evaluation was the dominant per-atom-group overhead at massive
3113 // K, where N rows scatter across many atoms into tiny groups so a fixed
3114 // per-call cost is amortized over only a handful of rows. This is what keeps
3115 // the fast index-routed encode near-flat as K grows.
3116 let valid_charts: Vec<usize> = (0..atom_atlas.charts.len())
3117 .filter(|&c| atom_atlas.charts[c].certified_radius > 0.0)
3118 .collect();
3119 if valid_charts.is_empty() {
3120 return Ok((coords, valid));
3121 }
3122 // recon_centers (C × p): the cached m(t_c) for each certifiable chart.
3123 let mut recon_centers = Array2::<f64>::zeros((valid_charts.len(), p));
3124 for (ci, &c) in valid_charts.iter().enumerate() {
3125 recon_centers
3126 .row_mut(ci)
3127 .assign(&atom_atlas.charts[c].recon_center);
3128 }
3129 // Per-chart routing key: route_idx[row] = argmin_c ‖x_row − z_row·r_c‖²
3130 // (F1: the TRUE objective at the row's amplitude `z_row`, where `r_c` is
3131 // the amplitude-1 center reconstruction). First chart wins on a tie (strict
3132 // `<`), matching `nearest_chart`. A non-finite amplitude routes to chart 0
3133 // (moot — the predictor below skips the row anyway).
3134 //
3135 // Score the DIRECT squared distance `Σ_j (z·r_cj − x_j)²`, accumulated in
3136 // the same element order as the per-row `nearest_chart`, NOT the algebraic
3137 // expansion `z²‖r_c‖² − 2z·(x·r_c)`. The two are equal in exact arithmetic,
3138 // but the expansion subtracts two O(‖x‖²) quantities and loses ~‖x‖²·ε of
3139 // precision to cancellation — enough to FLIP the argmin between two charts
3140 // whose reconstructions coincide to rounding (e.g. period-wrapped torus-seam
3141 // charts, whose latent centers differ by a full period yet reconstruct to
3142 // within 1e-14). On such a near-tie the expansion and `nearest_chart`
3143 // disagree, and the two seam charts predict coords a full period apart — so
3144 // the batched fast-encode would diverge from the per-row warm-start it must
3145 // reproduce bit-for-bit (the `fast_encode_matches_per_row_warm_start`
3146 // contract). Computing the same direct distance in the same order keeps this
3147 // path byte-identical to `nearest_chart`, so routing (and the tie-break)
3148 // agrees on every row. `recon_centers` is still the cached offline
3149 // `m₁(t_c)` — no basis re-evaluation, which was the fast path's real cost.
3150 let route_idx: Vec<usize> = if valid_charts.len() == 1 {
3151 vec![0usize; n]
3152 } else {
3153 (0..n)
3154 .map(|row| {
3155 let z = amplitudes[row];
3156 // F4 — ACTIVITY GATE BEFORE ROUTING: an inactive (z = 0) or
3157 // non-finite-amplitude row is skipped by the predictor loop
3158 // below (`amp.abs() > 0.0`), so its chart never matters. Routing
3159 // it anyway is the dense `O(n·K·C·p)` waste this path incurs at
3160 // massive K, where each atom is active on only a sparse handful of
3161 // the N rows yet the routing scan still touches every (row, chart)
3162 // pair. Gate the amplitude here so the `C·p` distance scan runs
3163 // only for the atom's genuinely-active rows; the chart-0 sentinel
3164 // is moot for the gated rows (they are dropped downstream).
3165 if !(z.is_finite() && z.abs() > 0.0) {
3166 return 0usize;
3167 }
3168 let x_row = x.row(row);
3169 let mut best_c = 0usize;
3170 let mut best_d = f64::INFINITY;
3171 for c in 0..valid_charts.len() {
3172 // Shared F1 metric — allocation-free (no per-row/chart Vec),
3173 // so the massive-K fast route stays near-flat as K grows.
3174 let dist = amplitude_scaled_center_dist(recon_centers.row(c), x_row, z);
3175 if dist < best_d {
3176 best_d = dist;
3177 best_c = c;
3178 }
3179 }
3180 best_c
3181 })
3182 .collect()
3183 };
3184
3185 // ── Per-chart batched affine predictor. ───────────────────────────────
3186 // For rows routed to chart `c` with finite jacobian `A₁` (d × p) and
3187 // center reconstruction `m₁` (= `chart.recon_center`), the predictor is
3188 // t̂ = t_c − A₁·m₁ + (1/z)·(A₁·x).
3189 // `t_c − A₁·m₁` is a per-chart constant `base`; `A₁·x` is a d-vector of
3190 // per-row dot products. Instead of gathering routed rows into a fresh
3191 // `X_c` (n_c × p) buffer and running a GEMM into a second `U` (n_c × d)
3192 // buffer — two allocations plus a full copy of the routed rows, per chart —
3193 // fuse the gather straight into the multiply: stream each source row of `x`
3194 // once (it is contiguous) and dot it against `A₁`'s rows, writing the
3195 // predicted coord directly. Zero per-chart heap traffic; the inverse
3196 // amplitude is hoisted to one reciprocal per row.
3197 //
3198 // Precompute each valid chart's `(A₁, base)` once (charts with a singular
3199 // Gauss–Newton block carry no `A₁`, so their routed rows stay
3200 // zeroed/uncertified — same as `amortized_warm_start` returning `None`).
3201 struct ChartPredictor<'a> {
3202 a1: &'a Array2<f64>,
3203 base: &'a Array1<f64>,
3204 }
3205 let predictors: Vec<Option<ChartPredictor<'_>>> = valid_charts
3206 .iter()
3207 .map(|&c| {
3208 let chart = &atom_atlas.charts[c];
3209 // `base = t_c − A₁·m₁` is precomputed offline in the atlas; reuse it
3210 // (both are `Some` together — singular G-N block ⇒ both `None`).
3211 match (
3212 chart.amortized_jacobian.as_ref(),
3213 chart.amortized_base.as_ref(),
3214 ) {
3215 (Some(a1), Some(base)) => Some(ChartPredictor { a1, base }),
3216 _ => None,
3217 }
3218 })
3219 .collect();
3220
3221 for row in 0..n {
3222 let Some(pred) = predictors[route_idx[row]].as_ref() else {
3223 continue;
3224 };
3225 let amp = amplitudes[row];
3226 if !(amp.is_finite() && amp.abs() > 0.0) {
3227 continue;
3228 }
3229 let inv_z = 1.0 / amp;
3230 let x_row = x.row(row);
3231 let mut coord_row = coords.row_mut(row);
3232 for axis in 0..d {
3233 // (A₁·x)[axis] = A₁ row `axis` (contiguous, length p) · x_row.
3234 coord_row[axis] = pred.base[axis] + pred.a1.row(axis).dot(&x_row) * inv_z;
3235 }
3236 valid[row] = true;
3237 }
3238 Ok((coords, valid))
3239 }
3240
3241 /// Fast batched FULL forward pass against one atom: encode → decode, the
3242 /// manifold analogue of a traditional SAE's `x̂ = z·D` (decoder `D`, code `z`).
3243 ///
3244 /// A traditional SAE decodes with one GEMM. The manifold SAE's reconstruction
3245 /// is `m(t̂) = z·Φ(t̂)·B` (module header) — the SAME GEMM `Φ·B`, but the code
3246 /// `Φ(t̂)` is the curved chart basis evaluated at the encoded latent coordinate
3247 /// rather than a flat one-hot. So the fast forward is exactly:
3248 /// 1. [`amortized_encode_batch_fast`] → per-row latent coords `t̂` (one
3249 /// routing GEMM + one affine GEMM per chart — a traditional `W·x+b`);
3250 /// 2. ONE batched basis evaluation `Φ(t̂)` (the manifold-curvature step a
3251 /// flat SAE doesn't have — `n×m`);
3252 /// 3. ONE GEMM `recon = Φ(t̂)·B` (`(n×m)·(m×p)` — a traditional decoder
3253 /// `z·D`), then the per-row amplitude scale `z`.
3254 ///
3255 /// Rows the encoder could not certify-predict (no evaluator / singular
3256 /// Gauss–Newton block / non-finite-or-zero amplitude) are returned as a ZERO
3257 /// reconstruction and flagged `false` in the valid-mask — never a silent wrong
3258 /// decode. The reconstruction of a valid row equals, bit-for-bit up to GEMM
3259 /// reassociation, `z·(Φ(t̂_row)·B)` with `t̂` from the per-row predictor.
3260 pub fn amortized_reconstruct_batch_fast(
3261 &self,
3262 atom: &SaeManifoldAtom,
3263 atom_index: usize,
3264 x: ArrayView2<'_, f64>,
3265 amplitudes: ArrayView1<'_, f64>,
3266 ) -> Result<(Array2<f64>, Vec<bool>), String> {
3267 let n = x.nrows();
3268 let p = atom.output_dim();
3269 // Step 1: batched encode → latent coords (reuses the fast routing+affine).
3270 let (coords, valid) = self.amortized_encode_batch_fast(atom, atom_index, x, amplitudes)?;
3271 let mut recon = Array2::<f64>::zeros((n, p));
3272 // A missing evaluator means no row could encode — every row is zeroed and
3273 // already flagged `false` by the encode; nothing to decode.
3274 let Some(evaluator) = atom.basis_evaluator.as_ref().cloned() else {
3275 return Ok((recon, valid));
3276 };
3277 // Step 2: ONE batched basis evaluation Φ(t̂) over all rows (n × m). Invalid
3278 // rows carry coords = 0 (the chart-origin); we still evaluate them in the
3279 // batch for a single GEMM, then zero their reconstruction below — the basis
3280 // is finite at the origin so this cannot poison the valid rows' GEMM.
3281 let (phi, _jet) = evaluator
3282 .evaluate(coords.view())
3283 .map_err(|err| format!("amortized_reconstruct_batch_fast: basis eval: {err}"))?;
3284 // Step 3: ONE GEMM recon = Φ·B (n × p), then per-row amplitude scale z.
3285 // m(t̂) = z·Φ(t̂)·B, matching the module header and `fill_decoded_row`'s
3286 // `Φ·decoder` accumulation (the amplitude is applied once here).
3287 let decoded = phi.dot(&atom.decoder_coefficients); // (n × p), amplitude-1
3288 for row in 0..n {
3289 if !valid[row] {
3290 continue; // stays zeroed — uncertified, like warm_start `None`.
3291 }
3292 let z = amplitudes[row];
3293 for col in 0..p {
3294 recon[[row, col]] = z * decoded[[row, col]];
3295 }
3296 }
3297 Ok((recon, valid))
3298 }
3299
3300 /// LSH-routed certified encode (issue #1010 step 2 + 3): for each target
3301 /// row, the existing [`SaeCandidateIndex`] (#985/#994) proposes the
3302 /// best-aligned atom by frame alignment to the row direction; the row is then
3303 /// encoded against THAT atom's certified chart atlas. This is the production
3304 /// routing path. Atom selection is EXACT (#1777): [`SaeCandidateIndex::route_exact`]
3305 /// returns the global argmax of the routing score (the universal-bound LSH fast
3306 /// path, else a full-scan fallback) — never a silently-missed ungathered atom —
3307 /// and the atlas does the in-atom nearest-chart routing and the per-row
3308 /// Kantorovich certificate.
3309 ///
3310 /// `atoms[id]` must be aligned with the atlas's `atoms[id]` (same dictionary
3311 /// order the atlas was built from and the sketch/index were built over).
3312 /// A row over an empty dictionary, or whose globally-best atom aligns below the
3313 /// fit-quality floor, is flagged uncertified — it routes to the exact
3314 /// multi-start fallback, never a silent wrong encode.
3315 pub fn certified_encode_with_index<S: AtomFrameSketch + Sync>(
3316 &self,
3317 atoms: &[SaeManifoldAtom],
3318 index: &SaeCandidateIndex,
3319 sketch: &S,
3320 targets: ArrayView2<'_, f64>,
3321 amplitudes: ArrayView1<'_, f64>,
3322 latent_dim: usize,
3323 ) -> Result<EncodeResult, String> {
3324 let n = targets.nrows();
3325 if amplitudes.len() != n {
3326 return Err(format!(
3327 "certified_encode_with_index: amplitudes len {} != rows {n}",
3328 amplitudes.len()
3329 ));
3330 }
3331 let budget = auto_candidate_budget(atoms.len().max(1));
3332 // LSH-routed per-row encode is independent across rows (sublinear atom
3333 // selection + frozen-dictionary in-atom Newton), so the corpus-rate batch
3334 // fans out over rows (#1026 amortized-encoder/routing leg / #977 Stage-3).
3335 // `None` coords (no LSH candidate) carry through as a zeroed row flagged
3336 // uncertified — identical to the sequential semantics. Results assemble
3337 // back in row order (bit-identical run-to-run); the first encode error
3338 // propagates deterministically. Stay sequential inside a rayon worker to
3339 // avoid nested oversubscription.
3340 let encode_rows =
3341 |range: std::ops::Range<usize>| -> Result<Vec<Option<(Array1<f64>, bool)>>, String> {
3342 range
3343 .map(|row| {
3344 // EXACT routing (#1777): pick the GLOBAL argmax of the
3345 // routing score over the whole dictionary, not merely the
3346 // best LSH-gathered candidate. `route_exact` certifies the
3347 // sublinear gather against the universal `[0,1]` alignment
3348 // bound and falls back to a full scan otherwise, so the
3349 // returned atom is guaranteed to be the globally-best — no
3350 // silently-missed ungathered atom.
3351 let Some(route) =
3352 index.route_exact(sketch, targets.row(row), budget, true)
3353 else {
3354 // Empty dictionary: flag for the exact fallback.
3355 return Ok(None);
3356 };
3357 let best_atom = route.atom;
3358 // Fit-quality floor: even the globally-best atom may align
3359 // only weakly with this row (no atom fits it). A finite
3360 // alignment below the floor — or a NaN, the zero-norm
3361 // ‖d‖ = 0 row — flags for the exact multi-start fallback
3362 // rather than encoding against a poorly-fitting atom. This is
3363 // a quality gate, not a routing-correctness gate; routing is
3364 // already exact. See CANDIDATE_ROUTING_MIN_ALIGNMENT.
3365 if !route.alignment.is_finite()
3366 || route.alignment < CANDIDATE_ROUTING_MIN_ALIGNMENT
3367 {
3368 return Ok(None);
3369 }
3370 let atom = atoms.get(best_atom).ok_or_else(|| {
3371 format!(
3372 "certified_encode_with_index: proposed atom {best_atom} out of range"
3373 )
3374 })?;
3375 let (t, cert) = self.certified_encode_row(
3376 atom,
3377 best_atom,
3378 targets.row(row),
3379 amplitudes[row],
3380 )?;
3381 // Heterogeneous-atom dictionaries with different latent_dim
3382 // per atom are not supported by the batched API: the caller
3383 // declares one shared `latent_dim` for the output tensor.
3384 // Silently zeroing the coord row while recording a
3385 // certified=true flag would produce corrupted
3386 // reconstructions downstream — error loudly instead.
3387 if t.len() != latent_dim {
3388 return Err(format!(
3389 "certified_encode_with_index: atom {best_atom} returned t.len()={} \
3390 but declared latent_dim={latent_dim}; heterogeneous-dim \
3391 dictionaries are not supported by this batched encode path",
3392 t.len()
3393 ));
3394 }
3395 Ok(Some((t, cert.certified())))
3396 })
3397 .collect()
3398 };
3399 let rows: Vec<Option<(Array1<f64>, bool)>> =
3400 if n >= ENCODE_BATCH_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
3401 use rayon::prelude::*;
3402 const CHUNK: usize = 256;
3403 let n_chunks = n.div_ceil(CHUNK);
3404 let chunked: Vec<Vec<Option<(Array1<f64>, bool)>>> = (0..n_chunks)
3405 .into_par_iter()
3406 .map(|c| {
3407 let start = c * CHUNK;
3408 let end = (start + CHUNK).min(n);
3409 encode_rows(start..end)
3410 })
3411 .collect::<Result<_, _>>()?;
3412 chunked.into_iter().flatten().collect()
3413 } else {
3414 encode_rows(0..n)?
3415 };
3416 let mut coords = Array2::<f64>::zeros((n, latent_dim));
3417 let mut certified = Vec::with_capacity(n);
3418 for (row, slot) in rows.into_iter().enumerate() {
3419 match slot {
3420 Some((t, cert)) => {
3421 coords.row_mut(row).assign(&t);
3422 certified.push(cert);
3423 }
3424 None => certified.push(false),
3425 }
3426 }
3427 Ok(EncodeResult::from_rows(coords, certified))
3428 }
3429
3430 /// LSH-routed AMORTIZED (distilled) encode — the production token-rate
3431 /// encoder of #1026 ladder item 3. Identical routing to
3432 /// [`Self::certified_encode_with_index`] (LSH proposes the best-aligned atom,
3433 /// the atlas routes to the in-atom nearest chart), but the in-atom encode is
3434 /// the closed-form per-chart Jacobian predictor + certificate gate of
3435 /// [`Self::amortized_encode_row`] rather than the certified Newton-refinement
3436 /// path.
3437 /// This is the deployment path: the distilled affine map produces the encode
3438 /// in one mat-vec, the Kantorovich certificate decides trust-or-fallback per
3439 /// row, and uncertified rows (the adversarial tail the thread expects to
3440 /// concentrate on rare tokens) are flagged for the exact multi-start solve —
3441 /// compute goes where the questions are. Row-independent against the frozen
3442 /// dictionary, so the batch fans out over rows with deterministic row-order
3443 /// assembly (bit-identical run-to-run).
3444 pub fn amortized_encode_with_index<S: AtomFrameSketch + Sync>(
3445 &self,
3446 atoms: &[SaeManifoldAtom],
3447 index: &SaeCandidateIndex,
3448 sketch: &S,
3449 targets: ArrayView2<'_, f64>,
3450 amplitudes: ArrayView1<'_, f64>,
3451 latent_dim: usize,
3452 ) -> Result<EncodeResult, String> {
3453 let n = targets.nrows();
3454 if amplitudes.len() != n {
3455 return Err(format!(
3456 "amortized_encode_with_index: amplitudes len {} != rows {n}",
3457 amplitudes.len()
3458 ));
3459 }
3460 let budget = auto_candidate_budget(atoms.len().max(1));
3461 let encode_rows =
3462 |range: std::ops::Range<usize>| -> Result<Vec<Option<(Array1<f64>, bool)>>, String> {
3463 range
3464 .map(|row| {
3465 // EXACT routing (#1777): global argmax of the routing score,
3466 // not just the best LSH-gathered candidate (see
3467 // certified_encode_with_index for the full rationale).
3468 let Some(route) =
3469 index.route_exact(sketch, targets.row(row), budget, true)
3470 else {
3471 return Ok(None);
3472 };
3473 let best_atom = route.atom;
3474 // Fit-quality floor (not a routing-correctness gate; routing
3475 // is exact): even the globally-best atom may fit a row poorly,
3476 // and a NaN alignment is the zero-norm ‖d‖ = 0 row. Either way
3477 // flag for the exact multi-start fallback. See
3478 // CANDIDATE_ROUTING_MIN_ALIGNMENT.
3479 if !route.alignment.is_finite()
3480 || route.alignment < CANDIDATE_ROUTING_MIN_ALIGNMENT
3481 {
3482 return Ok(None);
3483 }
3484 let atom = atoms.get(best_atom).ok_or_else(|| {
3485 format!(
3486 "amortized_encode_with_index: proposed atom {best_atom} out of range"
3487 )
3488 })?;
3489 let (t, cert) = self.amortized_encode_row(
3490 atom,
3491 best_atom,
3492 targets.row(row),
3493 amplitudes[row],
3494 )?;
3495 if t.len() != latent_dim {
3496 return Err(format!(
3497 "amortized_encode_with_index: atom {best_atom} returned t.len()={} \
3498 but declared latent_dim={latent_dim}; heterogeneous-dim \
3499 dictionaries are not supported by this batched encode path",
3500 t.len()
3501 ));
3502 }
3503 Ok(Some((t, cert.certified())))
3504 })
3505 .collect()
3506 };
3507 let rows: Vec<Option<(Array1<f64>, bool)>> =
3508 if n >= ENCODE_BATCH_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
3509 use rayon::prelude::*;
3510 const CHUNK: usize = 256;
3511 let n_chunks = n.div_ceil(CHUNK);
3512 let chunked: Vec<Vec<Option<(Array1<f64>, bool)>>> = (0..n_chunks)
3513 .into_par_iter()
3514 .map(|c| {
3515 let start = c * CHUNK;
3516 let end = (start + CHUNK).min(n);
3517 encode_rows(start..end)
3518 })
3519 .collect::<Result<_, _>>()?;
3520 chunked.into_iter().flatten().collect()
3521 } else {
3522 encode_rows(0..n)?
3523 };
3524 let mut coords = Array2::<f64>::zeros((n, latent_dim));
3525 let mut certified = Vec::with_capacity(n);
3526 for (row, slot) in rows.into_iter().enumerate() {
3527 match slot {
3528 Some((t, cert)) => {
3529 coords.row_mut(row).assign(&t);
3530 certified.push(cert);
3531 }
3532 None => certified.push(false),
3533 }
3534 }
3535 Ok(EncodeResult::from_rows(coords, certified))
3536 }
3537
3538 /// LSH-routed FAST amortized encode over the WHOLE dictionary — the
3539 /// multi-atom, corpus-rate analogue of [`Self::amortized_encode_with_index`].
3540 ///
3541 /// `amortized_encode_with_index` routes per row, then runs the per-row
3542 /// closed-form predictor + Kantorovich certificate + cold cross-check on each
3543 /// row independently. This fast variant keeps the SAME per-row EXACT routing
3544 /// (`index.route_exact` + the fit-quality floor), but replaces the per-row
3545 /// predictor with the GEMM-batched [`Self::amortized_encode_batch_fast`]:
3546 /// it GROUPS rows by their global-argmax atom and runs one batched affine-
3547 /// predictor pass per atom-group (a routing GEMM + a predictor GEMM each),
3548 /// reproducing a traditional SAE's whole-dictionary `W·x+b` throughput. No
3549 /// per-row certificate — this is the speed mode validated as accuracy-parity
3550 /// with the certified solve (`fast_forward_is_accuracy_parity_with_certified`).
3551 ///
3552 /// Returns the per-row latent coords and a valid-mask: `false` for a row with
3553 /// an empty dictionary, a sub-threshold/NaN routing alignment, or one the batched
3554 /// predictor could not fire on (no evaluator / singular Gauss–Newton block /
3555 /// non-finite-or-zero amplitude). Each row is written exactly once (disjoint
3556 /// per-atom groups), so the result is independent of group iteration order.
3557 pub fn amortized_encode_with_index_fast<S: AtomFrameSketch + Sync>(
3558 &self,
3559 atoms: &[SaeManifoldAtom],
3560 index: &SaeCandidateIndex,
3561 sketch: &S,
3562 targets: ArrayView2<'_, f64>,
3563 amplitudes: ArrayView1<'_, f64>,
3564 latent_dim: usize,
3565 ) -> Result<(Array2<f64>, Vec<bool>), String> {
3566 let n = targets.nrows();
3567 if amplitudes.len() != n {
3568 return Err(format!(
3569 "amortized_encode_with_index_fast: amplitudes len {} != rows {n}",
3570 amplitudes.len()
3571 ));
3572 }
3573 let budget = auto_candidate_budget(atoms.len().max(1));
3574 let mut coords = Array2::<f64>::zeros((n, latent_dim));
3575 let mut valid = vec![false; n];
3576 // ── Single allocation-free pass: route each row, apply the CACHED predictor.
3577 //
3578 // Routing sublinearity (massive-K, K≈32k): the certified path uses
3579 // `route_exact`, whose universal-bound LSH certificate only fires at the
3580 // alignment ceiling (≈1.0); for any real dictionary (`alignment < 1`) it
3581 // falls back to `brute_force_best_atom` — an O(K) full scan PER ROW, making
3582 // the encode O(N·K). This SPEED path takes the LSH gather's best-aligned atom
3583 // directly (`propose` scores only ~budget candidates → O(log K)); a rare miss
3584 // is caught by the fit-quality floor + the downstream certificate/exact
3585 // fallback (the documented speed/accuracy tradeoff).
3586 //
3587 // Allocation: the predictor uses only OFFLINE-cached atlas data (per-chart
3588 // `recon_center` for routing + `amortized_jacobian`/`amortized_base` for the
3589 // `t̂ = base + (1/z)·A₁·x` mat-vec), so NO per-row or per-atom heap buffer is
3590 // allocated. This replaces the old per-atom-group GEMM sub-batch — which at
3591 // `K ≫ N` degenerated to ONE row per group, so its per-group buffers (x_sub,
3592 // recon-centers, predictors) dominated and made the "fast" path allocation-
3593 // bound. Now the only per-row allocation is inside `index.propose`.
3594 for row in 0..n {
3595 let dir = targets.row(row);
3596 let proposal = index.propose(sketch, dir, budget, true);
3597 let Some(&best_atom) = proposal.proposed.first() else {
3598 continue; // nothing gathered (empty dictionary / probe-dim mismatch)
3599 };
3600 // Fit-quality floor: the best gathered atom still fits this row poorly,
3601 // or the alignment is NaN (zero-norm row) — flag for the exact fallback.
3602 let alignment = sketch.alignment(best_atom, dir);
3603 if !alignment.is_finite() || alignment < CANDIDATE_ROUTING_MIN_ALIGNMENT {
3604 continue;
3605 }
3606 let atom = atoms.get(best_atom).ok_or_else(|| {
3607 format!("amortized_encode_with_index_fast: proposed atom {best_atom} out of range")
3608 })?;
3609 if atom.latent_dim() != latent_dim {
3610 return Err(format!(
3611 "amortized_encode_with_index_fast: atom {best_atom} latent_dim {} != declared \
3612 {latent_dim}; heterogeneous-dim dictionaries are not supported by this path",
3613 atom.latent_dim()
3614 ));
3615 }
3616 let Some(atom_atlas) = self.atoms.get(best_atom) else {
3617 continue; // no atlas for this atom → predictor cannot fire (zeroed)
3618 };
3619 if amortized_predict_row(
3620 atom_atlas,
3621 dir,
3622 amplitudes[row],
3623 latent_dim,
3624 coords.row_mut(row),
3625 ) {
3626 valid[row] = true;
3627 }
3628 }
3629 Ok((coords, valid))
3630 }
3631
3632 /// LSH-routed FAST full forward over the WHOLE dictionary: encode → decode,
3633 /// the multi-atom analogue of [`Self::amortized_reconstruct_batch_fast`]. Same
3634 /// sublinear per-row routing + per-atom grouping as
3635 /// [`Self::amortized_encode_with_index_fast`], but each group is run through
3636 /// the batched reconstruct (`m(t̂) = z·Φ(t̂)·B`) so the result is the per-row
3637 /// reconstruction in the ambient space. Rows that do not route/predict decode
3638 /// to an exact zero reconstruction and are flagged `false`.
3639 pub fn amortized_reconstruct_with_index_fast<S: AtomFrameSketch + Sync>(
3640 &self,
3641 atoms: &[SaeManifoldAtom],
3642 index: &SaeCandidateIndex,
3643 sketch: &S,
3644 targets: ArrayView2<'_, f64>,
3645 amplitudes: ArrayView1<'_, f64>,
3646 ) -> Result<(Array2<f64>, Vec<bool>), String> {
3647 let n = targets.nrows();
3648 let p = targets.ncols();
3649 if amplitudes.len() != n {
3650 return Err(format!(
3651 "amortized_reconstruct_with_index_fast: amplitudes len {} != rows {n}",
3652 amplitudes.len()
3653 ));
3654 }
3655 let budget = auto_candidate_budget(atoms.len().max(1));
3656 // SUBLINEAR routing for the SPEED-mode full forward — mirror
3657 // `amortized_encode_with_index_fast`: take the LSH gather's best-aligned
3658 // atom (O(budget) candidates) instead of route_exact's O(K) full-scan
3659 // certification, keeping the whole fast encode→decode sublinear in K at
3660 // K=32k. The gather's best is the exact argmax on the vast majority of rows;
3661 // rare misses are caught by the fit-quality floor + downstream fallback.
3662 let mut groups: std::collections::HashMap<usize, Vec<usize>> =
3663 std::collections::HashMap::new();
3664 for row in 0..n {
3665 let dir = targets.row(row);
3666 let proposal = index.propose(sketch, dir, budget, true);
3667 let Some(&best_atom) = proposal.proposed.first() else {
3668 continue; // nothing gathered (empty dictionary / probe-dim mismatch)
3669 };
3670 let alignment = sketch.alignment(best_atom, dir);
3671 if !alignment.is_finite() || alignment < CANDIDATE_ROUTING_MIN_ALIGNMENT {
3672 continue;
3673 }
3674 groups.entry(best_atom).or_default().push(row);
3675 }
3676
3677 let mut recon = Array2::<f64>::zeros((n, p));
3678 let mut valid = vec![false; n];
3679 for (atom_idx, rows_here) in groups {
3680 let atom = atoms.get(atom_idx).ok_or_else(|| {
3681 format!(
3682 "amortized_reconstruct_with_index_fast: proposed atom {atom_idx} out of range"
3683 )
3684 })?;
3685 if atom.output_dim() != p {
3686 return Err(format!(
3687 "amortized_reconstruct_with_index_fast: atom {atom_idx} output_dim {} != target \
3688 dim {p}",
3689 atom.output_dim()
3690 ));
3691 }
3692 let mut x_sub = Array2::<f64>::zeros((rows_here.len(), p));
3693 let mut amp_sub = Array1::<f64>::zeros(rows_here.len());
3694 for (i, &row) in rows_here.iter().enumerate() {
3695 x_sub.row_mut(i).assign(&targets.row(row));
3696 amp_sub[i] = amplitudes[row];
3697 }
3698 let (sub_recon, sub_valid) = self.amortized_reconstruct_batch_fast(
3699 atom,
3700 atom_idx,
3701 x_sub.view(),
3702 amp_sub.view(),
3703 )?;
3704 for (i, &row) in rows_here.iter().enumerate() {
3705 if sub_valid[i] {
3706 recon.row_mut(row).assign(&sub_recon.row(i));
3707 valid[row] = true;
3708 }
3709 }
3710 }
3711 Ok((recon, valid))
3712 }
3713}
3714
3715/// Offline `β = 1/λ_min(H_GN)` at a chart center from the Gauss-Newton block
3716/// `H_GN = J_mᵀ J_m` (residual-free). The offline `β` bounds the curvature the
3717/// online certificate sees: charts are placed where the encode lands, so the
3718/// representative residual is small and `H_GN` is the dominant, residual-free
3719/// curvature estimate. (The online per-row certificate still uses the FULL
3720/// Hessian; this is only the offline radius-sizing curvature.) Returns `None`
3721/// for a degenerate center (`λ_min ≤ 0`), which marks an uncertifiable chart.
3722pub(crate) fn center_beta(atom: &SaeManifoldAtom, center: &Array1<f64>, ridge: f64) -> Option<f64> {
3723 let evaluator = atom.basis_evaluator.as_ref()?.clone();
3724 let d = atom.latent_dim();
3725 let p = atom.output_dim();
3726 let m = atom.basis_size();
3727 let coords = center.view().to_shape((1, d)).ok()?.to_owned();
3728 let (_phi, jet) = evaluator.evaluate(coords.view()).ok()?;
3729 let decoder = &atom.decoder_coefficients;
3730 // J_m[axis] = Bᵀ (∂Φ/∂t_axis) ∈ ℝᵖ (amplitude-1; curvature scales with z²
3731 // and is absorbed conservatively by the amplitude-bounded Lipschitz term).
3732 let mut jm = Array2::<f64>::zeros((d, p));
3733 for axis in 0..d {
3734 for basis_col in 0..m {
3735 let dphi = jet[[0, basis_col, axis]];
3736 if dphi == 0.0 {
3737 continue;
3738 }
3739 for out in 0..p {
3740 jm[[axis, out]] += dphi * decoder[[basis_col, out]];
3741 }
3742 }
3743 }
3744 let mut h = Array2::<f64>::zeros((d, d));
3745 for a in 0..d {
3746 for b in 0..d {
3747 h[[a, b]] = jm.row(a).dot(&jm.row(b));
3748 }
3749 h[[a, a]] += ridge;
3750 }
3751 let (vals, _vecs) = h.eigh(Side::Lower).ok()?;
3752 let lambda_min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
3753 if lambda_min.is_finite() && lambda_min > 0.0 {
3754 Some(1.0 / lambda_min)
3755 } else {
3756 None
3757 }
3758}
3759
3760/// #1154 — the amortized encoder's closed-form warm-start coordinate for one
3761/// row `x` against one chart at amplitude `z`:
3762///
3763/// ```text
3764/// t̂ = t_c + (1/z) · A₁ · (x − z · m₁(t_c)),
3765/// ```
3766///
3767/// a single `O(d·p)` mat-vec from the chart's precomputed IFT Jacobian `A₁` and
3768/// center reconstruction `m₁`. Returns `None` when the chart carries no
3769/// distilled Jacobian (singular Gauss–Newton block) or the amplitude is not
3770/// strictly positive and finite (a near-inactive atom, where the
3771/// amplitude-divided map is undefined) — in those cases the caller starts from
3772/// the chart center instead. Shared by the amortized encode (where `t̂` is the
3773/// prediction) and the exact certified encode (where `t̂` is the Newton
3774/// warm-start that then refines to stationarity, Design A).
3775pub(crate) fn amortized_warm_start(
3776 chart: &CertifiedChart,
3777 x: ArrayView1<'_, f64>,
3778 amplitude: f64,
3779) -> Option<Array1<f64>> {
3780 let a1 = chart.amortized_jacobian.as_ref()?;
3781 if !(amplitude.is_finite() && amplitude.abs() > 0.0) {
3782 return None;
3783 }
3784 let d = a1.nrows();
3785 let mut t_hat = chart.region.center.clone();
3786 for (out_idx, &m1_out) in chart.recon_center.iter().enumerate().take(a1.ncols()) {
3787 let resid = x[out_idx] - amplitude * m1_out;
3788 for axis in 0..d {
3789 t_hat[axis] += a1[[axis, out_idx]] * resid / amplitude;
3790 }
3791 }
3792 Some(t_hat)
3793}
3794
3795/// Single-row amortized predictor against ONE atom's cached atlas, writing the
3796/// encoded latent coordinate DIRECTLY into `out` (length `d`) with NO heap
3797/// allocation. Routes to the nearest certifiable chart by cached center-
3798/// reconstruction distance `‖x − m(t_c)‖²` (matching `amortized_encode_batch_fast`'s
3799/// per-chart routing), then applies that chart's precomputed affine predictor
3800/// `t̂ = base + (1/z)·A₁·x` (`base = t_c − A₁·m₁` is `chart.amortized_base`).
3801///
3802/// Returns `false` — leaving `out` at its incoming (zeroed) value — for exactly the
3803/// rows `amortized_encode_batch_fast` would flag: no certifiable chart, a nearest
3804/// chart with no distilled predictor (singular Gauss–Newton block), or an unusable
3805/// amplitude. This is the per-row core of the allocation-free massive-K fast encode.
3806pub(crate) fn amortized_predict_row(
3807 atom_atlas: &AtomEncodeAtlas,
3808 x: ArrayView1<'_, f64>,
3809 amplitude: f64,
3810 d: usize,
3811 mut out: ndarray::ArrayViewMut1<'_, f64>,
3812) -> bool {
3813 if !(amplitude.is_finite() && amplitude.abs() > 0.0) {
3814 return false;
3815 }
3816 // Nearest certifiable chart by the TRUE objective ‖x − z·recon_center‖²
3817 // (F1; `z = amplitude`, finite and non-zero by the guard above). First-wins on
3818 // ties (strict `<`) — same amplitude-scaled argmin as
3819 // `amortized_encode_batch_fast`'s route_idx.
3820 let mut best_ci: Option<usize> = None;
3821 let mut best_dist = f64::INFINITY;
3822 for (ci, chart) in atom_atlas.charts.iter().enumerate() {
3823 if chart.certified_radius <= 0.0 {
3824 continue;
3825 }
3826 let dist = amplitude_scaled_center_dist(chart.recon_center.view(), x, amplitude);
3827 if dist < best_dist {
3828 best_dist = dist;
3829 best_ci = Some(ci);
3830 }
3831 }
3832 let Some(ci) = best_ci else {
3833 return false;
3834 };
3835 let chart = &atom_atlas.charts[ci];
3836 // The nearest chart must carry a distilled predictor; otherwise flag (zeroed),
3837 // exactly as the per-chart `None` branch of `amortized_encode_batch_fast`.
3838 let (Some(a1), Some(base)) = (
3839 chart.amortized_jacobian.as_ref(),
3840 chart.amortized_base.as_ref(),
3841 ) else {
3842 return false;
3843 };
3844 let inv_z = 1.0 / amplitude;
3845 for axis in 0..d {
3846 out[axis] = base[axis] + a1.row(axis).dot(&x) * inv_z;
3847 }
3848 true
3849}
3850
3851/// The amplitude-1 distilled amortized-encoder Jacobian at a chart center
3852/// (#1026 ladder item 3). Returns `(A₁, m₁)` where `m₁ = BᵀΦ(t_c) ∈ ℝᵖ` is the
3853/// amplitude-1 center reconstruction and `A₁ = (J₁ᵀJ₁ + ridge·I)⁻¹ J₁ ∈ ℝ^{d×p}`
3854/// is the implicit-function-theorem derivative of the encode map `x ↦ t`
3855/// (Gauss–Newton block — the residual-free, dominant curvature exactly as the
3856/// offline radius-sizing `β`). With these, the online encode of a row `x` at
3857/// amplitude `z` is the closed-form affine prediction
3858/// `t = t_c + (1/z)·A₁·(x − z·m₁)` — one mat-vec, no per-row factorization.
3859/// `None` when the basis has no jet or the Gauss–Newton block is singular (no
3860/// certifiable amortization), matching `center_beta`'s gate so a chart with a
3861/// finite `β` always carries a Jacobian and vice versa.
3862pub(crate) fn center_amortized_jacobian(
3863 atom: &SaeManifoldAtom,
3864 center: &Array1<f64>,
3865 ridge: f64,
3866) -> Option<(Array2<f64>, Array1<f64>)> {
3867 let evaluator = atom.basis_evaluator.as_ref()?.clone();
3868 let d = atom.latent_dim();
3869 let p = atom.output_dim();
3870 let m = atom.basis_size();
3871 let coords = center.view().to_shape((1, d)).ok()?.to_owned();
3872 let (phi, jet) = evaluator.evaluate(coords.view()).ok()?;
3873 let decoder = &atom.decoder_coefficients;
3874 // m₁(t_c) = BᵀΦ(t_c) ∈ ℝᵖ (amplitude-1 center reconstruction).
3875 let mut recon = Array1::<f64>::zeros(p);
3876 for basis_col in 0..m {
3877 let phi_v = phi[[0, basis_col]];
3878 if phi_v == 0.0 {
3879 continue;
3880 }
3881 for out in 0..p {
3882 recon[out] += phi_v * decoder[[basis_col, out]];
3883 }
3884 }
3885 // J₁[axis] = Bᵀ (∂Φ/∂t_axis) ∈ ℝᵖ (amplitude-1; z factors out analytically).
3886 let mut jm = Array2::<f64>::zeros((d, p));
3887 for axis in 0..d {
3888 for basis_col in 0..m {
3889 let dphi = jet[[0, basis_col, axis]];
3890 if dphi == 0.0 {
3891 continue;
3892 }
3893 for out in 0..p {
3894 jm[[axis, out]] += dphi * decoder[[basis_col, out]];
3895 }
3896 }
3897 }
3898 // H_GN = J₁ J₁ᵀ + ridge·I ∈ ℝ^{d×d}.
3899 let mut h = Array2::<f64>::zeros((d, d));
3900 for a in 0..d {
3901 for b in 0..d {
3902 h[[a, b]] = jm.row(a).dot(&jm.row(b));
3903 }
3904 h[[a, a]] += ridge;
3905 }
3906 let (vals, vecs) = h.eigh(Side::Lower).ok()?;
3907 let lambda_min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
3908 if !(lambda_min.is_finite() && lambda_min > 0.0) {
3909 return None;
3910 }
3911 // A₁ = H_GN⁻¹ J₁ via the eigendecomposition: H⁻¹ = Σ_i (1/λᵢ) vᵢ vᵢᵀ, so
3912 // A₁[:, out] = Σ_i (vᵢ · J₁[:, out]) / λᵢ · vᵢ. Column-by-column keeps it the
3913 // d×p Jacobian (one SPD solve reused across all p output channels).
3914 let mut a1 = Array2::<f64>::zeros((d, p));
3915 for out in 0..p {
3916 let jcol = jm.column(out);
3917 for (i, &lam) in vals.iter().enumerate() {
3918 if !(lam.is_finite() && lam > 0.0) {
3919 return None;
3920 }
3921 let vi = vecs.column(i);
3922 let coeff = vi.dot(&jcol) / lam;
3923 for row in 0..d {
3924 a1[[row, out]] += coeff * vi[row];
3925 }
3926 }
3927 }
3928 Some((a1, recon))
3929}
3930
3931/// The single F1 routing metric: the amplitude-scaled center-reconstruction
3932/// distance `Σ_j (z·m₁_j − x_j)²`, where `m₁` is the amplitude-1 center
3933/// reconstruction `recon_center` and `z` is the row amplitude. Computed as the
3934/// DIRECT squared distance in element order — never the algebraically-equal but
3935/// cancellation-prone expansion `z²‖m₁‖² − 2z·(x·m₁)`, which loses ~‖x‖²·ε and can
3936/// FLIP the argmin between two charts whose reconstructions coincide to rounding
3937/// (period-wrapped seam charts). Every router — [`nearest_chart`],
3938/// [`select_nearest_charts_topk`], the batched `amortized_encode_batch_fast`
3939/// routing, and [`amortized_predict_row`] — scores through THIS function, so they
3940/// break near-ties identically. `#[inline]` keeps it zero-overhead on the
3941/// allocation-free massive-K fast-encode paths.
3942#[inline]
3943pub(crate) fn amplitude_scaled_center_dist(
3944 recon: ArrayView1<'_, f64>,
3945 x: ArrayView1<'_, f64>,
3946 amplitude: f64,
3947) -> f64 {
3948 let mut dist = 0.0;
3949 for (r, xv) in recon.iter().zip(x.iter()) {
3950 let diff = amplitude * r - xv;
3951 dist += diff * diff;
3952 }
3953 dist
3954}
3955
3956/// The shared chart-routing comparator: the SINGLE amplitude-gating + tie-break
3957/// decision every chart-routing top-k selection funnels through, so the CPU
3958/// atlas encode ([`nearest_chart`], [`nearest_charts_topk`]) and the GPU-host
3959/// resident encode
3960/// ([`crate::gpu_kernels::sae_encode_resident::nearest_charts_topk`]) can never
3961/// drift apart.
3962///
3963/// For each of `n_charts` charts (in index order), `recon_into(idx, out)` writes
3964/// the chart's amplitude-1 center reconstruction `m₁(t_idx)` into `out` and
3965/// returns whether the chart is certifiable (`certified_radius > 0`); a `false`
3966/// return skips it. The score is the TRUE encode objective at the row's
3967/// amplitude `z`, `‖x − z·m₁(t_c)‖²` — NOT the amplitude-1 `‖x − m₁(t_c)‖²`,
3968/// because the reconstruction actually compared against `x` is `z·m₁(t_c)` and
3969/// routing on amplitude-1 centers picks the wrong chart whenever `z ≠ 1` (a small
3970/// `z` makes a large-norm center reconstruct near a small `x`). The `k` charts of
3971/// smallest score are returned as `(index, distance)` sorted by `(distance,
3972/// index)` — a strict, deterministic first-wins tie rule.
3973///
3974/// The recon SOURCE is deliberately left to the caller: the CPU path copies the
3975/// distilled `recon_center` (precomputed once at build), while the GPU-host path
3976/// re-evaluates the basis at the chart center (mirroring exactly what the CUDA
3977/// kernel does, so the emulator stays its bit-parity oracle). Only the comparator
3978/// is shared.
3979pub(crate) fn select_nearest_charts_topk(
3980 n_charts: usize,
3981 x: ArrayView1<'_, f64>,
3982 amplitude: f64,
3983 k: usize,
3984 mut recon_into: impl FnMut(usize, &mut [f64]) -> bool,
3985) -> Vec<(usize, f64)> {
3986 if n_charts == 0 || k == 0 {
3987 return Vec::new();
3988 }
3989 let mut recon = vec![0.0_f64; x.len()];
3990 let mut scored: Vec<(usize, f64)> = Vec::new();
3991 for idx in 0..n_charts {
3992 if !recon_into(idx, &mut recon) {
3993 continue;
3994 }
3995 let dist = amplitude_scaled_center_dist(ArrayView1::from(recon.as_slice()), x, amplitude);
3996 scored.push((idx, dist));
3997 }
3998 // Sort by distance, then chart index for a deterministic, first-wins order.
3999 scored.sort_by(|a, b| {
4000 a.1.partial_cmp(&b.1)
4001 .unwrap_or(std::cmp::Ordering::Equal)
4002 .then(a.0.cmp(&b.0))
4003 });
4004 scored.truncate(k);
4005 scored
4006}
4007
4008/// Route a target row to the nearest chart of an atom by reconstruction
4009/// distance: the chart whose center reconstruction `m(t_c)` is closest to `x`.
4010/// Returns the chart index and the distance, or `None` when the atom has no
4011/// charts. Equivalent to `select_nearest_charts_topk(.., 1).first()`, routed
4012/// through the shared comparator.
4013pub(crate) fn nearest_chart(
4014 atom_atlas: &AtomEncodeAtlas,
4015 x: ArrayView1<'_, f64>,
4016 amplitude: f64,
4017) -> Option<(usize, f64)> {
4018 select_nearest_charts_topk(atom_atlas.charts.len(), x, amplitude, 1, |idx, out| {
4019 let chart = &atom_atlas.charts[idx];
4020 if chart.certified_radius <= 0.0 {
4021 return false;
4022 }
4023 for (o, r) in out.iter_mut().zip(chart.recon_center.iter()) {
4024 *o = *r;
4025 }
4026 true
4027 })
4028 .into_iter()
4029 .next()
4030}
4031
4032/// The `k` charts whose CENTER reconstruction `m(t_c)` is nearest to `x` in
4033/// ambient ‖·‖², returned as chart indices sorted by increasing distance (ties
4034/// broken by chart index — deterministic). Only certifiable charts
4035/// (`certified_radius > 0`) are considered, exactly like [`nearest_chart`], whose
4036/// single result is `nearest_charts_topk(.., 1)[0]`. Used by the certified encode
4037/// to refine the global basin on self-approaching atoms (see
4038/// [`CERTIFIED_ROUTING_TOPK`]).
4039pub(crate) fn nearest_charts_topk(
4040 atom_atlas: &AtomEncodeAtlas,
4041 x: ArrayView1<'_, f64>,
4042 amplitude: f64,
4043 k: usize,
4044) -> Vec<usize> {
4045 // `m₁(t_c) = BᵀΦ(t_c)` is an OFFLINE per-chart constant already distilled into
4046 // `chart.recon_center` at build time (bit-for-bit the same φ·decoder
4047 // accumulation this used to recompute). Reuse it instead of re-evaluating the
4048 // basis at a fixed center for every row — that re-eval was the encode's
4049 // dominant per-row cost. The amplitude-gating + tie-break comparator lives in
4050 // `select_nearest_charts_topk` (shared with the GPU-host path).
4051 select_nearest_charts_topk(atom_atlas.charts.len(), x, amplitude, k, |idx, out| {
4052 let chart = &atom_atlas.charts[idx];
4053 if chart.certified_radius <= 0.0 {
4054 return false;
4055 }
4056 for (o, r) in out.iter_mut().zip(chart.recon_center.iter()) {
4057 *o = *r;
4058 }
4059 true
4060 })
4061 .into_iter()
4062 .map(|(idx, _)| idx)
4063 .collect()
4064}
4065
4066/// Reconstruction error `‖x − z·m(t)‖` of an encoded coordinate `t` — the
4067/// criterion the certified encode minimizes over its candidate charts to pick the
4068/// GLOBAL basin. `m(t) = Bᵀ Φ(t)` is the amplitude-1 reconstruction; `z` is the
4069/// amplitude. A non-finite reconstruction returns `+∞` so it never wins.
4070pub fn encode_reconstruction_error(
4071 atom: &SaeManifoldAtom,
4072 evaluator: &dyn SaeBasisEvaluator,
4073 coord: ArrayView1<'_, f64>,
4074 x: ArrayView1<'_, f64>,
4075 amplitude: f64,
4076) -> f64 {
4077 // Bare Euclidean residual norm — bit-identical to the metric-free encode.
4078 encode_reconstruction_error_core(
4079 atom,
4080 evaluator,
4081 coord,
4082 x,
4083 amplitude,
4084 &EncodeObjective::euclidean(),
4085 )
4086}
4087
4088/// Objective-aware reconstruction error (F3): the WHITENED residual norm
4089/// `‖M^{1/2} r‖ = ‖Uᵀ r‖` when a metric is active, so the candidate-ranking /
4090/// warm-start SSE guard measures error in the SAME metric the certified objective
4091/// minimizes — an unwhitened `‖r‖₂` guard would rank candidates by a different
4092/// functional than the one being certified. With no metric this is `‖r‖₂`, exactly
4093/// the historical guard.
4094pub(crate) fn encode_reconstruction_error_core(
4095 atom: &SaeManifoldAtom,
4096 evaluator: &dyn SaeBasisEvaluator,
4097 coord: ArrayView1<'_, f64>,
4098 x: ArrayView1<'_, f64>,
4099 amplitude: f64,
4100 objective: &EncodeObjective<'_>,
4101) -> f64 {
4102 let d = atom.latent_dim();
4103 let p = atom.output_dim();
4104 let m = atom.basis_size();
4105 let coords = match coord.to_shape((1, d)) {
4106 Ok(c) => c.to_owned(),
4107 Err(_) => return f64::INFINITY,
4108 };
4109 let Ok((phi, _jet)) = evaluator.evaluate(coords.view()) else {
4110 return f64::INFINITY;
4111 };
4112 let mut residual = Array1::<f64>::zeros(p);
4113 for out in 0..p {
4114 let mut recon = 0.0;
4115 for basis_col in 0..m {
4116 recon += phi[[0, basis_col]] * atom.decoder_coefficients[[basis_col, out]];
4117 }
4118 residual[out] = x[out] - amplitude * recon;
4119 }
4120 // `½ rᵀ M r = ½‖Uᵀr‖²` under `M = U Uᵀ`; the guard reports the metric norm
4121 // `‖Uᵀr‖`. Euclidean (`None`) accumulates `Σ r²` in the SAME element order as
4122 // the historical loop, so the metric-free guard is bit-for-bit unchanged.
4123 let err2 = match objective.metric_factor {
4124 Some(u) => {
4125 let utr = u.t().dot(&residual);
4126 utr.dot(&utr)
4127 }
4128 None => {
4129 let mut e = 0.0;
4130 for out in 0..p {
4131 e += residual[out] * residual[out];
4132 }
4133 e
4134 }
4135 };
4136 if err2.is_finite() {
4137 err2.sqrt()
4138 } else {
4139 f64::INFINITY
4140 }
4141}
4142
4143/// Maximum number of chart centers laid down per atom (the SHAPE_BAND grid
4144/// point cap; mirrors `SHAPE_BAND_MAX_POINTS` in the atom band machinery).
4145pub(crate) const SHAPE_BAND_MAX_POINTS: usize = 512;
4146
4147/// Lay down chart centers on an atom's coordinate grid (the SHAPE_BAND grid
4148/// idiom): a regular grid spanning the compact latent domain for periodic /
4149/// sphere / torus atoms, and a strided cover of the latent axes for unbounded
4150/// (Duchon / Euclidean) atoms.
4151///
4152/// Periodic / torus latents are fractions of one period, so the per-axis grid
4153/// spans `[0, 1)`; the sphere chart spans `lat ∈ [−π/2, π/2]`, `lon ∈ [−π, π)`.
4154/// These conventions match the basis evaluators (the fraction-of-period circle
4155/// harmonic and the lat/lon sphere chart).
4156/// Squared coordinate distance between two latent points under the atom's chart
4157/// geometry: per-axis WRAPPED distance `min(|a−b|, period−|a−b|)` on periodic
4158/// (circle) axes — period 1 to match `chart_center_grid`'s `[0,1)` torus tiling
4159/// — and plain difference on line axes. Used to place + size data-driven charts.
4160pub(crate) fn coord_dist_sq(
4161 atom: &SaeManifoldAtom,
4162 a: ArrayView1<'_, f64>,
4163 b: ArrayView1<'_, f64>,
4164) -> f64 {
4165 // Per-axis period comes from the ONE canonical source `latent_axis_period` —
4166 // the SAME convention the certified-encode `in_chart` guard uses (via
4167 // `latent_coordinate_distance`): period-1 fraction axes for periodic/torus and
4168 // the cylinder angle, period-2π on the sphere LONGITUDE (axis 1), and
4169 // NON-periodic otherwise — including the sphere LATITUDE (axis 0), which ranges
4170 // over [−π/2, π/2] and must NOT wrap. A former local `periodic_axis` closure
4171 // wrapped BOTH sphere axes at period 1, so two genuinely-far radian longitudes
4172 // (e.g. 0 and 3 rad) collapsed to distance 0 — corrupting the farthest-point
4173 // placement AND the nearest-neighbour radii in `data_driven_chart_centers` for
4174 // sphere atoms, and disagreeing with the metric the encode's `in_chart`
4175 // soundness guard enforces. Delegating keeps the two metrics identical.
4176 let mut acc = 0.0;
4177 for axis in 0..a.len().min(b.len()) {
4178 let mut d = (a[axis] - b[axis]).abs();
4179 if let Some(period) = latent_axis_period(atom, axis) {
4180 let wrapped = d.rem_euclid(period);
4181 d = wrapped.min(period - wrapped);
4182 }
4183 acc += d * d;
4184 }
4185 acc
4186}
4187
4188/// Greedy farthest-point sampling of up to `max_charts` chart centers from the
4189/// atom's latent `coords` (n × d), with each center's nominal radius set to half
4190/// the distance to its nearest neighbor center (floored, so a singleton/coincident
4191/// cluster still gets a usable ball). Deterministic: seeds from row 0, then
4192/// repeatedly adds the coord maximally far (under [`coord_dist_sq`]) from the
4193/// chosen set — coverage-maximizing and reproducible run-to-run.
4194pub(crate) fn data_driven_chart_centers(
4195 atom: &SaeManifoldAtom,
4196 coords: ArrayView2<'_, f64>,
4197 max_charts: usize,
4198) -> Result<(Array2<f64>, Vec<f64>), String> {
4199 let n = coords.nrows();
4200 let d = coords.ncols();
4201 if d != atom.latent_dim() {
4202 return Err(format!(
4203 "data_driven_chart_centers: coords have {d} cols but atom latent_dim is {}",
4204 atom.latent_dim()
4205 ));
4206 }
4207 if n == 0 {
4208 return Ok((Array2::<f64>::zeros((0, d)), Vec::new()));
4209 }
4210 let k = max_charts.min(n);
4211 // Farthest-point sampling: maintain each row's distance to the nearest chosen
4212 // center, add the row with the maximum such distance each step.
4213 let mut chosen: Vec<usize> = Vec::with_capacity(k);
4214 chosen.push(0);
4215 let mut nearest_sq: Vec<f64> = (0..n)
4216 .map(|r| coord_dist_sq(atom, coords.row(r), coords.row(0)))
4217 .collect();
4218 while chosen.len() < k {
4219 // Pick the row farthest from the current center set (first-wins tie).
4220 let mut best = 0usize;
4221 let mut best_d = -1.0;
4222 for r in 0..n {
4223 if nearest_sq[r] > best_d {
4224 best_d = nearest_sq[r];
4225 best = r;
4226 }
4227 }
4228 if best_d <= 0.0 {
4229 break; // all remaining rows coincide with a chosen center.
4230 }
4231 chosen.push(best);
4232 for r in 0..n {
4233 let dr = coord_dist_sq(atom, coords.row(r), coords.row(best));
4234 if dr < nearest_sq[r] {
4235 nearest_sq[r] = dr;
4236 }
4237 }
4238 }
4239 let m = chosen.len();
4240 let mut centers = Array2::<f64>::zeros((m, d));
4241 for (i, &row) in chosen.iter().enumerate() {
4242 centers.row_mut(i).assign(&coords.row(row));
4243 }
4244 // Per-center radius = half the nearest-OTHER-center distance, floored so a
4245 // coincident pair still yields a positive ball, capped at 0.5 (the largest
4246 // meaningful half-period on a unit circle).
4247 let mut radii = vec![0.0_f64; m];
4248 for i in 0..m {
4249 let mut nn = f64::INFINITY;
4250 for j in 0..m {
4251 if i == j {
4252 continue;
4253 }
4254 let dsq = coord_dist_sq(atom, centers.row(i), centers.row(j));
4255 if dsq < nn {
4256 nn = dsq;
4257 }
4258 }
4259 let r = if nn.is_finite() { 0.5 * nn.sqrt() } else { 0.5 };
4260 radii[i] = r.max(1.0e-3).min(0.5);
4261 }
4262 Ok((centers, radii))
4263}
4264
4265pub(crate) fn chart_center_grid(atom: &SaeManifoldAtom, resolution: usize) -> Array2<f64> {
4266 use crate::manifold::SaeAtomBasisKind::*;
4267 let d = atom.latent_dim();
4268 match atom.basis_kind() {
4269 Periodic | Torus | KleinBottle => regular_product_grid(d, resolution, 0.0, 1.0, false),
4270 // Cylinder `S¹ × ℝ`: axis 0 is the periodic circle `[0, 1)` (no
4271 // endpoint, like the harmonic axes); axis 1 is the unbounded line,
4272 // covered by a strided unit box `[-0.5, 0.5]` about the origin (like the
4273 // Euclidean patch). The certified radius refines each chart; out-of-cover
4274 // line starts route to the exact fallback honestly.
4275 Cylinder if d == 2 => cylinder_chart_center_grid(resolution),
4276 Cylinder => regular_product_grid(d, resolution, -0.5, 0.5, true),
4277 Mobius if d == 2 => mobius_chart_center_grid(resolution),
4278 Mobius => regular_product_grid(d, resolution, -1.0, 1.0, true),
4279 Sphere | ProjectivePlane if d == 2 => sphere_latlon_grid(resolution),
4280 Linear | Sphere | ProjectivePlane | Duchon | EuclideanPatch | Poincare | Precomputed(_)
4281 | FiniteSet => {
4282 // Unbounded / non-compact latents (and the finite-set index axis): a
4283 // strided cover of a unit box about the origin per axis. The certified
4284 // radius refines each chart; out-of-cover starts route to the exact
4285 // fallback honestly.
4286 regular_product_grid(d, resolution, -0.5, 0.5, true)
4287 }
4288 }
4289}
4290
4291/// A regular `resolution`-per-axis product grid over `[lo, hi]^d`, capped at
4292/// [`SHAPE_BAND_MAX_POINTS`] total points (the per-axis resolution is reduced
4293/// until the product fits). When `include_endpoint` the last grid point sits at
4294/// `hi`; otherwise the axis is treated as periodic and stops one step short.
4295/// Per-axis resolution actually used by [`regular_product_grid`] after the
4296/// [`SHAPE_BAND_MAX_POINTS`] product cap. Chart radii must be derived from THIS
4297/// (not the raw `resolution`), otherwise for `resolution^d > SHAPE_BAND_MAX_POINTS`
4298/// the grid spacing is coarser than the radius and the charts leave gaps.
4299pub(crate) fn capped_per_axis(d: usize, resolution: usize) -> usize {
4300 let mut per_axis = resolution.max(2);
4301 while per_axis.saturating_pow(d as u32) > SHAPE_BAND_MAX_POINTS && per_axis > 2 {
4302 per_axis -= 1;
4303 }
4304 per_axis
4305}
4306
4307pub(crate) fn regular_product_grid(
4308 d: usize,
4309 resolution: usize,
4310 lo: f64,
4311 hi: f64,
4312 include_endpoint: bool,
4313) -> Array2<f64> {
4314 if d == 0 {
4315 return Array2::<f64>::zeros((1, 0));
4316 }
4317 let per_axis = capped_per_axis(d, resolution);
4318 let total = per_axis.saturating_pow(d as u32).max(1);
4319 let denom = if include_endpoint {
4320 (per_axis.max(2) - 1) as f64
4321 } else {
4322 per_axis as f64
4323 };
4324 let mut grid = Array2::<f64>::zeros((total, d));
4325 let mut idx = vec![0usize; d];
4326 for flat in 0..total {
4327 for axis in 0..d {
4328 let frac = idx[axis] as f64 / denom;
4329 grid[[flat, axis]] = lo + (hi - lo) * frac;
4330 }
4331 for axis in (0..d).rev() {
4332 idx[axis] += 1;
4333 if idx[axis] < per_axis {
4334 break;
4335 }
4336 idx[axis] = 0;
4337 }
4338 }
4339 grid
4340}
4341
4342/// Lat/lon sphere chart grid: `lat ∈ [−π/2, π/2]`, `lon ∈ [−π, π)`, matching
4343/// the [`crate::manifold::SphereChartEvaluator`] convention.
4344pub(crate) fn sphere_latlon_grid(resolution: usize) -> Array2<f64> {
4345 use std::f64::consts::PI;
4346 // Per-axis cap derived from the shared point budget rather than a hardcoded
4347 // literal (#2071): the largest r with r² ≤ SHAPE_BAND_MAX_POINTS. Equals 22
4348 // at the current budget (22²=484≤512), but now tracks the budget if it
4349 // changes instead of silently desyncing from the sibling grid arms.
4350 let r_cap = SHAPE_BAND_MAX_POINTS.isqrt();
4351 let r = resolution.max(2).min(r_cap);
4352 let mut grid = Array2::<f64>::zeros((r * r, 2));
4353 for i in 0..r {
4354 let lat = -PI / 2.0 + PI * (i as f64 + 0.5) / r as f64;
4355 for j in 0..r {
4356 let lon = -PI + 2.0 * PI * (j as f64) / r as f64;
4357 grid[[i * r + j, 0]] = lat;
4358 grid[[i * r + j, 1]] = lon;
4359 }
4360 }
4361 grid
4362}
4363
4364/// Cylinder `S¹ × ℝ` chart-center grid: axis 0 sweeps the periodic circle over
4365/// one period `[0, 1)` (no endpoint, matching the harmonic axis), axis 1 strides
4366/// a unit box `[−0.5, 0.5]` about the origin on the unbounded line (with
4367/// endpoint). Capped at [`SHAPE_BAND_MAX_POINTS`] total centers.
4368pub(crate) fn cylinder_chart_center_grid(resolution: usize) -> Array2<f64> {
4369 let mut per_axis = resolution.max(2);
4370 while per_axis * per_axis > SHAPE_BAND_MAX_POINTS && per_axis > 2 {
4371 per_axis -= 1;
4372 }
4373 let total = per_axis * per_axis;
4374 let line_denom = (per_axis.max(2) - 1) as f64;
4375 let mut grid = Array2::<f64>::zeros((total, 2));
4376 for i in 0..per_axis {
4377 // Periodic axis 0: stop one step short of the period.
4378 let circle = i as f64 / per_axis as f64;
4379 for j in 0..per_axis {
4380 // Line axis 1: include the endpoint of the unit box.
4381 let line = -0.5 + (j as f64) / line_denom;
4382 grid[[i * per_axis + j, 0]] = circle;
4383 grid[[i * per_axis + j, 1]] = line;
4384 }
4385 }
4386 grid
4387}
4388
4389/// Möbius double-cover chart grid: angle `s ∈ [0, 2)` and bounded width
4390/// `w ∈ [-1, 1]`. The deck identification is encoded by the basis, so the
4391/// atlas covers the ordinary cylindrical chart without duplicating a seam.
4392pub(crate) fn mobius_chart_center_grid(resolution: usize) -> Array2<f64> {
4393 let mut per_axis = resolution.max(2);
4394 while per_axis * per_axis > SHAPE_BAND_MAX_POINTS && per_axis > 2 {
4395 per_axis -= 1;
4396 }
4397 let mut grid = Array2::<f64>::zeros((per_axis * per_axis, 2));
4398 let width_denom = (per_axis - 1) as f64;
4399 for i in 0..per_axis {
4400 let angle = 2.0 * i as f64 / per_axis as f64;
4401 for j in 0..per_axis {
4402 let width = -1.0 + 2.0 * j as f64 / width_denom;
4403 grid[[i * per_axis + j, 0]] = angle;
4404 grid[[i * per_axis + j, 1]] = width;
4405 }
4406 }
4407 grid
4408}
4409
4410/// Nominal in-chart radius: half the inter-center grid spacing, so charts tile
4411/// the domain. For compact latents this is the grid step; for unbounded latents
4412/// a unit default that the certified radius refines.
4413pub(crate) fn chart_nominal_radius(atom: &SaeManifoldAtom, resolution: usize) -> f64 {
4414 use crate::manifold::SaeAtomBasisKind::*;
4415 match atom.basis_kind() {
4416 Periodic | Torus | KleinBottle => {
4417 0.5 / (capped_per_axis(atom.latent_dim(), resolution) as f64)
4418 }
4419 // Must use the SAME capped per-axis count `sphere_latlon_grid` lays the
4420 // centers on: the coarsest tiling step is the longitude half-spacing `π/r`
4421 // with `r` the grid's per-axis count. Deriving the radius from the RAW
4422 // resolution makes it smaller than the grid spacing once the grid caps,
4423 // leaving gaps between charts so rows in the gaps spuriously route to the
4424 // exact fallback (the hazard `capped_per_axis` documents for the regular
4425 // grid). The cap is DERIVED, not a literal (#2071): `sphere_latlon_grid`
4426 // uses `SHAPE_BAND_MAX_POINTS.isqrt()` — the largest `r` with `r² ≤`
4427 // the band-point budget — so the two stay in lockstep if the budget moves
4428 // (a hardcoded `22` here silently desyncs the moment the budget changes).
4429 Sphere | ProjectivePlane => {
4430 let r_cap = SHAPE_BAND_MAX_POINTS.isqrt();
4431 std::f64::consts::PI / (resolution.max(2).min(r_cap) as f64)
4432 }
4433 // Cylinder charts tile two heterogeneous axes (a `[0,1)` periodic step
4434 // and a unit-box line step); the chart radius is a single scalar, so we
4435 // take the tighter (periodic) step `0.5/res` to keep every chart valid
4436 // on both axes. The certified Kantorovich radius refines it per chart.
4437 Cylinder => 0.5 / (capped_per_axis(atom.latent_dim(), resolution) as f64),
4438 // Angle spacing is `2/r` and width spacing is `2/(r-1)`; their
4439 // half-spacings are `1/r` and `1/(r-1)`, so the angular axis is the
4440 // conservative scalar chart radius.
4441 Mobius => 1.0 / (capped_per_axis(atom.latent_dim(), resolution) as f64),
4442 Linear | Duchon | EuclideanPatch | Poincare | Precomputed(_) | FiniteSet => {
4443 1.0 / (resolution.max(2) as f64)
4444 }
4445 }
4446}
4447
4448/// Build the [`ChartRegion`] for a center, attaching the radial r_min / r_max
4449/// bracket for Duchon atoms (the chart's distance range to the kernel centers).
4450pub(crate) fn chart_region(
4451 atom: &SaeManifoldAtom,
4452 center: Array1<f64>,
4453 radius: f64,
4454) -> ChartRegion {
4455 use crate::manifold::SaeAtomBasisKind::*;
4456 let region = ChartRegion::new(center.clone(), radius);
4457 match atom.basis_kind() {
4458 Duchon => {
4459 // r ranges over [‖t_c‖ − radius, ‖t_c‖ + radius] about the single
4460 // origin-anchored center used by the conservative radial bound.
4461 //
4462 // The lower bound must be `max(0, center_norm − radius)` — NOT floored
4463 // at `radius`. When the chart contains the kernel center
4464 // (`center_norm < radius`, true r_min = 0), flooring at `radius`
4465 // would give a finite, NON-CONSERVATIVE `r_min`, causing the
4466 // hessian_sup / third_sup formulas (which divide by r_min) to
4467 // underestimate the Lipschitz constant and potentially grant a false
4468 // Kantorovich certificate. Flooring at `f64::MIN_POSITIVE` instead
4469 // correctly drives the formulas toward ∞, producing a very large L
4470 // that will NEVER certify (rows route to the exact multi-start
4471 // fallback) — conservative and sound.
4472 let center_norm = center.dot(¢er).sqrt();
4473 let r_min = (center_norm - radius).max(f64::MIN_POSITIVE);
4474 let r_max = center_norm + radius;
4475 region.with_radial_bounds(r_min, r_max)
4476 }
4477 // Cylinder has no radial kernel block (it is a harmonic × polynomial
4478 // tensor, not a Duchon radial basis), so it needs no radial r_min/r_max.
4479 Periodic | Sphere | Torus | ProjectivePlane | KleinBottle | Cylinder | Mobius | Linear
4480 | EuclideanPatch | Poincare | Precomputed(_) | FiniteSet => region,
4481 }
4482}
4483
4484/// Per-atom ambient tangents at the given coords: for atom `k` and row `i`, the
4485/// `d_k × p` matrix whose axis-`a` row is `∂m_k/∂t_a = (∂Φ/∂t_a)·B_k`, the image
4486/// tangent the joint Hessian couples through. `None` for an atom with no basis
4487/// evaluator (its coordinate is not differentiable, so it carries no coupling).
4488fn atom_row_tangents(
4489 atom: &SaeManifoldAtom,
4490 coords: ArrayView2<'_, f64>,
4491) -> Result<Option<Vec<Array2<f64>>>, String> {
4492 let Some(evaluator) = atom.basis_evaluator.as_ref() else {
4493 return Ok(None);
4494 };
4495 let n = coords.nrows();
4496 let d = atom.latent_dim();
4497 let p = atom.output_dim();
4498 let (_phi, jet) = evaluator.evaluate(coords)?; // jet: (n, M, d)
4499 let m = jet.shape()[1];
4500 let b = &atom.decoder_coefficients; // (M, p)
4501 let mut out = Vec::with_capacity(n);
4502 for row in 0..n {
4503 let mut tan = Array2::<f64>::zeros((d, p));
4504 for axis in 0..d {
4505 for out_col in 0..p {
4506 let mut acc = 0.0;
4507 for basis_col in 0..m {
4508 acc += jet[[row, basis_col, axis]] * b[[basis_col, out_col]];
4509 }
4510 tan[[axis, out_col]] = acc;
4511 }
4512 }
4513 out.push(tan);
4514 }
4515 Ok(Some(out))
4516}
4517
4518/// Smallest eigenvalue of the symmetric `d × d` Gauss–Newton curvature block
4519/// `z² · T Tᵀ` (`T` is `d × p`). `d = 1` is the scalar fast path; general `d`
4520/// uses a symmetric eigensolve.
4521fn min_curvature_eigenvalue(tan: &Array2<f64>, z: f64) -> Result<f64, String> {
4522 let d = tan.nrows();
4523 if d == 0 {
4524 return Ok(0.0);
4525 }
4526 let z2 = z * z;
4527 if d == 1 {
4528 let row = tan.row(0);
4529 return Ok(z2 * row.dot(&row));
4530 }
4531 let mut gram = Array2::<f64>::zeros((d, d));
4532 for a in 0..d {
4533 for bx in 0..d {
4534 gram[[a, bx]] = z2 * tan.row(a).dot(&tan.row(bx));
4535 }
4536 }
4537 let (evals, _vecs) = gram
4538 .eigh(faer::Side::Lower)
4539 .map_err(|e| format!("min_curvature_eigenvalue: eigh failed: {e:?}"))?;
4540 Ok(evals.iter().copied().fold(f64::INFINITY, f64::min))
4541}
4542
4543/// Frobenius norm of the cross-coupling block `z_k z_j · T_k T_jᵀ`
4544/// (`T_k` is `d_k × p`, `T_j` is `d_j × p`). Frobenius upper-bounds the operator
4545/// norm, so a dominance decision made against it is SOUND.
4546fn cross_block_frobenius(tan_k: &Array2<f64>, tan_j: &Array2<f64>, zk: f64, zj: f64) -> f64 {
4547 let mut acc = 0.0;
4548 for a in 0..tan_k.nrows() {
4549 for bx in 0..tan_j.nrows() {
4550 let dot = tan_k.row(a).dot(&tan_j.row(bx));
4551 acc += dot * dot;
4552 }
4553 }
4554 (zk * zj).abs() * acc.sqrt()
4555}
4556
4557/// The JOINT (multi-atom) encode-fallback fraction: the share of rows whose
4558/// per-row joint reconstruction problem across CO-ACTIVE atoms is NOT covered by
4559/// the composition of the per-atom certificates, so the row genuinely needs the
4560/// exact multi-start solve (reviewer condition #3 — the honest encode-tax cost
4561/// multiplier at scale).
4562///
4563/// The per-atom Kantorovich certificate certifies each atom's coordinate encode
4564/// IN ISOLATION — a block-diagonal view of the joint Hessian. The joint problem
4565/// couples co-active atoms through the off-diagonal blocks
4566/// `H_kj = z_k z_j J_k(t_k)ᵀ J_j(t_j)` (tangent-image inner products). When an
4567/// atom's own curvature block fails to dominate its coupling to the rest —
4568/// Gershgorin: `λ_min(H_kk) ≤ Σ_{j≠k} ‖H_kj‖` — the block-diagonal certificate
4569/// no longer implies a joint root, a second basin can open, and the row must go
4570/// to multi-start. This fraction GROWS with atom-image similarity and
4571/// co-activation: no per-atom certificate covers the joint problem.
4572///
4573/// The curvature block uses the Gauss–Newton form `z_k² J_kᵀ J_k` (exact for
4574/// flat/linear atoms, where the residual-curvature term vanishes identically);
4575/// the off-diagonal is measured in Frobenius norm, which upper-bounds the
4576/// operator norm, so a row DECLARED dominant is genuinely dominant and the
4577/// fraction never under-reports the multi-start need. Rows with fewer than two
4578/// co-active atoms have no cross blocks and are never counted as fallbacks.
4579///
4580/// `amplitude_floor` is the mass above which an atom counts as co-active; pass a
4581/// small positive value (a domain threshold on the assignment mass, not a solver
4582/// knob).
4583pub fn joint_encode_fallback_fraction(
4584 atoms: &[SaeManifoldAtom],
4585 coords: &[Array2<f64>],
4586 amplitudes: ArrayView2<'_, f64>,
4587 amplitude_floor: f64,
4588) -> Result<f64, String> {
4589 let k_atoms = atoms.len();
4590 let (n, amp_k) = amplitudes.dim();
4591 if amp_k != k_atoms {
4592 return Err(format!(
4593 "joint_encode_fallback_fraction: amplitudes have {amp_k} cols but {k_atoms} atoms"
4594 ));
4595 }
4596 if coords.len() != k_atoms {
4597 return Err(format!(
4598 "joint_encode_fallback_fraction: {} coord blocks but {k_atoms} atoms",
4599 coords.len()
4600 ));
4601 }
4602 if n == 0 || k_atoms == 0 {
4603 return Ok(0.0);
4604 }
4605 // F5 — FILTER BEFORE MATERIALIZE: the dense form built the full `n × K`
4606 // tangent tensor (`atom_row_tangents` over every atom, all N rows) BEFORE the
4607 // per-row activity filter, which OOMs at `K = 32k` even though each row couples
4608 // only through its co-active atoms. Cross-coupling exists only among a row's
4609 // co-active, differentiable atoms (`z > floor`), so materialize just that row's
4610 // active tangents, lazily, per row. `atom_row_tangents` is row-wise, so the
4611 // single-row slice is bit-identical to the batched evaluate the dense path
4612 // indexed — the fallback fraction is unchanged; only the peak allocation drops
4613 // from `O(n·K·d·p)` to the max active-set size per row.
4614 for (atom_idx, coord) in coords.iter().enumerate() {
4615 if coord.nrows() != n {
4616 return Err(format!(
4617 "joint_encode_fallback_fraction: coord block {atom_idx} has {} rows, expected {n}",
4618 coord.nrows()
4619 ));
4620 }
4621 }
4622 let mut fallback_rows = 0usize;
4623 for row in 0..n {
4624 // Gather this row's co-active, differentiable atoms (evaluator-less atoms
4625 // carry no coupling — same `is_some()` predicate the dense path applied via
4626 // `tangents[k].is_some()`).
4627 let active: Vec<usize> = (0..k_atoms)
4628 .filter(|&k| {
4629 amplitudes[[row, k]] > amplitude_floor && atoms[k].basis_evaluator.is_some()
4630 })
4631 .collect();
4632 if active.len() < 2 {
4633 continue; // no cross blocks: the per-atom certificate composes trivially
4634 }
4635 // Materialize ONLY this row's active tangents (one `d × p` block per active
4636 // atom), never the dense tensor. An atom that unexpectedly yields no tangent
4637 // (evaluator present but non-differentiable) simply drops out of the coupling,
4638 // exactly as a `None` block did in the dense path.
4639 let mut tans: Vec<Array2<f64>> = Vec::with_capacity(active.len());
4640 let mut zs: Vec<f64> = Vec::with_capacity(active.len());
4641 for &k in &active {
4642 let coord_row = coords[k].row(row).insert_axis(Axis(0)); // (1, d)
4643 if let Some(mut block) = atom_row_tangents(&atoms[k], coord_row)? {
4644 tans.push(block.pop().expect("single-row tangents carry one block"));
4645 zs.push(amplitudes[[row, k]]);
4646 }
4647 }
4648 if tans.len() < 2 {
4649 continue;
4650 }
4651 let mut row_needs_multistart = false;
4652 for (ki, tan_k) in tans.iter().enumerate() {
4653 let zk = zs[ki];
4654 let lam_min = min_curvature_eigenvalue(tan_k, zk)?;
4655 let mut coupling = 0.0;
4656 for (ji, tan_j) in tans.iter().enumerate() {
4657 if ji == ki {
4658 continue;
4659 }
4660 coupling += cross_block_frobenius(tan_k, tan_j, zk, zs[ji]);
4661 }
4662 if lam_min <= coupling {
4663 row_needs_multistart = true;
4664 break;
4665 }
4666 }
4667 if row_needs_multistart {
4668 fallback_rows += 1;
4669 }
4670 }
4671 Ok(fallback_rows as f64 / n as f64)
4672}
4673
4674#[cfg(test)]
4675mod encode_fix_tests {
4676 //! Unit tests for the two `certify_with_basin_warmup` fixes:
4677 //! FIX #3 — wrap-aware chart containment (`in_chart` now measures latent
4678 //! distance on the atom's periodic geometry via
4679 //! [`latent_coordinate_distance`]).
4680 //! FIX #4 — multiplicative sufficient-decrease bound on the warm-up loop
4681 //! ([`warmup_progress_sufficient`]).
4682 use super::*;
4683 use crate::manifold::SaeAtomBasisKind;
4684 use ndarray::{Array1, Array2, Array3};
4685
4686 /// Minimal atom carrying only a `basis_kind`; the wrap-aware metric reads no
4687 /// other field, so a tiny well-formed basis/decoder/penalty suffices.
4688 fn tiny_atom(kind: SaeAtomBasisKind, latent_dim: usize) -> SaeManifoldAtom {
4689 let m = 2usize;
4690 let phi = Array2::<f64>::eye(m);
4691 let jet = Array3::<f64>::zeros((m, m, latent_dim));
4692 let dec = Array2::<f64>::from_elem((m, 1), 0.5);
4693 let smooth = Array2::<f64>::eye(m);
4694 SaeManifoldAtom::new_with_provided_function_gram(
4695 "tiny", kind, latent_dim, phi, jet, dec, smooth,
4696 )
4697 .expect("tiny atom builds")
4698 }
4699
4700 #[test]
4701 fn joint_normal_equations_use_the_shared_multi_atom_residual() {
4702 // u1=(1,0), u2=(1,1), x=(2,1). At t=(0,0), the shared residual is -x.
4703 // The joint normal equations recover the unique coefficients (1,1);
4704 // independent projections would instead produce (2,1.5).
4705 let jac = ndarray::array![[1.0_f64, 0.0], [1.0, 1.0]];
4706 let residual = ndarray::array![-2.0_f64, -1.0];
4707 let (_value, grad, hess) = joint_data_value_grad_hess(jac.view(), residual.view(), None);
4708 let step = joint_encode_damped_step(hess.view(), grad.view(), 1.0e-15)
4709 .expect("joint system factors")
4710 .expect("joint system is positive definite");
4711 assert!(
4712 (step[0] - 1.0).abs() < 1.0e-12,
4713 "first coefficient={}",
4714 step[0]
4715 );
4716 assert!(
4717 (step[1] - 1.0).abs() < 1.0e-12,
4718 "second coefficient={}",
4719 step[1]
4720 );
4721 let recon0 = step[0] + step[1];
4722 let recon1 = step[1];
4723 assert!((recon0 - 2.0).abs() < 1.0e-12 && (recon1 - 1.0).abs() < 1.0e-12);
4724 }
4725
4726 /// FIX #3: a point on the far side of the wrap seam of a periodic axis is now
4727 /// IN-CHART under the wrap-aware metric, where the old raw-Euclidean sum used
4728 /// by `in_chart` rejected it. This is the exact predicate `in_chart` evaluates
4729 /// (`latent_coordinate_distance(atom, t, center) <= radius`).
4730 #[test]
4731 fn wrap_aware_containment_accepts_seam_point() {
4732 let atom = tiny_atom(SaeAtomBasisKind::Periodic, 1);
4733 let t = Array1::from(vec![0.99f64]);
4734 let center = Array1::from(vec![0.01f64]);
4735 let radius = 0.05;
4736
4737 // Precondition: the OLD raw-Euclidean latent distance is 0.98 — far
4738 // outside the 0.05 ball, so the old `in_chart` REJECTED this iterate.
4739 let raw = (t[0] - center[0]).abs();
4740 assert!(
4741 raw > radius,
4742 "precondition: raw Euclidean distance {raw} must exceed the chart radius {radius}"
4743 );
4744
4745 // The wrap-aware metric sees the true circle distance 0.02 (period 1.0),
4746 // so the seam point is now correctly IN-CHART.
4747 let d = latent_coordinate_distance(&atom, t.view(), center.view());
4748 assert!(
4749 (d - 0.02).abs() < 1e-12,
4750 "wrap-aware distance across the seam must be 0.02, got {d}"
4751 );
4752 assert!(
4753 d <= radius,
4754 "wrap-aware seam point must now be in-chart (d={d} <= r={radius})"
4755 );
4756 }
4757
4758 /// Soundness guard for FIX #3: on a NON-periodic axis the metric must NOT
4759 /// wrap — the same coordinates stay at their full Euclidean separation and
4760 /// (correctly) remain OUT of the small ball. This preserves the
4761 /// never-issue-a-false-certificate invariant for flat-patch families.
4762 #[test]
4763 fn wrap_aware_containment_does_not_wrap_flat_axis() {
4764 let atom = tiny_atom(SaeAtomBasisKind::EuclideanPatch, 1);
4765 let t = Array1::from(vec![0.99f64]);
4766 let center = Array1::from(vec![0.01f64]);
4767 let d = latent_coordinate_distance(&atom, t.view(), center.view());
4768 assert!(
4769 (d - 0.98).abs() < 1e-12,
4770 "a flat (non-periodic) axis must keep the full 0.98 distance, got {d}"
4771 );
4772 assert!(
4773 d > 0.05,
4774 "flat-axis point correctly stays out of a 0.05 ball"
4775 );
4776 }
4777
4778 /// FIX #4: a genuinely converging (Kantorovich-quadratic) `h`-sequence is
4779 /// untouched — every step clears the sufficient-decrease bar, so no
4780 /// converging row is regressed to the fallback.
4781 #[test]
4782 fn converging_h_sequence_is_never_flagged() {
4783 // Quadratic Newton contraction h_{k+1} = h_k^2 from an uncertified start.
4784 let mut h = 0.9f64;
4785 while h > KANTOROVICH_THRESHOLD {
4786 let h_next = h * h;
4787 assert!(
4788 warmup_progress_sufficient(h_next, h),
4789 "converging step {h} -> {h_next} must be accepted (no regression)"
4790 );
4791 h = h_next;
4792 }
4793 }
4794
4795 /// FIX #4: a monotone `h`-sequence decreasing toward a limit ABOVE ½ (the
4796 /// pathological plateau that the old strict-decrease rule spun on until the
4797 /// increments fell below one ulp) now terminates in a BOUNDED, small number
4798 /// of `warmup_progress_sufficient` steps — flag-to-fallback rather than loop.
4799 #[test]
4800 fn plateau_h_sequence_terminates_bounded() {
4801 let limit = 0.65f64; // plateau limit strictly above ½: never certifies
4802 let ratio = 0.8f64; // geometric approach; ratio -> 1 as h -> limit
4803 let mut h = 2.0f64; // uncertified start
4804 let start = h;
4805 let mut accepted = 0usize;
4806 let mut iters = 0usize;
4807 loop {
4808 iters += 1;
4809 // Hard ceiling: proves boundedness. The OLD strict-decrease rule
4810 // would run ~1e15 steps here (h strictly decreases every step toward
4811 // 0.65 and only stalls below one ulp), so tripping this assert would
4812 // signal a regression to the unbounded behavior.
4813 assert!(
4814 iters < 10_000,
4815 "warm-up must terminate in a bounded number of steps, not loop"
4816 );
4817 let h_next = limit + (h - limit) * ratio; // monotone decreasing > limit
4818 if !warmup_progress_sufficient(h_next, h) {
4819 break; // flag to the exact fallback
4820 }
4821 accepted += 1;
4822 h = h_next;
4823 }
4824 // It made real progress before flagging (a warm-up, not an instant bail)…
4825 assert!(
4826 accepted >= 1,
4827 "expected the warm-up to accept at least one contracting step first"
4828 );
4829 // …and it flagged while still strictly above the plateau limit — exactly
4830 // where the OLD rule would have kept spinning (h is still shrinking).
4831 assert!(
4832 h > limit && h < start,
4833 "flagged mid-descent (limit < h={h} < start={start}); old rule would not stop here"
4834 );
4835 // Termination bound was tiny, not astronomical.
4836 assert!(iters < 200, "plateau flagged in {iters} steps (bounded)");
4837 }
4838
4839 /// FIX #4: non-finite `h` (indefinite / blown-up Hessian) is treated as
4840 /// insufficient progress — the row flags rather than being accepted.
4841 #[test]
4842 fn non_finite_h_flags() {
4843 assert!(!warmup_progress_sufficient(f64::NAN, 0.9));
4844 assert!(!warmup_progress_sufficient(f64::INFINITY, 0.9));
4845 assert!(!warmup_progress_sufficient(0.5, f64::NAN));
4846 }
4847
4848 /// BUG (sphere data-driven placement): `coord_dist_sq` must measure sphere
4849 /// coordinates in RADIANS via the canonical `latent_axis_period` — longitude
4850 /// (axis 1) wraps at 2π, latitude (axis 0) does NOT wrap — not at unit period
4851 /// on both axes. The old period-1 wrap collapsed genuinely-far longitudes to
4852 /// distance 0, corrupting `data_driven_chart_centers` for sphere atoms.
4853 #[test]
4854 fn coord_dist_sq_sphere_uses_radian_metric_not_unit_period() {
4855 let atom = tiny_atom(SaeAtomBasisKind::Sphere, 2);
4856 // Longitude differs by 3.0 rad: circle distance min(3, 2π−3)=3
4857 // (2π−3 ≈ 3.283) ⇒ dist² = 9. The old period-1 wrap read 3 mod 1 = 0.
4858 let a = Array1::from(vec![0.0f64, 0.0]);
4859 let b = Array1::from(vec![0.0f64, 3.0]);
4860 let dsq = coord_dist_sq(&atom, a.view(), b.view());
4861 assert!(
4862 (dsq - 9.0).abs() < 1e-9,
4863 "sphere longitude dist² must be 3²=9 (2π radian period), got {dsq}"
4864 );
4865 // Latitude (axis 0) must NOT wrap: 1.2 rad apart ⇒ dist² = 1.44.
4866 let c = Array1::from(vec![0.0f64, 0.0]);
4867 let e = Array1::from(vec![1.2f64, 0.0]);
4868 let dsq_lat = coord_dist_sq(&atom, c.view(), e.view());
4869 assert!(
4870 (dsq_lat - 1.44).abs() < 1e-9,
4871 "sphere latitude must not wrap; 1.2²=1.44, got {dsq_lat}"
4872 );
4873 // Must agree with the certified-encode metric (single source of truth).
4874 let dc = latent_coordinate_distance(&atom, a.view(), b.view());
4875 assert!(
4876 (dc * dc - dsq).abs() < 1e-9,
4877 "coord_dist_sq must equal latent_coordinate_distance² ({} vs {dsq})",
4878 dc * dc
4879 );
4880 }
4881
4882 /// No-regression: periodic / torus axes still wrap at unit period.
4883 #[test]
4884 fn coord_dist_sq_torus_still_wraps_unit_period() {
4885 let atom = tiny_atom(SaeAtomBasisKind::Torus, 1);
4886 let a = Array1::from(vec![0.02f64]);
4887 let b = Array1::from(vec![0.98f64]);
4888 // Wrapped circle distance min(0.96, 0.04) = 0.04 ⇒ dist² = 0.0016.
4889 let dsq = coord_dist_sq(&atom, a.view(), b.view());
4890 assert!(
4891 (dsq - 0.0016).abs() < 1e-12,
4892 "torus unit-period wrap; got {dsq}"
4893 );
4894 }
4895
4896 /// BUG (sphere chart tiling): `chart_nominal_radius` for the sphere must use
4897 /// the SAME capped per-axis count `SHAPE_BAND_MAX_POINTS.isqrt()` as
4898 /// `sphere_latlon_grid`, so the radius covers the (capped) longitude
4899 /// half-spacing `π/r_cap` and the charts tile without gaps for
4900 /// `resolution > r_cap` (raw π/40 would leave gaps). `r_cap` is derived from
4901 /// the band-point budget (#2071), so this test tracks it rather than pinning 22.
4902 #[test]
4903 fn chart_nominal_radius_sphere_covers_capped_grid_spacing() {
4904 let atom = tiny_atom(SaeAtomBasisKind::Sphere, 2);
4905 let r_cap = SHAPE_BAND_MAX_POINTS.isqrt(); // 22 at the current 512-point budget
4906 let resolution = r_cap * 2; // any resolution past the cap exercises the gap hazard
4907 let lon_half_spacing = std::f64::consts::PI / r_cap as f64;
4908 let r = chart_nominal_radius(&atom, resolution);
4909 assert!(
4910 r >= lon_half_spacing - 1e-12,
4911 "sphere radius {r} must cover the capped lon half-spacing {lon_half_spacing} \
4912 (no gaps for resolution>r_cap); raw π/{resolution}={} would gap",
4913 std::f64::consts::PI / resolution as f64
4914 );
4915 }
4916
4917 // ---------------------------------------------------------------------
4918 // F1: amplitude-aware chart routing.
4919 // ---------------------------------------------------------------------
4920
4921 /// A single certifiable chart with the given amplitude-1 center reconstruction.
4922 fn chart_with_recon(recon: Vec<f64>) -> CertifiedChart {
4923 CertifiedChart {
4924 region: ChartRegion::new(Array1::zeros(1), 1.0),
4925 lipschitz: 1.0,
4926 beta_center: 1.0,
4927 certified_radius: 1.0,
4928 amortized_jacobian: None,
4929 recon_center: Array1::from(recon),
4930 amortized_base: None,
4931 }
4932 }
4933
4934 fn atlas_two_charts(m1: f64, m2: f64) -> AtomEncodeAtlas {
4935 AtomEncodeAtlas {
4936 atom_index: 0,
4937 latent_dim: 1,
4938 decoder_norm_sum: 1.0,
4939 charts: vec![chart_with_recon(vec![m1]), chart_with_recon(vec![m2])],
4940 }
4941 }
4942
4943 /// F1 counterexample (module review): chart centers reconstruct (amplitude 1)
4944 /// to `m₁ = 1` and `m₂ = 10`. A row `x = 1` at amplitude `z = 0.1`
4945 /// reconstructs as `z·m`, so chart 2 is EXACT (`0.1·10 = 1 = x`) and chart 1 is
4946 /// wrong (`0.1·1 = 0.1`, error `0.9`). Amplitude-blind routing on the
4947 /// amplitude-1 centers picks chart 1 (`|1−1| = 0 < |10−1| = 9`); the
4948 /// amplitude-aware fix picks chart 2.
4949 #[test]
4950 fn f1_routing_scores_amplitude_scaled_reconstruction() {
4951 let atlas = atlas_two_charts(1.0, 10.0);
4952 let x = Array1::from(vec![1.0]);
4953
4954 let (idx, _) = nearest_chart(&atlas, x.view(), 0.1).expect("routes");
4955 assert_eq!(
4956 idx, 1,
4957 "z=0.1: must route to the m=10 chart (z·m=1 exact), not m=1"
4958 );
4959
4960 let ranked = nearest_charts_topk(&atlas, x.view(), 0.1, 2);
4961 assert_eq!(ranked[0], 1, "z=0.1: nearest chart is the m=10 chart");
4962
4963 // Negative amplitude: z=-0.1 makes z·m₂ = -1 (error 2) and z·m₁ = -0.1
4964 // (error 1.1), so chart 1 is now nearer — the sign is respected.
4965 let (idxn, _) = nearest_chart(&atlas, x.view(), -0.1).expect("routes");
4966 assert_eq!(idxn, 0, "z=-0.1: sign-aware routing prefers the m=1 chart");
4967
4968 // No-regression at z=1: recovers the amplitude-1 argmin (m=1 chart exact).
4969 let (idx1, _) = nearest_chart(&atlas, x.view(), 1.0).expect("routes");
4970 assert_eq!(idx1, 0, "z=1 recovers the amplitude-1 nearest (m=1) chart");
4971 assert_eq!(nearest_charts_topk(&atlas, x.view(), 1.0, 1)[0], 0);
4972 }
4973
4974 // ---------------------------------------------------------------------
4975 // F2: the certificate uses the TRUE (un-ridged) Hessian.
4976 // ---------------------------------------------------------------------
4977
4978 /// A basis with constant `Φ`, zero Jacobian, and zero second jet: the
4979 /// reconstruction is locally CONSTANT, so the true encode Hessian is `0`
4980 /// (singular) and the coordinate is non-unique.
4981 #[derive(Debug)]
4982 struct ConstantPhi {
4983 m: usize,
4984 d: usize,
4985 }
4986 impl SaeBasisEvaluator for ConstantPhi {
4987 fn evaluate(
4988 &self,
4989 coords: ndarray::ArrayView2<'_, f64>,
4990 ) -> Result<(Array2<f64>, Array3<f64>), String> {
4991 let n = coords.nrows();
4992 Ok((
4993 Array2::ones((n, self.m)),
4994 Array3::zeros((n, self.m, self.d)),
4995 ))
4996 }
4997 fn second_jet_dyn(
4998 &self,
4999 coords: ndarray::ArrayView2<'_, f64>,
5000 ) -> Option<Result<ndarray::Array4<f64>, String>> {
5001 let n = coords.nrows();
5002 Some(Ok(ndarray::Array4::zeros((n, self.m, self.d, self.d))))
5003 }
5004 fn third_jet_dyn(
5005 &self,
5006 coords: ndarray::ArrayView2<'_, f64>,
5007 ) -> Option<Result<ndarray::Array5<f64>, String>> {
5008 if coords.ncols() != self.d {
5009 return Some(Err(format!(
5010 "ConstantPhi::third_jet_dyn: expected d = {}, got {} coords",
5011 self.d,
5012 coords.ncols()
5013 )));
5014 }
5015 None
5016 }
5017 }
5018
5019 /// F2: a locally-constant reconstruction has a SINGULAR true Hessian (`H = 0`),
5020 /// so the point is NOT a genuine isolated minimum and must NOT be certified.
5021 /// The old code ridged the certified Hessian (`H → ridge·I`, PD), faking
5022 /// `β = 1/ridge`, `η = 0`, `h = 0 ≤ ½` — a FALSE certificate. With the true
5023 /// Hessian (`encode_grad_hess` no longer adds ridge) `beta_eta_newton` sees
5024 /// `λ_min = 0` and refuses.
5025 #[test]
5026 fn f2_certificate_uses_true_hessian_refuses_singular_field() {
5027 let atom = tiny_atom(SaeAtomBasisKind::EuclideanPatch, 1);
5028 let eval = ConstantPhi {
5029 m: atom.basis_size(),
5030 d: 1,
5031 };
5032 let t0 = Array1::from(vec![0.0]);
5033 let x = Array1::from(vec![0.5]);
5034
5035 let (g, h) = encode_grad_hess(&atom, &eval, t0.view(), x.view(), 1.0)
5036 .expect("encode_grad_hess runs")
5037 .expect("second jet present ⇒ Some");
5038 assert!(
5039 h.iter().all(|&v| v == 0.0),
5040 "the TRUE Hessian of a constant reconstruction is 0 — no ridge is added \
5041 to the certified field; got {h:?}"
5042 );
5043 assert!(
5044 g.iter().all(|&v| v == 0.0),
5045 "gradient is 0 at a flat reconstruction"
5046 );
5047
5048 let (cert, _) = row_certificate(&atom, &eval, t0.view(), x.view(), 1.0, 1.0)
5049 .expect("row_certificate runs");
5050 assert!(
5051 !cert.certified(),
5052 "a singular true Hessian must NOT be certified (the old ridged H falsely did)"
5053 );
5054 assert!(
5055 !cert.beta.is_finite(),
5056 "β must be ∞ (uncertifiable), never the ridge-faked 1/ridge; got {}",
5057 cert.beta
5058 );
5059 }
5060
5061 // ---------------------------------------------------------------------
5062 // F3: Duchon atoms are refused (honest refusal, never a fabricated bound).
5063 // ---------------------------------------------------------------------
5064
5065 /// F3: `build_atom_atlas_from_centers` must emit ONLY uncertified charts for a
5066 /// Duchon atom — the closed-form bound available here would fabricate cubic-r³
5067 /// jets and an origin center for a polyharmonic `c·r^(2m−d)` kernel over
5068 /// data-placed centers, risking an under-estimate of `L` (false certificate).
5069 /// The refusal routes every Duchon row to the exact multi-start encode. A
5070 /// non-Duchon control atom is NOT auto-zeroed by this guard.
5071 #[test]
5072 fn f3_duchon_atoms_are_uncertifiable() {
5073 let atom = tiny_atom(SaeAtomBasisKind::Duchon, 1);
5074 let centers = ndarray::array![[0.0_f64], [0.3], [0.7]];
5075 let radii = vec![0.1_f64, 0.1, 0.1];
5076 let atlas = EncodeAtlas::build_atom_atlas_from_centers(
5077 0,
5078 &atom,
5079 centers.view(),
5080 &radii,
5081 1.0,
5082 1.0,
5083 &AtlasConfig::default(),
5084 )
5085 .expect("duchon atlas builds (uncertified)");
5086 assert_eq!(atlas.charts.len(), 3, "one chart per center");
5087 for (i, chart) in atlas.charts.iter().enumerate() {
5088 assert_eq!(
5089 chart.certified_radius, 0.0,
5090 "duchon chart {i} must be uncertified (refused), got r={}",
5091 chart.certified_radius
5092 );
5093 assert!(
5094 chart.amortized_jacobian.is_none(),
5095 "duchon chart {i} must carry no amortized predictor"
5096 );
5097 }
5098 }
5099
5100 // ---------------------------------------------------------------------
5101 // F6: an already-certified warm-up step is never rejected by the progress rule.
5102 // ---------------------------------------------------------------------
5103
5104 /// F6: a step that just crossed into the certified region (`h ≤ ½`) is accepted
5105 /// even when its multiplicative decrease was below the progress floor
5106 /// (`0.501 → 0.499`). Only a STILL-uncertified plateau step is rejected. The
5107 /// old unconditional progress test rejected the `0.501 → 0.499` cross — a false
5108 /// negative that forced an unnecessary exact-solve fallback.
5109 #[test]
5110 fn f6_certified_step_not_rejected_by_progress_rule() {
5111 // 0.501 → 0.499: below the 1/64 multiplicative floor, and NOT the quadratic
5112 // path, so `warmup_progress_sufficient` is false…
5113 assert!(
5114 !warmup_progress_sufficient(0.499, 0.501),
5115 "precondition: this tiny decrease fails the progress bar"
5116 );
5117 // …but because the step is now CERTIFIED it must NOT be rejected (F6).
5118 assert!(
5119 !warmup_should_reject(true, 0.499, 0.501),
5120 "a certified step must be accepted regardless of the progress floor"
5121 );
5122 // A still-uncertified plateau step IS rejected (the guard still bites).
5123 assert!(
5124 warmup_should_reject(false, 0.499, 0.501),
5125 "an uncertified sub-floor step is a plateau and must flag to fallback"
5126 );
5127 // A certified step that ALSO made big progress is accepted (sanity).
5128 assert!(!warmup_should_reject(true, 0.1, 0.9));
5129 // A genuinely-contracting uncertified step is accepted (no regression).
5130 assert!(!warmup_should_reject(false, 0.4, 0.9));
5131 }
5132
5133 // A singular Hessian is not invertible as the derivative F'(t). Replacing
5134 // its null eigenvalue by an arbitrary stiffness describes a different map,
5135 // so it must never produce an ordinary Kantorovich certificate.
5136 #[test]
5137 fn beta_eta_newton_refuses_rank1_null_2x2() {
5138 let h = ndarray::array![[4.0_f64, 0.0], [0.0, 0.0]];
5139 let g = Array1::from(vec![4.0_f64, 0.0]);
5140 assert!(beta_eta_newton(h.view(), g.view()).expect("runs").is_none());
5141 }
5142
5143 /// Refusal is independent of whether the observed gradient happens to have a
5144 /// small projection onto the null direction: that observation cannot turn a
5145 /// singular derivative into the derivative required by the theorem.
5146 #[test]
5147 fn beta_eta_newton_refuses_null_with_projected_gradient() {
5148 let h = ndarray::array![[4.0_f64, 0.0], [0.0, 0.0]];
5149 let g = Array1::from(vec![4.0_f64, 1.0e-3]);
5150 assert!(beta_eta_newton(h.view(), g.view()).expect("runs").is_none());
5151 }
5152
5153 /// A genuinely INDEFINITE 2×2 (one negative eigenvalue) is STILL refused —
5154 /// deflation must not manufacture a certificate at a saddle/max of the encode
5155 /// objective (the pre-existing negative-curvature guard is preserved).
5156 #[test]
5157 fn beta_eta_newton_refuses_genuine_negative_curvature_2x2() {
5158 let h = ndarray::array![[4.0_f64, 0.0], [0.0, -2.0]];
5159 let g = Array1::from(vec![1.0_f64, 1.0]);
5160 let out = beta_eta_newton(h.view(), g.view()).expect("runs");
5161 assert!(
5162 out.is_none(),
5163 "a negative-curvature (indefinite) start must NOT certify — it is at/past a \
5164 basin boundary; deflating it to +1 would be a false certificate"
5165 );
5166 }
5167
5168 /// The general eigen path applies the same refusal to a rank-deficient PSD
5169 /// block instead of silently changing its spectrum.
5170 #[test]
5171 fn beta_eta_newton_refuses_rank_deficient_3x3() {
5172 let h = ndarray::array![[4.0_f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]];
5173 let g = Array1::from(vec![4.0_f64, 1.0, 0.0]);
5174 assert!(beta_eta_newton(h.view(), g.view()).expect("runs").is_none());
5175 }
5176
5177 /// A healthy positive-definite 2×2 is UNCHANGED by the fix: it keeps the
5178 /// closed-form fast path, β = 1/λ_min, and the Newton step solves H·δ = −g.
5179 #[test]
5180 fn beta_eta_newton_healthy_pd_unchanged() {
5181 let h = ndarray::array![[4.0_f64, 1.0], [1.0, 3.0]];
5182 let g = Array1::from(vec![2.0_f64, -1.0]);
5183 let (beta, eta, delta) = beta_eta_newton(h.view(), g.view())
5184 .expect("runs")
5185 .expect("a PD block certifies");
5186 // λ_min = ½(7 − √5); β = 1/λ_min.
5187 let lambda_min = 0.5 * (7.0 - 5.0_f64.sqrt());
5188 assert!((beta - 1.0 / lambda_min).abs() < 1e-9, "β={beta}");
5189 // Newton normal equations: H·δ = −g.
5190 let hd0 = h[[0, 0]] * delta[0] + h[[0, 1]] * delta[1];
5191 let hd1 = h[[1, 0]] * delta[0] + h[[1, 1]] * delta[1];
5192 assert!((hd0 + g[0]).abs() < 1e-9 && (hd1 + g[1]).abs() < 1e-9);
5193 assert!((eta - delta.dot(&delta).sqrt()).abs() < 1e-12);
5194 }
5195}
5196
5197#[cfg(test)]
5198mod joint_fallback_tests {
5199 //! Reviewer condition #3 — the multi-start-fallback fraction is an honest
5200 //! encode-tax number that GROWS with atom-image similarity / co-activation
5201 //! interference. These tests drive `joint_encode_fallback_fraction` over a
5202 //! similarity sweep on flat (linear) atoms, where the Gauss–Newton curvature
5203 //! block is EXACT (no residual-curvature term), so the Gershgorin dominance
5204 //! decision is the true joint certificate and the curve is analytic.
5205 use super::*;
5206 use crate::manifold::SaeAtomBasisKind;
5207 use ndarray::Array2;
5208 use std::sync::Arc;
5209
5210 /// Deterministic LCG (no `rand` dependency) for reproducible amplitudes.
5211 struct Lcg(u64);
5212 impl Lcg {
5213 fn unit(&mut self) -> f64 {
5214 self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
5215 ((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
5216 }
5217 }
5218
5219 /// Build `k` degree-1 (flat) atoms in `R^p` whose image TANGENT directions
5220 /// have common pairwise cosine `rho`: `dir_k = √rho · e0 + √(1−rho) · e_{k+1}`
5221 /// with `{e0, e1, …}` orthonormal. Requires `p ≥ k + 1`. Each atom's decoder
5222 /// puts the tangent on the degree-1 monomial row, so `∂m/∂t = dir_k` exactly.
5223 fn similar_linear_atoms(k: usize, p: usize, rho: f64) -> Vec<SaeManifoldAtom> {
5224 assert!(p >= k + 1, "need p >= k+1 orthonormal directions");
5225 let evaluator = Arc::new(EuclideanPatchEvaluator::new(1, 1).expect("degree-1 patch"));
5226 // A single coordinate row is enough to pin the (constant) tangent; the
5227 // diagnostic re-evaluates the jet at whatever coords it is given.
5228 let coord = Array2::<f64>::zeros((1, 1));
5229 let (phi, jet) = evaluator.evaluate(coord.view()).expect("evaluate");
5230 let m = phi.ncols();
5231 (0..k)
5232 .map(|atom_idx| {
5233 let mut dec = Array2::<f64>::zeros((m, p));
5234 // Row 1 is the degree-1 monomial (`∂φ/∂t = 1` there); place the
5235 // unit tangent direction on it.
5236 dec[[1, 0]] = rho.sqrt();
5237 dec[[1, atom_idx + 1]] = (1.0 - rho).sqrt();
5238 SaeManifoldAtom::new_with_provided_function_gram(
5239 "lin",
5240 SaeAtomBasisKind::EuclideanPatch,
5241 1,
5242 phi.clone(),
5243 jet.clone(),
5244 dec,
5245 Array2::<f64>::eye(m),
5246 )
5247 .expect("atom builds")
5248 .with_basis_second_jet(evaluator.clone())
5249 })
5250 .collect()
5251 }
5252
5253 /// The joint multi-start-fallback fraction is MONOTONE NON-DECREASING in
5254 /// atom-image similarity, is exactly zero when the atoms are orthogonal, and
5255 /// is strictly positive once the images are strongly aligned — the honest
5256 /// encode-tax cost multiplier the reviewer asks for.
5257 #[test]
5258 fn joint_fallback_fraction_rises_with_atom_similarity() {
5259 let k = 4usize;
5260 let p = 8usize;
5261 let n = 400usize;
5262 // Per-row amplitudes: a spread of masses so that at intermediate
5263 // similarity SOME rows (those with an atom whose mass is dominated by its
5264 // co-active neighbours) tip out of Gershgorin dominance while others stay
5265 // in — a smooth curve rather than a step.
5266 let mut rng = Lcg(42);
5267 let amplitudes = Array2::from_shape_fn((n, k), |_| 0.2 + 1.3 * rng.unit());
5268 // Coordinates are irrelevant for flat atoms (constant tangent), but the
5269 // diagnostic still evaluates the jet at them.
5270 let coords: Vec<Array2<f64>> = (0..k).map(|_| Array2::<f64>::zeros((n, 1))).collect();
5271 let floor = 1.0e-9;
5272
5273 let sweep = [0.0_f64, 0.2, 0.4, 0.6, 0.8, 0.95];
5274 let mut fractions = Vec::new();
5275 for &rho in &sweep {
5276 let atoms = similar_linear_atoms(k, p, rho);
5277 let frac = joint_encode_fallback_fraction(&atoms, &coords, amplitudes.view(), floor)
5278 .expect("joint fallback fraction computes");
5279 assert!(
5280 (0.0..=1.0).contains(&frac),
5281 "fallback fraction must be a probability, got {frac} at rho={rho}"
5282 );
5283 fractions.push(frac);
5284 }
5285 eprintln!(
5286 "[ENCODE-FALLBACK-SWEEP] similarity rho={:?} -> joint multistart fraction={:?}",
5287 sweep, fractions
5288 );
5289 // Orthogonal atoms: the block-diagonal per-atom certificates compose, no
5290 // row needs multi-start.
5291 assert!(
5292 fractions[0] == 0.0,
5293 "orthogonal atoms must need no multi-start fallback, got {}",
5294 fractions[0]
5295 );
5296 // Monotone non-decreasing across the similarity sweep.
5297 for w in fractions.windows(2) {
5298 assert!(
5299 w[1] >= w[0] - 1.0e-12,
5300 "fallback fraction must not DECREASE as similarity rises: {:?}",
5301 fractions
5302 );
5303 }
5304 // Strongly-aligned images force a materially higher fallback fraction —
5305 // the effect is real, not a rounding wobble.
5306 assert!(
5307 *fractions.last().unwrap() > 0.25,
5308 "strong atom similarity must drive a substantial multi-start tail; curve={fractions:?}"
5309 );
5310 }
5311
5312 /// A single co-active atom per row has no cross blocks, so the joint fallback
5313 /// fraction is zero regardless of how curved or ill-conditioned the atom is —
5314 /// the joint tax is purely a CO-ACTIVATION phenomenon.
5315 #[test]
5316 fn joint_fallback_zero_without_coactivation() {
5317 let k = 3usize;
5318 let p = 8usize;
5319 let n = 50usize;
5320 let atoms = similar_linear_atoms(k, p, 0.9); // highly similar, but…
5321 // …only ONE atom active per row (block-diagonal one-hot amplitudes).
5322 let mut amplitudes = Array2::<f64>::zeros((n, k));
5323 for row in 0..n {
5324 amplitudes[[row, row % k]] = 1.0;
5325 }
5326 let coords: Vec<Array2<f64>> = (0..k).map(|_| Array2::<f64>::zeros((n, 1))).collect();
5327 let frac = joint_encode_fallback_fraction(&atoms, &coords, amplitudes.view(), 1.0e-9)
5328 .expect("computes");
5329 assert!(
5330 frac == 0.0,
5331 "no co-activation ⇒ no joint fallback, got {frac}"
5332 );
5333 }
5334
5335 /// The per-atom fallback tiers PARTITION the (row, atom) grid and the tier
5336 /// fractions are consistent probabilities; `accumulate` folds per-atom
5337 /// telemetry into a dictionary-wide breakdown that preserves the partition.
5338 #[test]
5339 fn fallback_telemetry_tiers_partition_and_accumulate() {
5340 let a = FallbackTelemetry {
5341 n_rows: 100,
5342 n_atoms: 1,
5343 amortized_certified: 70,
5344 newton_rescued: 20,
5345 multistart_fallback: 10,
5346 };
5347 assert_eq!(
5348 a.amortized_certified + a.newton_rescued + a.multistart_fallback,
5349 a.total(),
5350 "the three tiers must partition the (row, atom) grid"
5351 );
5352 assert!((a.amortized_fraction() - 0.70).abs() < 1e-12);
5353 assert!((a.newton_fraction() - 0.20).abs() < 1e-12);
5354 assert!((a.multistart_fraction() - 0.10).abs() < 1e-12);
5355 assert!(
5356 (a.amortized_fraction() + a.newton_fraction() + a.multistart_fraction() - 1.0).abs()
5357 < 1e-12,
5358 "the tier fractions must sum to one"
5359 );
5360
5361 let b = FallbackTelemetry {
5362 n_rows: 100,
5363 n_atoms: 1,
5364 amortized_certified: 40,
5365 newton_rescued: 30,
5366 multistart_fallback: 30,
5367 };
5368 let mut agg = a.clone();
5369 agg.accumulate(&b);
5370 assert_eq!(agg.n_rows, 100, "n_rows is shared across atoms");
5371 assert_eq!(agg.n_atoms, 2, "accumulate sums the atom count");
5372 assert_eq!(agg.multistart_fallback, 40);
5373 assert_eq!(
5374 agg.amortized_certified + agg.newton_rescued + agg.multistart_fallback,
5375 agg.total(),
5376 "the partition is preserved under accumulation"
5377 );
5378 }
5379}