Skip to main content

gam_terms/basis/
implicit_psi_derivative.rs

1use super::*;
2
3/// The fixed row-space complement a moving-design derivative is represented in.
4///
5/// A smooth collection chooses a row-space constraint block `C` once, while a
6/// spatial hyperparameter move changes the term-local design `X(psi)`.  The
7/// collection freezes one coefficient chart at its reference realization;
8/// re-whitening it at every `psi` would make arbitrary right-coordinate motion
9/// part of the statistical derivative. In the frozen chart the canonical
10/// derivative is instead
11///
12/// `P_C X_psi T`, where `P_C = I - Q_C Q_C^T`.
13///
14/// `Q_C` is formed from the thin SVD of column-normalized `C`, so rescaling one
15/// constraint column cannot change the projector or its numerical rank.  The
16/// object is deliberately generic: it composes a fixed left projector with any
17/// design jet without teaching kernel formulae about collection ownership.
18#[derive(Debug, Clone)]
19pub struct FixedRowSpaceProjector {
20    range_basis: Array2<f64>,
21    /// Maps coordinates in `range_basis` back to coefficients of the raw
22    /// constraint block supplied to [`Self::from_constraint_block`].  If
23    /// `C = U Sigma V^T D` after column normalization, this is
24    /// `D^-1 V Sigma^-1`, so `C * constraint_coordinates == U` on the retained
25    /// range.  Keeping this small `q x rank(C)` map lets a projected VALUE
26    /// design export the exact row-space correction prediction must replay,
27    /// without materializing an `n x p` block.
28    constraint_coordinates: Array2<f64>,
29}
30
31impl FixedRowSpaceProjector {
32    pub fn from_constraint_block(constraint: ArrayView2<'_, f64>) -> Result<Self, BasisError> {
33        let (n, q) = constraint.dim();
34        if constraint.iter().any(|value| !value.is_finite()) {
35            return Err(BasisError::InvalidInput(
36                "fixed row-space projector received a non-finite constraint block".to_string(),
37            ));
38        }
39        if q == 0 {
40            return Ok(Self {
41                range_basis: Array2::zeros((n, 0)),
42                constraint_coordinates: Array2::zeros((0, 0)),
43            });
44        }
45
46        let mut normalized = constraint.to_owned();
47        let mut column_norms = vec![0.0_f64; q];
48        for column in 0..q {
49            let norm = normalized
50                .column(column)
51                .dot(&normalized.column(column))
52                .sqrt();
53            column_norms[column] = norm;
54            if norm > 0.0 && norm.is_finite() {
55                normalized
56                    .column_mut(column)
57                    .mapv_inplace(|value| value / norm);
58            }
59        }
60        let (left, singular, right_t) =
61            gam_linalg::faer_ndarray::FaerSvd::svd(&normalized, true, true)
62                .map_err(BasisError::LinalgError)?;
63        let leading = singular.first().copied().unwrap_or(0.0);
64        let cutoff =
65            default_rrqr_rank_alpha() * f64::EPSILON * n.max(q).max(1) as f64 * leading.max(1.0);
66        let rank = singular.iter().filter(|&&value| value > cutoff).count();
67        let left = left.ok_or_else(|| {
68            BasisError::InvalidInput(
69                "fixed row-space projector SVD did not return its requested left frame".to_string(),
70            )
71        })?;
72        if left.nrows() != n || left.ncols() < rank {
73            return Err(BasisError::InvalidInput(format!(
74                "fixed row-space projector SVD returned a {}x{} left frame for an {n}x{q} constraint block of rank {rank}",
75                left.nrows(),
76                left.ncols(),
77            )));
78        }
79        let right_t = right_t.ok_or_else(|| {
80            BasisError::InvalidInput(
81                "fixed row-space projector SVD did not return its requested right frame"
82                    .to_string(),
83            )
84        })?;
85        if right_t.nrows() < rank || right_t.ncols() != q {
86            return Err(BasisError::InvalidInput(format!(
87                "fixed row-space projector SVD returned a {}x{} right frame for an {n}x{q} constraint block of rank {rank}",
88                right_t.nrows(),
89                right_t.ncols(),
90            )));
91        }
92        let mut constraint_coordinates = Array2::<f64>::zeros((q, rank));
93        for constraint_column in 0..q {
94            let norm = column_norms[constraint_column];
95            if !(norm > 0.0 && norm.is_finite()) {
96                continue;
97            }
98            for range_column in 0..rank {
99                constraint_coordinates[[constraint_column, range_column]] =
100                    right_t[[range_column, constraint_column]] / (norm * singular[range_column]);
101            }
102        }
103        Ok(Self {
104            range_basis: left.slice(s![.., 0..rank]).to_owned(),
105            constraint_coordinates,
106        })
107    }
108
109    pub fn nrows(&self) -> usize {
110        self.range_basis.nrows()
111    }
112
113    pub fn rank(&self) -> usize {
114        self.range_basis.ncols()
115    }
116
117    fn project_vector_owned(&self, mut values: Array1<f64>) -> Array1<f64> {
118        assert_eq!(values.len(), self.nrows());
119        if self.rank() > 0 {
120            let coordinates = self.range_basis.t().dot(&values);
121            values -= &self.range_basis.dot(&coordinates);
122        }
123        values
124    }
125
126    pub fn project_matrix_in_place(&self, values: &mut Array2<f64>) -> Result<(), BasisError> {
127        if values.nrows() != self.nrows() {
128            crate::bail_dim_basis!(
129                "fixed row-space projector has {} rows but the design jet has {}",
130                self.nrows(),
131                values.nrows()
132            );
133        }
134        if self.rank() > 0 {
135            let coordinates = fast_atb(&self.range_basis, values);
136            *values -= &fast_ab(&self.range_basis, &coordinates);
137        }
138        Ok(())
139    }
140
141    /// Project a possibly-lazy value design into this fixed row-space
142    /// complement, retaining lazy storage and returning the correction in the
143    /// ORIGINAL constraint block's coordinates.
144    ///
145    /// For `D = X T0`, this returns
146    ///
147    /// `D_projected = D - C R = P_C D`,
148    ///
149    /// with `R` satisfying `C R = Q_C Q_C^T D`.  The cross `Q_C^T D` is
150    /// streamed in bounded row chunks; the projected design is represented as
151    /// one block operator, so an outer-psi replay never materializes `n x p`.
152    pub fn project_design(
153        &self,
154        design: DesignMatrix,
155        context: &str,
156    ) -> Result<(DesignMatrix, Array2<f64>), BasisError> {
157        use gam_linalg::matrix::{BlockDesignOperator, DesignBlock};
158
159        if design.nrows() != self.nrows() {
160            crate::bail_dim_basis!(
161                "fixed row-space projector has {} rows but value design '{context}' has {}",
162                self.nrows(),
163                design.nrows()
164            );
165        }
166        let p = design.ncols();
167        let rank = self.rank();
168        if rank == 0 {
169            return Ok((
170                design,
171                Array2::zeros((self.constraint_coordinates.nrows(), p)),
172            ));
173        }
174
175        let mut range_cross = Array2::<f64>::zeros((rank, p));
176        const CHUNK: usize = 1024;
177        for start in (0..design.nrows()).step_by(CHUNK) {
178            let end = (start + CHUNK).min(design.nrows());
179            let design_chunk = design
180                .try_row_chunk(start..end)
181                .map_err(|error| BasisError::InvalidInput(error.to_string()))?;
182            range_cross += &fast_atb(&self.range_basis.slice(s![start..end, ..]), &design_chunk);
183        }
184        let row_space_correction = fast_ab(&self.constraint_coordinates, &range_cross);
185
186        let design_block = match design {
187            DesignMatrix::Dense(inner) => DesignBlock::Dense(inner),
188            DesignMatrix::Sparse(inner) => DesignBlock::Sparse(inner),
189        };
190        let stacked = BlockDesignOperator::new(vec![
191            design_block,
192            DesignBlock::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
193                self.range_basis.clone(),
194            )),
195        ])
196        .map_err(BasisError::InvalidInput)?;
197        let mut transform = Array2::<f64>::zeros((p + rank, p));
198        for column in 0..p {
199            transform[[column, column]] = 1.0;
200        }
201        for range_column in 0..rank {
202            for column in 0..p {
203                transform[[p + range_column, column]] = -range_cross[[range_column, column]];
204            }
205        }
206        let projected = CoefficientTransformOperator::new(
207            gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(stacked)),
208            transform,
209        )
210        .map_err(|error| {
211            BasisError::InvalidInput(format!(
212                "fixed row-space projection failed for value design '{context}': {error}"
213            ))
214        })?;
215        Ok((
216            DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(
217                projected,
218            ))),
219            row_space_correction,
220        ))
221    }
222
223    fn project_matrix_owned(&self, mut values: Array2<f64>) -> Array2<f64> {
224        self.project_matrix_in_place(&mut values)
225            .expect("installed fixed row-space projector has the operator's row count");
226        values
227    }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
231enum ProjectedJetKey {
232    FirstRaw(usize),
233    SecondDiagonal(usize),
234    SecondCross(usize, usize),
235}
236
237#[derive(Debug)]
238struct ImplicitRowProjection {
239    projector: FixedRowSpaceProjector,
240    corrections: std::sync::Mutex<HashMap<ProjectedJetKey, Arc<Array2<f64>>>>,
241}
242
243impl ImplicitRowProjection {
244    fn new(projector: FixedRowSpaceProjector) -> Self {
245        Self {
246            projector,
247            corrections: std::sync::Mutex::new(HashMap::new()),
248        }
249    }
250}
251
252/// Implicit representation of ∂X/∂ψ_d that supports matrix-vector products
253/// without materializing the full (n x p) derivative matrices.
254///
255/// For anisotropic Matern / Duchon terms with D axes, the dense path creates
256/// D matrices of size (n x p_smooth) for dX/dpsi_d. At n=400K, p=2000, D=16,
257/// that is ~100 GB.
258///
259/// Two storage modes:
260///
261/// **Materialized** (small-to-medium problems): stores pre-computed arrays
262/// - `phi_values[i*n_knots + j]` = phi(r_{ij})
263/// - `q_values[i*n_knots + j]` = phi'(r_{ij}) / r_{ij}
264/// - `t_values[i*n_knots + j]` = (phi''(r_{ij}) - q_{ij}) / r_{ij}^2
265/// - `axis_components[i*n_knots + j, d]` = exp(2 eta_d) * (x_{id} - c_{jd})^2
266/// Memory: O(n * k * (D + 2)).
267///
268/// **Streaming** (large scale): stores only data/centers/eta/kernel params
269/// and recomputes (q, t, s_a) on the fly during each matvec.
270/// Memory: O(n*d + k*d) -- no per-(data,knot) storage.
271///
272/// The raw-psi chain rule:
273///   shape_a   = q * s_a
274///   shape_ab  = t * s_a * s_b + 2 q s_a 1[a=b]
275///   dphi/dpsi_a         = shape_a + c * phi
276///   d2phi/(dpsi_a dpsi_b) = shape_ab + c (shape_a + shape_b) + c^2 phi
277/// where `c = 0` for Matérn and `c = delta / d` for hybrid Duchon.
278///
279/// Under a kernel chart (gam#979) — the forward basis shipping `α(ψ)·phi`
280/// rather than `phi` — the operator differentiates the CHARTED kernel: the
281/// share becomes `g_a = c + ∂ln α/∂ψ_a`, the second derivative gains
282/// `∂²ln α/∂ψ_a∂ψ_b · phi`, and every value is multiplied by `α`. The
283/// crate-private `with_kernel_chart` builder installs those chart derivatives.
284#[derive(Debug, Clone)]
285pub struct ImplicitDesignPsiDerivative {
286    logarithmic_correction: Option<Arc<DuchonLogarithmicPsiCorrection>>,
287    /// Pre-computed kernel values (materialized mode).
288    /// Shape: (n * n_knots,). Empty in streaming mode.
289    pub(crate) phi_values: Array1<f64>,
290
291    /// Pre-computed per (data, knot) pair axis components (materialized mode).
292    /// Shape: (n * n_knots, D) stored in row-major order.
293    /// Empty (0x0) in streaming mode.
294    pub(crate) axis_components: Array2<f64>,
295
296    /// Pre-computed R-operator first scalar (materialized mode).
297    /// Shape: (n * n_knots,). Empty in streaming mode.
298    pub(crate) q_values: Array1<f64>,
299
300    /// Pre-computed R-operator second scalar (materialized mode).
301    /// Shape: (n * n_knots,). Empty in streaming mode.
302    pub(crate) t_values: Array1<f64>,
303
304    /// When set, enables streaming recomputation of q/t/s from raw inputs
305    /// instead of reading from the pre-computed arrays above.
306    pub(crate) streaming: Option<StreamingRadialState>,
307
308    /// Identifiability/constraint transform Z: (n_knots x p_constrained).
309    /// Gauge ownership is upstream; the implicit operator stores this frozen
310    /// section only so forward/transpose matvecs can apply the already-gauged
311    /// chart without materializing derivative matrices. For Duchon this is the
312    /// kernel-constraint nullspace Z_kernel; for Matern with identifiability
313    /// constraints, it is the corresponding Z. `None` means the identity.
314    pub(crate) ident_transform: Option<Array2<f64>>,
315
316    /// Optional full identifiability transform applied after Z_kernel + padding.
317    /// This is likewise replay/application metadata for the matrix-free
318    /// operator, not a second coefficient-coordinate owner. For Duchon terms
319    /// that have an additional global identifiability transform, this is applied
320    /// after the kernel constraint and polynomial padding.
321    /// Shape: (p_constrained + n_poly, p_final).
322    pub(crate) full_ident_transform: Option<Array2<f64>>,
323
324    /// Number of data points.
325    pub(crate) n: usize,
326
327    /// Number of knots (raw basis functions before identifiability transform).
328    pub(crate) n_knots: usize,
329
330    /// Number of polynomial columns appended after the smooth part.
331    /// These have zero derivative with respect to psi_d.
332    pub(crate) n_poly: usize,
333
334    /// Number of axes (dimension D).
335    pub(crate) n_axes: usize,
336
337    /// Isotropic scaling contribution per raw anisotropic psi axis.
338    pub(crate) psi_scale_share: f64,
339
340    /// The kernel chart's amplitude `α` (gam#979). The forward Duchon basis
341    /// ships `α·φ` when the raw kernel underflows in high dimension, so every
342    /// ψ-derivative this operator forms is a derivative of `α(ψ)·φ(ψ)`, not of
343    /// `φ` alone. `1.0` for every non-Duchon kernel and for a Duchon chart
344    /// that is not amplified.
345    pub(crate) chart_scale: f64,
346
347    /// `∂ ln α / ∂ψ_a` per RAW axis (empty ⇒ zero). Enters the first
348    /// derivative through the effective share `g_a = c + L_a`.
349    pub(crate) chart_first: Vec<f64>,
350
351    /// `∂² ln α / ∂ψ_a ∂ψ_b − (∂ ln α/∂ψ_a)(∂ ln α/∂ψ_b)`'s complement, i.e.
352    /// `Λ_ab = ∂² ln α/∂ψ_a∂ψ_b` per RAW axis pair (empty ⇒ zero). Enters the
353    /// second derivative as the extra `Λ_ab·φ` term beside `g_a g_b φ`.
354    pub(crate) chart_second: Array2<f64>,
355
356    /// Optional fixed left projector for a collection-owned row-space gauge.
357    /// Kernel and penalty jets stay in the current coefficient chart; only
358    /// design jets are mapped through `I - Q_C Q_C^T`.
359    row_projection: Option<Arc<ImplicitRowProjection>>,
360
361    /// Optional exposed-axis to raw-axis linear combinations.
362    /// When present, axis `a` represents Σ_i coeff_i * raw_axis_i.
363    pub(crate) axis_combinations: Option<Vec<Vec<(usize, f64)>>>,
364}
365
366include!("duchon_logarithmic_psi.rs");
367
368/// Streaming design derivative for one per-row latent coordinate `t[n, a]`.
369///
370/// The operator stores the shared latent matrix plus either radial-kernel
371/// ingredients or a precomputed non-radial derivative jet. Individual REML
372/// hyper-directions carry only a flat coordinate index and call
373/// `forward_mul_axis` / `transpose_mul_axis` to expose the corresponding
374/// one-row design derivative on demand.
375pub struct LatentCoordDesignDerivative {
376    pub(crate) provider: Arc<dyn LocalDesignJacobianProvider>,
377}
378
379#[derive(Debug, Clone)]
380pub(crate) struct RadialLatentCoordLocalDesignJacobian {
381    pub(crate) latent: Arc<crate::latent::LatentCoordValues>,
382    /// Kernel centers in the STANDARDIZED frame, as `BasisMetadata` stores them.
383    pub(crate) centers: Arc<Array2<f64>>,
384    /// The frame `centers` and `radial_kind` live in, relative to the RAW
385    /// latent coordinates the optimizer moves (#2643).
386    ///
387    /// The realized design is `phi(||t/sigma - c||; ell/sigma)`, so a Jacobian
388    /// with respect to `t` must standardize `t` before forming radii AND carry
389    /// the `1/sigma` chain factor. Both were missing: the operator compared raw
390    /// `t` against standardized centers at an original-units range.
391    pub(crate) input_scale: crate::IsotropicScale,
392    pub(crate) radial_kind: RadialScalarKind,
393    pub(crate) ident_transform: Option<Array2<f64>>,
394    pub(crate) full_ident_transform: Option<Array2<f64>>,
395    pub(crate) n_poly: usize,
396    pub(crate) polynomial_order: Option<DuchonNullspaceOrder>,
397    /// The kernel chart amplitude `α` the forward design ships its kernel
398    /// block under (gam#979): the realized design is `α·φ(||t/σ − c||)`, so
399    /// every coordinate derivative carries `α` too. The amplitude depends on
400    /// the centers and the range only, never on `t`, so for a latent-coordinate
401    /// Jacobian it is a pure scalar. Matérn/thin-plate ship an identity chart
402    /// (`1.0`); the Duchon constructor computes it exactly as the forward does.
403    pub(crate) chart_scale: f64,
404}
405
406#[derive(Debug, Clone)]
407pub(crate) struct JetLatentCoordLocalDesignJacobian {
408    pub(crate) latent: Arc<crate::latent::LatentCoordValues>,
409    pub(crate) jet: Arc<Array3<f64>>,
410    pub(crate) ident_transform: Option<Array2<f64>>,
411}
412
413impl std::fmt::Debug for LatentCoordDesignDerivative {
414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        f.debug_struct("LatentCoordDesignDerivative")
416            .field("n_data", &self.n_data())
417            .field("latent_dim", &self.latent_dim())
418            .field("n_axes", &self.n_axes())
419            .field("p_out", &self.p_out())
420            .field("provider", &self.provider)
421            .finish()
422    }
423}
424
425impl Clone for LatentCoordDesignDerivative {
426    fn clone(&self) -> Self {
427        Self {
428            provider: Arc::clone(&self.provider),
429        }
430    }
431}
432
433impl RadialLatentCoordLocalDesignJacobian {
434    pub(crate) fn p_constrained(&self) -> usize {
435        self.ident_transform
436            .as_ref()
437            .map_or(self.centers.nrows(), Array2::ncols)
438    }
439
440    pub(crate) fn p_after_pad(&self) -> usize {
441        self.p_constrained() + self.n_poly
442    }
443
444    pub(crate) fn p_out(&self) -> usize {
445        self.full_ident_transform
446            .as_ref()
447            .map_or(self.p_after_pad(), Array2::ncols)
448    }
449}
450
451impl JetLatentCoordLocalDesignJacobian {
452    pub(crate) fn p_out(&self) -> usize {
453        self.ident_transform
454            .as_ref()
455            .map_or(self.jet.shape()[1], Array2::ncols)
456    }
457}
458
459/// The complete contract a per-row latent / novel-manifold coordinate type must
460/// supply to participate in the REML design-derivative operator surface.
461///
462/// Onboarding a new coordinate type (the SAE / novel-manifold frontier) reduces
463/// to implementing the small set of *required* methods below — the coordinate
464/// geometry (`n_data`, `latent_dim`, `n_axes`) plus the single genuinely-new
465/// payload `local_design_jacobian_row` (the local block ∂(design row)/∂(coord)).
466/// The streaming operator surface consumed by `LatentCoordDerivativeOp` in
467/// `src/solver/reml/mod.rs` — forward matvec, transpose matvec, and dense
468/// materialization, together with the flat-axis → (row, axis) decode — is
469/// inherited as *default* methods and never re-implemented per coordinate type.
470///
471/// This is the close condition for #767: a new coordinate type touches zero
472/// operator-surface code; it provides only its local Jacobian and geometry.
473pub trait LocalDesignJacobianProvider: Send + Sync + std::fmt::Debug {
474    /// Number of data rows `n` the operator spans.
475    fn n_data(&self) -> usize;
476
477    /// Latent coordinate dimension `d` (perturbation axes per row).
478    fn latent_dim(&self) -> usize;
479
480    /// Number of flat hyper-axes `n · d` (one per (row, coordinate-axis) pair).
481    fn n_axes(&self) -> usize;
482
483    /// Number of output-basis columns in each local design-Jacobian row.
484    fn p_out(&self) -> usize;
485
486    /// The only per-coordinate payload: the projected local design-Jacobian row
487    /// ∂(design row `row`)/∂(coordinate axis `axis`) in output-basis columns.
488    fn local_design_jacobian_row(&self, row: usize, axis: usize)
489    -> Result<Array1<f64>, BasisError>;
490
491    /// Decode a flat hyper-axis into its `(row, coordinate axis)`. Row-major over
492    /// `(row, axis)` with stride `latent_dim`; uniform across coordinate types.
493    fn row_axis(&self, flat_axis: usize) -> (usize, usize) {
494        let d = self.latent_dim();
495        (flat_axis / d, flat_axis % d)
496    }
497
498    /// Forward matvec for one flat hyper-axis: place `J_row · u` at `row`.
499    fn forward_mul_axis(
500        &self,
501        flat_axis: usize,
502        u: &ArrayView1<'_, f64>,
503    ) -> Result<Array1<f64>, BasisError> {
504        assert!(
505            flat_axis < self.n_axes(),
506            "latent-coordinate derivative flat axis out of bounds in forward_mul_axis: flat_axis={flat_axis}, n_axes={}",
507            self.n_axes()
508        );
509        let (row, axis) = self.row_axis(flat_axis);
510        let local_jacobian = self.local_design_jacobian_row(row, axis)?;
511        assert_eq!(
512            u.len(),
513            local_jacobian.len(),
514            "latent-coordinate derivative coefficient length mismatch in forward_mul_axis"
515        );
516        let value = local_jacobian.dot(u);
517        let mut out = Array1::<f64>::zeros(self.n_data());
518        out[row] = value;
519        Ok(out)
520    }
521
522    /// Transpose matvec for one flat hyper-axis: scatter `v[row] · J_rowᵀ`.
523    fn transpose_mul_axis(
524        &self,
525        flat_axis: usize,
526        v: &ArrayView1<'_, f64>,
527    ) -> Result<Array1<f64>, BasisError> {
528        assert!(
529            flat_axis < self.n_axes(),
530            "latent-coordinate derivative flat axis out of bounds in transpose_mul_axis: flat_axis={flat_axis}, n_axes={}",
531            self.n_axes()
532        );
533        assert_eq!(
534            v.len(),
535            self.n_data(),
536            "latent-coordinate derivative row-adjoint length mismatch in transpose_mul_axis"
537        );
538        let (row, axis) = self.row_axis(flat_axis);
539        let scale = v[row];
540        Ok(self
541            .local_design_jacobian_row(row, axis)?
542            .mapv(|value| scale * value))
543    }
544
545    /// Dense `(n_data × p_out)` materialization of one flat hyper-axis: the local
546    /// Jacobian row placed at `row`, all other rows zero.
547    fn materialize_axis(&self, flat_axis: usize) -> Result<Array2<f64>, BasisError> {
548        assert!(
549            flat_axis < self.n_axes(),
550            "latent-coordinate derivative flat axis out of bounds in materialize_axis: flat_axis={flat_axis}, n_axes={}",
551            self.n_axes()
552        );
553        let (row, axis) = self.row_axis(flat_axis);
554        let projected = self.local_design_jacobian_row(row, axis)?;
555        let mut out = Array2::<f64>::zeros((self.n_data(), projected.len()));
556        out.row_mut(row).assign(&projected);
557        Ok(out)
558    }
559}
560
561/// The rayon chunk size for parallel implicit matvec operations.
562/// Each chunk processes this many data points before reducing.
563pub(crate) const IMPLICIT_MATVEC_CHUNK_SIZE: usize = 1000;
564
565/// Minimum data size to activate parallel iteration for implicit matvecs.
566pub(crate) const IMPLICIT_MATVEC_PAR_THRESHOLD: usize = 10_000;
567
568/// Number of lower-triangular center rows per tile when assembling dense
569/// ThinPlate penalty ψ-derivative kernel blocks.
570pub(crate) const THIN_PLATE_PENALTY_PSI_TILE_ROWS: usize = 32;
571
572impl LatentCoordDesignDerivative {
573    pub(crate) fn from_local_design_jacobian_provider(
574        provider: Arc<dyn LocalDesignJacobianProvider>,
575    ) -> Self {
576        Self { provider }
577    }
578
579    /// `input_scale` and `length_scale` are the metadata's own pair: `centers`
580    /// are standardized by `input_scale`, and `length_scale` is the range in
581    /// ORIGINAL units. Taking both, and doing the one conversion here, is what
582    /// stops a caller pairing a standardized center set with an unconverted
583    /// range (#2643); the frame tags make the pairing checkable (#2636).
584    pub fn new_matern(
585        latent: Arc<crate::latent::LatentCoordValues>,
586        centers: Arc<Array2<f64>>,
587        input_scale: crate::IsotropicScale,
588        length_scale: crate::OriginalUnits,
589        nu: MaternNu,
590        include_intercept: bool,
591        ident_transform: Option<Array2<f64>>,
592    ) -> Result<Self, BasisError> {
593        if latent.latent_dim() != centers.ncols() {
594            crate::bail_dim_basis!(
595                "LatentCoordDesignDerivative Matérn dimension mismatch: latent d={} centers d={}",
596                latent.latent_dim(),
597                centers.ncols()
598            );
599        }
600        let length_scale = input_scale
601            .to_standardized_units(length_scale)
602            .standardized_value();
603        Ok(Self::from_local_design_jacobian_provider(Arc::new(
604            RadialLatentCoordLocalDesignJacobian {
605                latent,
606                centers,
607                input_scale,
608                radial_kind: RadialScalarKind::Matern { length_scale, nu },
609                ident_transform,
610                full_ident_transform: None,
611                n_poly: usize::from(include_intercept),
612                polynomial_order: None,
613                chart_scale: 1.0,
614            },
615        )))
616    }
617
618    /// See [`Self::new_matern`] for why this takes the metadata's frame pair
619    /// rather than a bare range.
620    pub fn new_duchon(
621        latent: Arc<crate::latent::LatentCoordValues>,
622        centers: Arc<Array2<f64>>,
623        input_scale: crate::IsotropicScale,
624        length_scale: Option<crate::OriginalUnits>,
625        power: f64,
626        nullspace_order: DuchonNullspaceOrder,
627        radial_reparam: Option<&Array2<f64>>,
628        full_ident_transform: Option<Array2<f64>>,
629    ) -> Result<Self, BasisError> {
630        if latent.latent_dim() != centers.ncols() {
631            crate::bail_dim_basis!(
632                "LatentCoordDesignDerivative Duchon dimension mismatch: latent d={} centers d={}",
633                latent.latent_dim(),
634                centers.ncols()
635            );
636        }
637        let effective_order = duchon_effective_nullspace_order(centers.view(), nullspace_order);
638        let p_order = duchon_p_from_nullspace_order(effective_order);
639        let s_order = power.max(0.0).round() as usize;
640        // The range must reach BOTH the kernel and the partial-fraction
641        // expansion in the standardized frame: `duchon_partial_fraction_coeffs`
642        // is built at `kappa = 1/ell`, so an unconverted range builds the whole
643        // expansion at the wrong kappa, not merely the kernel (#2643).
644        let length_scale =
645            length_scale.map(|ell| input_scale.to_standardized_units(ell).standardized_value());
646        // gam#979: the forward design ships `α·K` with `α = 1/max|K|` over
647        // the center cloud (`duchon_kernel_chart`), so the coordinate Jacobian
648        // must carry the same amplitude; it is computed from the same
649        // standardized centers, range and kernel coefficients the forward uses.
650        let (radial_kind, chart_scale) = if let Some(length_scale) = length_scale {
651            let coeffs = duchon_partial_fraction_coeffs(
652                p_order,
653                s_order,
654                duchon_inverse_length_scale(length_scale, "implicit ψ-derivative Duchon kernel")?,
655            );
656            let chart_scale = duchon_kernel_chart(
657                centers.view(),
658                Some(length_scale),
659                p_order,
660                s_order,
661                centers.ncols(),
662                None,
663                Some(&coeffs),
664                None,
665            )
666            .amplification;
667            (
668                RadialScalarKind::Duchon {
669                    length_scale,
670                    p_order,
671                    s_order,
672                    dim: centers.ncols(),
673                    coeffs,
674                },
675                chart_scale,
676            )
677        } else {
678            let pure_poly_coeff = PolyharmonicBlockCoeff::new(
679                pure_duchon_block_order(p_order, power),
680                centers.ncols(),
681            );
682            let chart_scale = duchon_kernel_chart(
683                centers.view(),
684                None,
685                p_order,
686                s_order,
687                centers.ncols(),
688                None,
689                None,
690                Some(&pure_poly_coeff),
691            )
692            .amplification;
693            (
694                RadialScalarKind::PureDuchon {
695                    block_order: pure_duchon_block_order(p_order, power).max(1.0) as usize,
696                    p_order,
697                    s_order,
698                    dim: centers.ncols(),
699                },
700                chart_scale,
701            )
702        };
703        let mut workspace = BasisWorkspace::default();
704        let mut ident_transform =
705            kernel_constraint_nullspace(centers.view(), effective_order, &mut workspace.cache)?;
706        // The shipped kernel block is `K · Z · V`: after the side-condition
707        // null space `Z` the forward folds the data-metric radial chart `V`
708        // (`BasisMetadata::Duchon::radial_reparam`, frozen at the fit's
709        // reference build) into the kernel transform. A Jacobian projected by
710        // `Z` alone is expressed in a coefficient chart the design does not
711        // use, and the two disagree by the whole rotation (gam#979: measured
712        // as a 38% relative gap on a 2-D control and 170% at the benchmark
713        // shape against the production rebuild).
714        if let Some(v) = radial_reparam {
715            if v.nrows() != ident_transform.ncols() {
716                crate::bail_dim_basis!(
717                    "LatentCoordDesignDerivative Duchon radial chart mismatch: Z has {} columns, V has {} rows",
718                    ident_transform.ncols(),
719                    v.nrows()
720                );
721            }
722            ident_transform = ident_transform.dot(v);
723        }
724        let n_poly = polynomial_block_from_order(centers.view(), effective_order).ncols();
725        Ok(Self::from_local_design_jacobian_provider(Arc::new(
726            RadialLatentCoordLocalDesignJacobian {
727                latent,
728                centers,
729                input_scale,
730                radial_kind,
731                ident_transform: Some(ident_transform),
732                full_ident_transform,
733                n_poly,
734                polynomial_order: Some(effective_order),
735                chart_scale,
736            },
737        )))
738    }
739
740    pub fn new_sphere(
741        latent: Arc<crate::latent::LatentCoordValues>,
742        centers: Arc<Array2<f64>>,
743        penalty_order: usize,
744        ident_transform: Option<Array2<f64>>,
745    ) -> Result<Self, BasisError> {
746        if latent.latent_dim() != centers.ncols() {
747            crate::bail_dim_basis!(
748                "LatentCoordDesignDerivative sphere dimension mismatch: latent d={} centers d={}",
749                latent.latent_dim(),
750                centers.ncols()
751            );
752        }
753        let raw_jet = sphere_first_derivative_nd(
754            latent.as_matrix().view(),
755            centers.view(),
756            penalty_order,
757            true,
758        )?;
759        let jet = latent.design_gradient_wrt_t_dispatch(
760            crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
761        )?;
762        Self::from_jet(latent, jet, ident_transform)
763    }
764
765    pub fn new_periodic_bspline(
766        latent: Arc<crate::latent::LatentCoordValues>,
767        data_range: (f64, f64),
768        degree: usize,
769        num_basis: usize,
770        ident_transform: Option<Array2<f64>>,
771    ) -> Result<Self, BasisError> {
772        let raw_jet = periodic_bspline_first_derivative_nd(
773            latent.as_matrix().view(),
774            data_range,
775            degree,
776            num_basis,
777        )?;
778        let jet = latent.design_gradient_wrt_t_dispatch(
779            crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
780        )?;
781        Self::from_jet(latent, jet, ident_transform)
782    }
783
784    pub fn new_tensor_bspline(
785        latent: Arc<crate::latent::LatentCoordValues>,
786        knots_per_axis: Vec<Array1<f64>>,
787        degrees: Vec<usize>,
788        ident_transform: Option<Array2<f64>>,
789    ) -> Result<Self, BasisError> {
790        let knot_views = knots_per_axis
791            .iter()
792            .map(|knots| knots.view())
793            .collect::<Vec<_>>();
794        let raw_jet =
795            bspline_tensor_first_derivative(latent.as_matrix().view(), &knot_views, &degrees)?;
796        let jet = latent.design_gradient_wrt_t_dispatch(
797            crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
798        )?;
799        Self::from_jet(latent, jet, ident_transform)
800    }
801
802    pub fn new_pca(
803        latent: Arc<crate::latent::LatentCoordValues>,
804        basis_matrix: Arc<Array2<f64>>,
805    ) -> Result<Self, BasisError> {
806        if latent.latent_dim() != basis_matrix.nrows() {
807            crate::bail_dim_basis!(
808                "LatentCoordDesignDerivative Pca dimension mismatch: latent d={} basis rows={}",
809                latent.latent_dim(),
810                basis_matrix.nrows()
811            );
812        }
813        let mut jet =
814            Array3::<f64>::zeros((latent.n_obs(), basis_matrix.ncols(), basis_matrix.nrows()));
815        for row in 0..latent.n_obs() {
816            for axis in 0..basis_matrix.nrows() {
817                for col in 0..basis_matrix.ncols() {
818                    jet[[row, col, axis]] = basis_matrix[[axis, col]];
819                }
820            }
821        }
822        Self::from_jet(latent, jet, None)
823    }
824
825    pub fn from_jet(
826        latent: Arc<crate::latent::LatentCoordValues>,
827        jet: Array3<f64>,
828        ident_transform: Option<Array2<f64>>,
829    ) -> Result<Self, BasisError> {
830        if jet.shape()[0] != latent.n_obs() || jet.shape()[2] != latent.latent_dim() {
831            crate::bail_dim_basis!(
832                "LatentCoordDesignDerivative jet shape {:?} does not match latent shape ({}, {}, {})",
833                jet.shape(),
834                latent.n_obs(),
835                jet.shape()[1],
836                latent.latent_dim()
837            );
838        }
839        if let Some(z) = ident_transform.as_ref()
840            && z.nrows() != jet.shape()[1]
841        {
842            crate::bail_dim_basis!(
843                "LatentCoordDesignDerivative identifiability transform has {} rows but derivative jet has {} basis columns",
844                z.nrows(),
845                jet.shape()[1]
846            );
847        }
848        Ok(Self::from_local_design_jacobian_provider(Arc::new(
849            JetLatentCoordLocalDesignJacobian {
850                latent,
851                jet: Arc::new(jet),
852                ident_transform,
853            },
854        )))
855    }
856
857    pub(crate) fn n_data(&self) -> usize {
858        self.provider.n_data()
859    }
860
861    pub(crate) fn latent_dim(&self) -> usize {
862        self.provider.latent_dim()
863    }
864
865    pub fn n_axes(&self) -> usize {
866        self.provider.n_axes()
867    }
868
869    pub fn p_out(&self) -> usize {
870        self.provider.p_out()
871    }
872}
873
874impl RadialLatentCoordLocalDesignJacobian {
875    pub(crate) fn project_and_pad(
876        &self,
877        raw_knot: &Array1<f64>,
878        raw_poly: &Array1<f64>,
879    ) -> Result<Array1<f64>, BasisError> {
880        let constrained = match &self.ident_transform {
881            Some(z) => z.t().dot(raw_knot),
882            None => raw_knot.clone(),
883        };
884        let mut padded = Array1::<f64>::zeros(constrained.len() + self.n_poly);
885        padded
886            .slice_mut(s![..constrained.len()])
887            .assign(&constrained);
888        if self.n_poly > 0 {
889            padded.slice_mut(s![constrained.len()..]).assign(raw_poly);
890        }
891        Ok(match &self.full_ident_transform {
892            Some(zf) => zf.t().dot(&padded),
893            None => padded,
894        })
895    }
896
897    pub(crate) fn kernel_axis_scalar(
898        &self,
899        row: usize,
900        center: usize,
901        axis: usize,
902    ) -> Result<f64, BasisError> {
903        // `centers` and the kernel range are standardized; the latent values
904        // the optimizer moves are raw. Standardize `t` before forming the
905        // radius so all three meet in one frame (#2643).
906        let t_row = self.latent.row(row);
907        let reciprocal = self.input_scale.reciprocal();
908        let mut r2 = 0.0_f64;
909        for a in 0..self.latent.latent_dim() {
910            let delta = t_row[a] * reciprocal - self.centers[[center, a]];
911            r2 += delta * delta;
912        }
913        let r = r2.sqrt();
914        if r == 0.0 {
915            // At a center collision the axis component s_axis = (t − c)_axis
916            // is exactly zero. The product q · s_axis is therefore 0 for any
917            // kernel whose q has a finite limit; for kernels where q diverges
918            // the value is genuinely indeterminate (0 · ∞) and we must not
919            // pretend it is zero. Defer to the kernel's classification.
920            if self.radial_kind.is_smooth_at_collision() {
921                return Ok(0.0);
922            }
923            return Err(BasisError::DegenerateAtCollision {
924                kernel: "RadialScalarKind (design axis)",
925                dim: self.latent.latent_dim(),
926                m: 0.0,
927                message: "radial scalar q = φ'/r has no finite limit at r = 0; \
928                          the design row axis component is undefined",
929            });
930        }
931        let (_, q, _) = self.radial_kind.eval_design_triplet(r)?;
932        // d/dt α·phi(||t/sigma - c||) = α · q · (t/sigma - c)_axis · (1/sigma):
933        // the axis component is standardized like the radius, the trailing
934        // `reciprocal` is the chain factor for the standardization itself, and
935        // `α` is the kernel chart the shipped design carries (gam#979).
936        Ok(self.chart_scale
937            * q
938            * (t_row[axis] * reciprocal - self.centers[[center, axis]])
939            * reciprocal)
940    }
941
942    pub(crate) fn polynomial_axis_values(&self, row: usize, axis: usize) -> Array1<f64> {
943        let Some(order) = self.polynomial_order else {
944            return Array1::<f64>::zeros(self.n_poly);
945        };
946        let max_degree = match order {
947            DuchonNullspaceOrder::Zero => 0usize,
948            DuchonNullspaceOrder::Linear => 1usize,
949            DuchonNullspaceOrder::Degree(k) => k,
950        };
951        // The realized polynomial block is built on the STANDARDIZED
952        // coordinates, and its constraint nullspace was built on standardized
953        // centers, so the monomials must be evaluated at `t/sigma` and carry
954        // the same `1/sigma` chain factor as the kernel block (#2643).
955        let t_row = self.latent.row(row);
956        let reciprocal = self.input_scale.reciprocal();
957        let exponents = monomial_exponents(self.latent.latent_dim(), max_degree);
958        let mut out = Array1::<f64>::zeros(exponents.len());
959        for (col, alpha) in exponents.iter().enumerate() {
960            let a_axis = alpha[axis];
961            if a_axis == 0 {
962                continue;
963            }
964            let mut value = a_axis as f64 * reciprocal;
965            for a in 0..self.latent.latent_dim() {
966                let exp_a = if a == axis { a_axis - 1 } else { alpha[a] };
967                if exp_a != 0 {
968                    value *= (t_row[a] * reciprocal).powi(exp_a as i32);
969                }
970            }
971            out[col] = value;
972        }
973        out
974    }
975}
976
977impl JetLatentCoordLocalDesignJacobian {
978    pub(crate) fn project_jet(&self, raw_knot: &Array1<f64>) -> Result<Array1<f64>, BasisError> {
979        Ok(match &self.ident_transform {
980            Some(z) => z.t().dot(raw_knot),
981            None => raw_knot.clone(),
982        })
983    }
984}
985
986impl LocalDesignJacobianProvider for LatentCoordDesignDerivative {
987    fn n_data(&self) -> usize {
988        self.provider.n_data()
989    }
990
991    fn latent_dim(&self) -> usize {
992        self.provider.latent_dim()
993    }
994
995    fn n_axes(&self) -> usize {
996        self.provider.n_axes()
997    }
998
999    fn p_out(&self) -> usize {
1000        self.provider.p_out()
1001    }
1002
1003    fn local_design_jacobian_row(
1004        &self,
1005        row: usize,
1006        axis: usize,
1007    ) -> Result<Array1<f64>, BasisError> {
1008        self.provider.local_design_jacobian_row(row, axis)
1009    }
1010}
1011
1012impl LocalDesignJacobianProvider for RadialLatentCoordLocalDesignJacobian {
1013    fn n_data(&self) -> usize {
1014        self.latent.n_obs()
1015    }
1016
1017    fn latent_dim(&self) -> usize {
1018        self.latent.latent_dim()
1019    }
1020
1021    fn n_axes(&self) -> usize {
1022        self.latent.len()
1023    }
1024
1025    fn p_out(&self) -> usize {
1026        Self::p_out(self)
1027    }
1028
1029    fn local_design_jacobian_row(
1030        &self,
1031        row: usize,
1032        axis: usize,
1033    ) -> Result<Array1<f64>, BasisError> {
1034        let mut raw_knot = Array1::<f64>::zeros(self.centers.nrows());
1035        for center in 0..self.centers.nrows() {
1036            raw_knot[center] = self.kernel_axis_scalar(row, center, axis)?;
1037        }
1038        let raw_poly = self.polynomial_axis_values(row, axis);
1039        self.project_and_pad(&raw_knot, &raw_poly)
1040    }
1041}
1042
1043impl LocalDesignJacobianProvider for JetLatentCoordLocalDesignJacobian {
1044    fn n_data(&self) -> usize {
1045        self.latent.n_obs()
1046    }
1047
1048    fn latent_dim(&self) -> usize {
1049        self.latent.latent_dim()
1050    }
1051
1052    fn n_axes(&self) -> usize {
1053        self.latent.len()
1054    }
1055
1056    fn p_out(&self) -> usize {
1057        Self::p_out(self)
1058    }
1059
1060    fn local_design_jacobian_row(
1061        &self,
1062        row: usize,
1063        axis: usize,
1064    ) -> Result<Array1<f64>, BasisError> {
1065        let mut raw_knot = Array1::<f64>::zeros(self.jet.shape()[1]);
1066        for basis_col in 0..self.jet.shape()[1] {
1067            raw_knot[basis_col] = self.jet[[row, basis_col, axis]];
1068        }
1069        self.project_jet(&raw_knot)
1070    }
1071}
1072
1073impl ImplicitDesignPsiDerivative {
1074    /// Construct from pre-computed radial jet scalars.
1075    ///
1076    /// # Arguments
1077    /// - `q_values`: (n * n_knots,) — φ'(r)/r for each (data, knot) pair.
1078    /// - `t_values`: (n * n_knots,) — (φ''(r) - q) / r² for each pair.
1079    /// - `axis_components`: (n * n_knots, D) — s_{d,ij} = exp(2η_d) · h_d² for each pair/axis.
1080    /// - `ident_transform`: optional (n_knots × p_constrained) constraint projection.
1081    /// - `full_ident_transform`: optional further projection after padding.
1082    /// - `n`, `n_knots`, `n_poly`, `n_axes`: dimensions.
1083    /// Construct from pre-computed (materialized) radial jet scalars.
1084    /// This is the original path for small-to-medium problems where
1085    /// O(n*k*(d+2)) storage is acceptable.
1086    pub fn new(
1087        phi_values: Array1<f64>,
1088        q_values: Array1<f64>,
1089        t_values: Array1<f64>,
1090        axis_components: Array2<f64>,
1091        ident_transform: Option<Array2<f64>>,
1092        full_ident_transform: Option<Array2<f64>>,
1093        n: usize,
1094        n_knots: usize,
1095        n_poly: usize,
1096        n_axes: usize,
1097    ) -> Self {
1098        assert_eq!(
1099            phi_values.len(),
1100            n * n_knots,
1101            "implicit psi derivative phi length mismatch: expected n*n_knots={}*{}={}, got {}",
1102            n,
1103            n_knots,
1104            n * n_knots,
1105            phi_values.len()
1106        );
1107        assert_eq!(
1108            q_values.len(),
1109            n * n_knots,
1110            "implicit psi derivative q length mismatch: expected n*n_knots={}*{}={}, got {}",
1111            n,
1112            n_knots,
1113            n * n_knots,
1114            q_values.len()
1115        );
1116        assert_eq!(
1117            t_values.len(),
1118            n * n_knots,
1119            "implicit psi derivative t length mismatch: expected n*n_knots={}*{}={}, got {}",
1120            n,
1121            n_knots,
1122            n * n_knots,
1123            t_values.len()
1124        );
1125        assert_eq!(
1126            axis_components.nrows(),
1127            n * n_knots,
1128            "implicit psi derivative axis-component row mismatch: expected n*n_knots={}*{}={}, got {}",
1129            n,
1130            n_knots,
1131            n * n_knots,
1132            axis_components.nrows()
1133        );
1134        assert_eq!(
1135            axis_components.ncols(),
1136            n_axes,
1137            "implicit psi derivative axis-component column mismatch: expected n_axes={n_axes}, got {}",
1138            axis_components.ncols()
1139        );
1140        Self {
1141            phi_values,
1142            axis_components,
1143            q_values,
1144            t_values,
1145            streaming: None,
1146            ident_transform,
1147            full_ident_transform,
1148            n,
1149            n_knots,
1150            n_poly,
1151            n_axes,
1152            psi_scale_share: 0.0,
1153            chart_scale: 1.0,
1154            chart_first: Vec::new(),
1155            chart_second: Array2::<f64>::zeros((0, 0)),
1156            row_projection: None,
1157            axis_combinations: None,
1158            logarithmic_correction: None,
1159        }
1160    }
1161
1162    pub(crate) fn with_psi_scale_share(mut self, psi_scale_share: f64) -> Self {
1163        self.psi_scale_share = psi_scale_share;
1164        self
1165    }
1166
1167    /// Install the kernel chart this operator differentiates under (gam#979):
1168    /// the amplitude `scale` the forward basis multiplies into the kernel
1169    /// block, `first[a] = ∂ ln scale/∂ψ_a` and `second[[a, b]] = ∂² ln
1170    /// scale/∂ψ_a∂ψ_b` over the RAW axes. With `F̃ = scale·F`:
1171    ///
1172    /// ```text
1173    ///   F̃_a  = scale · (F_a + L_a F)
1174    ///   F̃_ab = scale · (F_ab + L_a F_b + L_b F_a + (Λ_ab + L_a L_b) F)
1175    /// ```
1176    ///
1177    /// which the two kernel-value helpers realize as the effective share
1178    /// `g_a = c + L_a` and the extra `Λ_ab φ` term.
1179    pub(crate) fn with_kernel_chart(
1180        mut self,
1181        scale: f64,
1182        first: Vec<f64>,
1183        second: Array2<f64>,
1184    ) -> Self {
1185        let raw_axes = self.n_axes;
1186        assert!(
1187            scale.is_finite() && scale > 0.0,
1188            "kernel chart scale must be a positive finite number, got {scale}"
1189        );
1190        assert_eq!(
1191            first.len(),
1192            raw_axes,
1193            "kernel chart first log-jet must have one entry per raw axis"
1194        );
1195        assert_eq!(
1196            second.dim(),
1197            (raw_axes, raw_axes),
1198            "kernel chart second log-jet must be raw-axes square"
1199        );
1200        self.chart_scale = scale;
1201        self.chart_first = first;
1202        self.chart_second = second;
1203        self
1204    }
1205
1206    /// `g_a = c + L_a` for an EXPOSED axis: the raw scaling-law share plus the
1207    /// chart's first log-jet, combined linearly across raw axes when the
1208    /// exposed axis is a combination.
1209    #[inline]
1210    pub(crate) fn effective_share(&self, axis: usize) -> f64 {
1211        let raw_share =
1212            |raw: usize| self.psi_scale_share + self.chart_first.get(raw).copied().unwrap_or(0.0);
1213        match self.axis_combinations.as_ref() {
1214            Some(_) => self
1215                .transformed_axis_combination(axis)
1216                .iter()
1217                .map(|(raw, coeff)| coeff * raw_share(*raw))
1218                .sum(),
1219            None => raw_share(axis),
1220        }
1221    }
1222
1223    /// `Λ_ab` for an EXPOSED axis pair (zero without a chart), bilinear across
1224    /// raw axes when the exposed axes are combinations.
1225    #[inline]
1226    pub(crate) fn chart_lambda(&self, axis_a: usize, axis_b: usize) -> f64 {
1227        if self.chart_second.is_empty() {
1228            return 0.0;
1229        }
1230        match self.axis_combinations.as_ref() {
1231            Some(_) => {
1232                let combo_a = self.transformed_axis_combination(axis_a);
1233                let combo_b = self.transformed_axis_combination(axis_b);
1234                let mut total = 0.0;
1235                for (raw_a, coeff_a) in combo_a {
1236                    for (raw_b, coeff_b) in combo_b {
1237                        total += coeff_a * coeff_b * self.chart_second[[*raw_a, *raw_b]];
1238                    }
1239                }
1240                total
1241            }
1242            None => self.chart_second[[axis_a, axis_b]],
1243        }
1244    }
1245
1246    /// Construct a streaming operator that recomputes (q, t, s_a) on the fly
1247    /// from raw data/centers/eta during each matvec. No O(n*k) arrays are stored.
1248    /// This is the large-scale path.
1249    ///
1250    /// `pub` like the sibling `new_*` constructors: after the engine crate carve
1251    /// (#1521) the REML planner tests live in `gam-solve` and build streaming
1252    /// operators as fixtures, so this constructor is part of the cross-crate
1253    /// surface, not a crate-private helper.
1254    pub fn new_streaming(
1255        data: Arc<Array2<f64>>,
1256        centers: Arc<Array2<f64>>,
1257        eta: Vec<f64>,
1258        radial_kind: RadialScalarKind,
1259        ident_transform: Option<Array2<f64>>,
1260        full_ident_transform: Option<Array2<f64>>,
1261        n_poly: usize,
1262    ) -> Self {
1263        let n = data.nrows();
1264        let n_knots = centers.nrows();
1265        let n_axes = data.ncols();
1266        let psi_scale_share = radial_kind.raw_psi_isotropic_share();
1267        assert_eq!(eta.len(), n_axes);
1268        assert_eq!(
1269            centers.ncols(),
1270            n_axes,
1271            "streaming radial centers have {} columns but data/eta have {n_axes}",
1272            centers.ncols()
1273        );
1274        let metric_weights: Arc<[f64]> = Arc::from(centered_aniso_metric_weights(&eta));
1275        Self {
1276            // Empty arrays -- not used in streaming mode.
1277            phi_values: Array1::<f64>::zeros(0),
1278            axis_components: Array2::<f64>::zeros((0, 0)),
1279            q_values: Array1::<f64>::zeros(0),
1280            t_values: Array1::<f64>::zeros(0),
1281            streaming: Some(StreamingRadialState {
1282                data,
1283                centers,
1284                axis_mode: StreamingAxisMode::PerAxis { metric_weights },
1285                radial_kind,
1286                triplet_cache: Arc::new(std::sync::OnceLock::new()),
1287            }),
1288            ident_transform,
1289            full_ident_transform,
1290            n,
1291            n_knots,
1292            n_poly,
1293            n_axes,
1294            psi_scale_share,
1295            chart_scale: 1.0,
1296            chart_first: Vec::new(),
1297            chart_second: Array2::<f64>::zeros((0, 0)),
1298            row_projection: None,
1299            axis_combinations: None,
1300            logarithmic_correction: None,
1301        }
1302    }
1303
1304    /// Construct a streaming operator for a scalar ψ derivative. The operator
1305    /// exposes a single axis component equal to the full scaled squared
1306    /// distance r² under the fixed metric defined by `eta`.
1307    pub(crate) fn new_streaming_scalar(
1308        data: Arc<Array2<f64>>,
1309        centers: Arc<Array2<f64>>,
1310        eta: Vec<f64>,
1311        radial_kind: RadialScalarKind,
1312        ident_transform: Option<Array2<f64>>,
1313        full_ident_transform: Option<Array2<f64>>,
1314        n_poly: usize,
1315    ) -> Self {
1316        let n = data.nrows();
1317        let n_knots = centers.nrows();
1318        let dim = data.ncols();
1319        assert_eq!(eta.len(), dim);
1320        assert_eq!(
1321            centers.ncols(),
1322            dim,
1323            "streaming scalar radial centers have {} columns but data/eta have {dim}",
1324            centers.ncols()
1325        );
1326        let metric_weights: Arc<[f64]> = Arc::from(centered_aniso_metric_weights(&eta));
1327        Self {
1328            phi_values: Array1::<f64>::zeros(0),
1329            axis_components: Array2::<f64>::zeros((0, 0)),
1330            q_values: Array1::<f64>::zeros(0),
1331            t_values: Array1::<f64>::zeros(0),
1332            streaming: Some(StreamingRadialState {
1333                data,
1334                centers,
1335                axis_mode: StreamingAxisMode::ScalarTotal { metric_weights },
1336                radial_kind,
1337                triplet_cache: Arc::new(std::sync::OnceLock::new()),
1338            }),
1339            ident_transform,
1340            full_ident_transform,
1341            n,
1342            n_knots,
1343            n_poly,
1344            n_axes: 1,
1345            psi_scale_share: 0.0,
1346            chart_scale: 1.0,
1347            chart_first: Vec::new(),
1348            chart_second: Array2::<f64>::zeros((0, 0)),
1349            row_projection: None,
1350            axis_combinations: None,
1351            logarithmic_correction: None,
1352        }
1353    }
1354
1355    /// Whether this operator is in streaming (recompute-on-the-fly) mode.
1356    #[inline]
1357    pub(crate) fn is_streaming(&self) -> bool {
1358        self.streaming.is_some()
1359    }
1360
1361    /// Number of data points.
1362    pub fn n_data(&self) -> usize {
1363        self.n
1364    }
1365
1366    /// Number of axes (D).
1367    pub fn n_axes(&self) -> usize {
1368        self.axis_combinations
1369            .as_ref()
1370            .map_or(self.n_axes, Vec::len)
1371    }
1372
1373    pub fn is_duchon_family(&self) -> bool {
1374        self.streaming.as_ref().is_some_and(|state| {
1375            matches!(
1376                state.radial_kind,
1377                RadialScalarKind::Duchon { .. } | RadialScalarKind::PureDuchon { .. }
1378            )
1379        }) || self.psi_scale_share != 0.0
1380    }
1381
1382    /// Whether this operator is wired up by a basis whose large-scale path
1383    /// is supposed to stay implicit, so a dense `(n × p)` materialization
1384    /// here is a regression rather than a normal compute path. Duchon-family
1385    /// terms qualify because they are streaming-only at any scale; ThinPlate
1386    /// qualifies because the new scalar-streaming routing relies on the
1387    /// implicit operator above the policy threshold and a sneaky
1388    /// `materialize_dense()` would silently re-introduce the n × p
1389    /// allocation we just removed. The flag is consulted by the
1390    /// materialize_first / materialize_second_diag / materialize_second_cross
1391    /// guards to fire `assert_no_dense_derivative_materialization` for these
1392    /// kinds whenever the resource policy says the materialization would
1393    /// exceed budget. Small-n problems still pass the assertion and get the
1394    /// dense fast path.
1395    pub(crate) fn enforces_dense_materialization_budget(&self) -> bool {
1396        if self
1397            .streaming
1398            .as_ref()
1399            .is_some_and(|state| state.radial_kind.enforces_dense_materialization_budget())
1400        {
1401            return true;
1402        }
1403        // The materialized-mode path keeps no `radial_kind` to inspect, but
1404        // a non-zero psi_scale_share is the unambiguous Duchon-family
1405        // signature there (Matern uses 0, ThinPlate uses 0). Materialized
1406        // ThinPlate / Matern terms are in the dense fast path and the
1407        // guard does not need to fire for them.
1408        self.psi_scale_share != 0.0
1409    }
1410
1411    /// Output dimension: total basis columns in the final space.
1412    pub fn p_out(&self) -> usize {
1413        if let Some(ref zf) = self.full_ident_transform {
1414            zf.ncols()
1415        } else {
1416            self.p_after_pad()
1417        }
1418    }
1419
1420    pub fn append_full_transform(mut self, transform: &Array2<f64>) -> Result<Self, BasisError> {
1421        if self.row_projection.is_some() {
1422            return Err(BasisError::InvalidInput(
1423                "implicit psi coefficient transforms must be composed before the fixed row-space projector is installed"
1424                    .to_string(),
1425            ));
1426        }
1427        if transform.nrows() != self.p_out() {
1428            crate::bail_dim_basis!(
1429                "implicit psi derivative transform has {} rows but operator has {} output columns",
1430                transform.nrows(),
1431                self.p_out()
1432            );
1433        }
1434        self.full_ident_transform = Some(match self.full_ident_transform.take() {
1435            Some(existing) => fast_ab(&existing, transform),
1436            None => transform.clone(),
1437        });
1438        Ok(self)
1439    }
1440
1441    /// Compose the finished coefficient-space derivative operator with a fixed
1442    /// collection row-space projector.
1443    ///
1444    /// This is intentionally the last chart operation.  The projector acts on
1445    /// rows, while every kernel/joint-null transform acts on coefficients; the
1446    /// two commute, but installing it last lets row-chunk correction caches be
1447    /// expressed directly in the final coefficient dimension.
1448    pub fn with_fixed_row_space_projection(
1449        mut self,
1450        projector: FixedRowSpaceProjector,
1451    ) -> Result<Self, BasisError> {
1452        if projector.nrows() != self.n {
1453            crate::bail_dim_basis!(
1454                "fixed row-space projector has {} rows but the implicit psi operator has {}",
1455                projector.nrows(),
1456                self.n
1457            );
1458        }
1459        if projector.rank() > 0 {
1460            self.row_projection = Some(Arc::new(ImplicitRowProjection::new(projector)));
1461        }
1462        Ok(self)
1463    }
1464
1465    fn projected_jet_correction(
1466        &self,
1467        key: ProjectedJetKey,
1468    ) -> Result<Option<Arc<Array2<f64>>>, BasisError> {
1469        let Some(row_projection) = self.row_projection.as_ref() else {
1470            return Ok(None);
1471        };
1472        let key = match key {
1473            ProjectedJetKey::SecondCross(left, right) if left > right => {
1474                ProjectedJetKey::SecondCross(right, left)
1475            }
1476            key => key,
1477        };
1478        if let Some(cached) = row_projection
1479            .corrections
1480            .lock()
1481            .unwrap_or_else(|poison| poison.into_inner())
1482            .get(&key)
1483            .cloned()
1484        {
1485            return Ok(Some(cached));
1486        }
1487
1488        let width = match key {
1489            ProjectedJetKey::FirstRaw(_) => self.n_knots,
1490            ProjectedJetKey::SecondDiagonal(_) | ProjectedJetKey::SecondCross(_, _) => self.p_out(),
1491        };
1492        let mut correction = Array2::<f64>::zeros((row_projection.projector.rank(), width));
1493        for basis_column in 0..row_projection.projector.rank() {
1494            let row_direction = row_projection.projector.range_basis.column(basis_column);
1495            let mut values = match key {
1496                ProjectedJetKey::FirstRaw(axis) => {
1497                    self.transpose_mul_first_raw_unprojected(axis, &row_direction)?
1498                }
1499                ProjectedJetKey::SecondDiagonal(axis) => {
1500                    self.transpose_mul_second_diag_unprojected(axis, &row_direction)?
1501                }
1502                ProjectedJetKey::SecondCross(left, right) => {
1503                    self.transpose_mul_second_cross_unprojected(left, right, &row_direction)?
1504                }
1505            };
1506            if self.logarithmic_correction.is_some() {
1507                for start in (0..self.n).step_by(IMPLICIT_MATVEC_CHUNK_SIZE) {
1508                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(self.n);
1509                    let mut polynomial = Array2::<f64>::zeros((end - start, width));
1510                    self.add_logarithmic_correction(key, start..end, &mut polynomial);
1511                    values += &polynomial
1512                        .t()
1513                        .dot(&row_direction.slice(ndarray::s![start..end]));
1514                }
1515            }
1516            correction.row_mut(basis_column).assign(&values);
1517        }
1518        let correction = Arc::new(correction);
1519        let correction = row_projection
1520            .corrections
1521            .lock()
1522            .unwrap_or_else(|poison| poison.into_inner())
1523            .entry(key)
1524            .or_insert_with(|| Arc::clone(&correction))
1525            .clone();
1526        Ok(Some(correction))
1527    }
1528
1529    fn subtract_projected_row_chunk_correction(
1530        &self,
1531        key: ProjectedJetKey,
1532        rows: std::ops::Range<usize>,
1533        chunk: &mut Array2<f64>,
1534    ) -> Result<(), BasisError> {
1535        let Some(row_projection) = self.row_projection.as_ref() else {
1536            return Ok(());
1537        };
1538        let Some(correction) = self.projected_jet_correction(key)? else {
1539            return Ok(());
1540        };
1541        let removed = fast_ab(
1542            &row_projection.projector.range_basis.slice(s![rows, ..]),
1543            correction.as_ref(),
1544        );
1545        *chunk -= &removed;
1546        Ok(())
1547    }
1548
1549    /// Dimension after kernel constraint + polynomial padding (before full ident).
1550    pub(crate) fn p_after_pad(&self) -> usize {
1551        let p_constrained = self.p_constrained();
1552        p_constrained + self.n_poly
1553    }
1554
1555    /// Dimension after kernel constraint projection (before poly padding).
1556    pub(crate) fn p_constrained(&self) -> usize {
1557        match &self.ident_transform {
1558            Some(z) => z.ncols(),
1559            None => self.n_knots,
1560        }
1561    }
1562
1563    /// Accumulate raw knot-space vector from weighted (data, knot) contributions.
1564    /// Returns a vector of length n_knots: Σ_i w_i · scalar_{ij} for each knot j.
1565    ///
1566    /// This is the core primitive: for each data point i, accumulate
1567    /// `v[i] * per_pair_scalar(i,j)` into knot j.
1568    pub(crate) fn accumulate_knot_vector<F>(&self, v: &ArrayView1<f64>, per_pair: F) -> Array1<f64>
1569    where
1570        F: Fn(usize) -> f64 + Send + Sync,
1571    {
1572        let n = self.n;
1573        let k = self.n_knots;
1574
1575        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1576            // Parallel path: chunk data points and reduce.
1577            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1578            let partial_sums: Vec<Array1<f64>> = (0..n_chunks)
1579                .into_par_iter()
1580                .map(|chunk_idx| {
1581                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1582                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1583                    let mut local = Array1::<f64>::zeros(k);
1584                    for i in start..end {
1585                        let vi = v[i];
1586                        if vi == 0.0 {
1587                            continue;
1588                        }
1589                        let base = i * k;
1590                        for j in 0..k {
1591                            local[j] += vi * per_pair(base + j);
1592                        }
1593                    }
1594                    local
1595                })
1596                .collect();
1597            let mut total = Array1::<f64>::zeros(k);
1598            for p in partial_sums {
1599                total += &p;
1600            }
1601            total
1602        } else {
1603            // Sequential path.
1604            let mut total = Array1::<f64>::zeros(k);
1605            for i in 0..n {
1606                let vi = v[i];
1607                if vi == 0.0 {
1608                    continue;
1609                }
1610                let base = i * k;
1611                for j in 0..k {
1612                    total[j] += vi * per_pair(base + j);
1613                }
1614            }
1615            total
1616        }
1617    }
1618
1619    /// Streaming accumulate knot vector from on-the-fly radial scalars.
1620    pub(crate) fn streaming_accumulate_knot_vector<G>(
1621        &self,
1622        v: &ArrayView1<f64>,
1623        deriv_fn: G,
1624    ) -> Result<Array1<f64>, BasisError>
1625    where
1626        G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1627    {
1628        let Some(st) = self.streaming.as_ref() else {
1629            return Err(BasisError::InvalidInput(
1630                "streaming_accumulate_knot_vector needs the streaming radial state, but this implicit \
1631                 ψ-derivative operator was built without one"
1632                    .to_string(),
1633            ));
1634        };
1635        let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1636        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1637            let err_flag = std::sync::atomic::AtomicBool::new(false);
1638            let nc = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1639            let ps: Vec<Array1<f64>> = (0..nc)
1640                .into_par_iter()
1641                .map(|ci| {
1642                    let s = ci * IMPLICIT_MATVEC_CHUNK_SIZE;
1643                    let e = (s + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1644                    let mut loc = Array1::<f64>::zeros(k);
1645                    let mut sb = vec![0.0; dim];
1646                    for i in s..e {
1647                        let vi = v[i];
1648                        if vi == 0.0 {
1649                            continue;
1650                        }
1651                        for j in 0..k {
1652                            match st.compute_pair(i, j, &mut sb) {
1653                                Ok((phi, q, t)) => {
1654                                    loc[j] += vi * deriv_fn(phi, q, t, &sb);
1655                                }
1656                                Err(_) => {
1657                                    err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
1658                                    return loc;
1659                                }
1660                            }
1661                        }
1662                    }
1663                    loc
1664                })
1665                .collect();
1666            if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1667                crate::bail_invalid_basis!(
1668                    "radial scalar evaluation failed during streaming accumulate_knot_vector"
1669                        .into(),
1670                );
1671            }
1672            let mut tot = Array1::<f64>::zeros(k);
1673            for p in ps {
1674                tot += &p;
1675            }
1676            Ok(tot)
1677        } else {
1678            let mut tot = Array1::<f64>::zeros(k);
1679            let mut sb = vec![0.0; dim];
1680            for i in 0..n {
1681                let vi = v[i];
1682                if vi == 0.0 {
1683                    continue;
1684                }
1685                for j in 0..k {
1686                    let (phi, q, t) = st.compute_pair(i,j,&mut sb).map_err(|e| BasisError::InvalidInput(
1687                        format!("radial scalar evaluation failed during streaming accumulate_knot_vector: {e}"),
1688                    ))?;
1689                    tot[j] += vi * deriv_fn(phi, q, t, &sb);
1690                }
1691            }
1692            Ok(tot)
1693        }
1694    }
1695    /// Streaming forward multiply.
1696    pub(crate) fn streaming_forward_mul<G>(
1697        &self,
1698        u_knot: &Array1<f64>,
1699        deriv_fn: G,
1700    ) -> Result<Array1<f64>, BasisError>
1701    where
1702        G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1703    {
1704        let Some(st) = self.streaming.as_ref() else {
1705            return Err(BasisError::InvalidInput(
1706                "streaming_forward_mul needs the streaming radial state, but this implicit \
1707                 ψ-derivative operator was built without one"
1708                    .to_string(),
1709            ));
1710        };
1711        let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1712        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1713            let err_flag = std::sync::atomic::AtomicBool::new(false);
1714            let nc = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1715            let cr: Vec<(usize, Vec<f64>)> = (0..nc)
1716                .into_par_iter()
1717                .map(|ci| {
1718                    let s = ci * IMPLICIT_MATVEC_CHUNK_SIZE;
1719                    let e = (s + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1720                    let mut loc = vec![0.0; e - s];
1721                    let mut sb = vec![0.0; dim];
1722                    for i in s..e {
1723                        let mut val = 0.0;
1724                        for j in 0..k {
1725                            match st.compute_pair(i, j, &mut sb) {
1726                                Ok((phi, q, t)) => {
1727                                    val += deriv_fn(phi, q, t, &sb) * u_knot[j];
1728                                }
1729                                Err(_) => {
1730                                    err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
1731                                    break;
1732                                }
1733                            }
1734                        }
1735                        loc[i - s] = val;
1736                    }
1737                    (s, loc)
1738                })
1739                .collect();
1740            if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1741                crate::bail_invalid_basis!(
1742                    "radial scalar evaluation failed during streaming forward_mul".into(),
1743                );
1744            }
1745            let mut res = Array1::<f64>::zeros(n);
1746            for (s, vs) in cr {
1747                for (o, &v) in vs.iter().enumerate() {
1748                    res[s + o] = v;
1749                }
1750            }
1751            Ok(res)
1752        } else {
1753            let mut res = Array1::<f64>::zeros(n);
1754            let mut sb = vec![0.0; dim];
1755            for i in 0..n {
1756                let mut val = 0.0;
1757                for j in 0..k {
1758                    let (phi, q, t) = st.compute_pair(i, j, &mut sb).map_err(|e| {
1759                        BasisError::InvalidInput(format!(
1760                            "radial scalar evaluation failed during streaming forward_mul: {e}"
1761                        ))
1762                    })?;
1763                    val += deriv_fn(phi, q, t, &sb) * u_knot[j];
1764                }
1765                res[i] = val;
1766            }
1767            Ok(res)
1768        }
1769    }
1770    /// Streaming materialization: build (n x k) raw matrix then project.
1771    pub(crate) fn streaming_materialize<G>(&self, deriv_fn: G) -> Result<Array2<f64>, BasisError>
1772    where
1773        G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1774    {
1775        let Some(st) = self.streaming.as_ref() else {
1776            return Err(BasisError::InvalidInput(
1777                "streaming_materialize needs the streaming radial state, but this implicit \
1778                 ψ-derivative operator was built without one"
1779                    .to_string(),
1780            ));
1781        };
1782        let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1783        let mut raw = Array2::<f64>::zeros((n, k));
1784        let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
1785        let nc = n.div_ceil(cs);
1786        let err_flag = std::sync::atomic::AtomicBool::new(false);
1787        {
1788            let rp = SendPtr(raw.as_mut_ptr());
1789            let ef = &err_flag;
1790            (0..nc).into_par_iter().for_each(move |ci| {
1791                let s = ci * cs;
1792                let e = (s + cs).min(n);
1793                let mut sb = vec![0.0; dim];
1794                for i in s..e {
1795                    for j in 0..k {
1796                        match st.compute_pair(i, j, &mut sb) {
1797                            // SAFETY: chunk ci owns rows [s..e) of the raw n×k buffer,
1798                            // so offsets i*k+j for i ∈ [s,e), j ∈ [0,k) are pairwise
1799                            // disjoint across workers and stay within n*k = raw.len().
1800                            Ok((phi, q, t)) => unsafe {
1801                                *rp.add(i * k + j) = deriv_fn(phi, q, t, &sb);
1802                            },
1803                            Err(_) => {
1804                                ef.store(true, std::sync::atomic::Ordering::Relaxed);
1805                                return;
1806                            }
1807                        }
1808                    }
1809                }
1810            });
1811        }
1812        if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1813            crate::bail_invalid_basis!(
1814                "radial scalar evaluation failed during streaming materialize".into(),
1815            );
1816        }
1817        Ok(self.project_matrix(raw))
1818    }
1819
1820    /// Project a raw knot-space vector through the identifiability transform
1821    /// and pad with zeros for polynomial columns.
1822    pub(crate) fn project_and_pad(&self, raw_knot_vec: &Array1<f64>) -> Array1<f64> {
1823        // Step 1: apply kernel constraint Z (if present).
1824        let constrained = match &self.ident_transform {
1825            Some(z) => z.t().dot(raw_knot_vec),
1826            None => raw_knot_vec.clone(),
1827        };
1828
1829        // Step 2: pad with polynomial zeros.
1830        let p_padded = constrained.len() + self.n_poly;
1831        let mut padded = Array1::<f64>::zeros(p_padded);
1832        padded
1833            .slice_mut(s![..constrained.len()])
1834            .assign(&constrained);
1835
1836        // Step 3: apply full identifiability transform (if present).
1837        match &self.full_ident_transform {
1838            Some(zf) => zf.t().dot(&padded),
1839            None => padded,
1840        }
1841    }
1842
1843    /// Expand a coefficient vector from the final space back to raw knot space.
1844    /// This is the transpose path: p_out → (padded) → (constrained) → n_knots.
1845    pub(crate) fn unproject(&self, u: &ArrayView1<f64>) -> Array1<f64> {
1846        // Step 1: undo full identifiability transform.
1847        let after_full = match &self.full_ident_transform {
1848            Some(zf) => zf.dot(u),
1849            None => u.to_owned(),
1850        };
1851
1852        // Step 2: extract smooth part (drop polynomial padding).
1853        let p_constrained = self.p_constrained();
1854        let smooth_part = after_full.slice(s![..p_constrained]);
1855
1856        // Step 3: undo kernel constraint Z.
1857        match &self.ident_transform {
1858            Some(z) => z.dot(&smooth_part),
1859            None => smooth_part.to_owned(),
1860        }
1861    }
1862
1863    /// Batched `unproject` for a (p_out × rank) coefficient matrix.
1864    /// Returns (n_knots × rank) via two BLAS3 matmuls — the same algebra as
1865    /// `unproject`, but amortized across all rank columns of `u`. Used by
1866    /// `forward_mul_matrix` so per-axis trace evaluations can be a single
1867    /// chunked GEMM rather than rank-many `forward_mul` calls.
1868    pub fn unproject_matrix(&self, u: &ArrayView2<f64>) -> Array2<f64> {
1869        assert_eq!(u.nrows(), self.p_out());
1870        // Step 1: undo full identifiability transform → (p_after_pad, rank).
1871        let after_full = match &self.full_ident_transform {
1872            Some(zf) => fast_ab(zf, u),
1873            None => u.to_owned(),
1874        };
1875        // Step 2: drop polynomial padding rows → (p_constrained, rank).
1876        let p_constrained = self.p_constrained();
1877        let smooth_part = after_full.slice(s![..p_constrained, ..]);
1878        // Step 3: undo kernel constraint Z → (n_knots, rank).
1879        match &self.ident_transform {
1880            Some(z) => fast_ab(z, &smooth_part),
1881            None => smooth_part.to_owned(),
1882        }
1883    }
1884
1885    /// Compute (∂X/∂ψ_d)^T v for a given axis d and vector v of length n.
1886    ///
1887    /// Returns a vector of length p_out (total basis dimension after all transforms).
1888    ///
1889    /// Formula in raw knot space:
1890    ///   \[raw\]_j = Σ_i v_i · q_{ij} · s_{d,ij}
1891    /// then project through Z and pad.
1892    ///
1893    /// Note: q = φ_r/r and s_d = exp(2ψ_d)·h_d² are UNNORMALIZED axis components.
1894    /// With this convention, q·s_d = (φ_r/r)·(exp(2ψ_d)·h_d²) = φ_r·(s_d/r),
1895    /// which equals the correct ∂φ/∂ψ_d = φ_r·∂r/∂ψ_d = φ_r·s_d/r.
1896    /// No r² correction is needed — that would be required only if s_d were
1897    /// the fractional quantity s_d/r².
1898    pub fn transpose_mul(
1899        &self,
1900        axis: usize,
1901        v: &ArrayView1<f64>,
1902    ) -> Result<Array1<f64>, BasisError> {
1903        if self.logarithmic_correction.is_some() {
1904            return Ok(self.project_and_pad(
1905                &self.logarithmic_transpose(ProjectedJetKey::FirstRaw(axis), v)?,
1906            ));
1907        }
1908        if let Some(row_projection) = self.row_projection.as_ref() {
1909            let projected = row_projection.projector.project_vector_owned(v.to_owned());
1910            return self.transpose_mul_unprojected(axis, &projected.view());
1911        }
1912        self.transpose_mul_unprojected(axis, v)
1913    }
1914
1915    fn transpose_mul_unprojected(
1916        &self,
1917        axis: usize,
1918        v: &ArrayView1<f64>,
1919    ) -> Result<Array1<f64>, BasisError> {
1920        let raw = self.transpose_mul_first_raw_unprojected(axis, v)?;
1921        Ok(self.project_and_pad(&raw))
1922    }
1923
1924    fn transpose_mul_first_raw_unprojected(
1925        &self,
1926        axis: usize,
1927        v: &ArrayView1<f64>,
1928    ) -> Result<Array1<f64>, BasisError> {
1929        assert!(
1930            axis < self.n_axes(),
1931            "implicit psi first transpose axis out of bounds: axis={axis}, n_axes={}",
1932            self.n_axes()
1933        );
1934        assert_eq!(
1935            v.len(),
1936            self.n,
1937            "implicit psi first transpose row-adjoint length mismatch"
1938        );
1939        if self.axis_combinations.is_some() {
1940            let combo = self.transformed_axis_combination(axis);
1941            if self.is_streaming() {
1942                let scale = self.chart_scale;
1943                let g = self.effective_share(axis);
1944                let raw = self.streaming_accumulate_knot_vector(v, |phi, q, _, sb| {
1945                    let s_combo = combo
1946                        .iter()
1947                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1948                        .sum();
1949                    Self::first_kernel_value(scale, phi, q, s_combo, g)
1950                })?;
1951                return Ok(raw);
1952            }
1953            let scale = self.chart_scale;
1954            let g = self.effective_share(axis);
1955            let raw = self.accumulate_knot_vector(v, |idx| {
1956                let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1957                Self::first_kernel_value(
1958                    scale,
1959                    self.phi_values[idx],
1960                    self.q_values[idx],
1961                    s_combo,
1962                    g,
1963                )
1964            });
1965            return Ok(raw);
1966        }
1967        if self.is_streaming() {
1968            let scale = self.chart_scale;
1969            let g = self.effective_share(axis);
1970            let raw = self.streaming_accumulate_knot_vector(v, |phi, q, _, sb| {
1971                Self::first_kernel_value(scale, phi, q, sb[axis], g)
1972            })?;
1973            return Ok(raw);
1974        }
1975        let scale = self.chart_scale;
1976        let g = self.effective_share(axis);
1977        let af = &self.axis_components;
1978        let pv = &self.phi_values;
1979        let qv = &self.q_values;
1980        let raw = self.accumulate_knot_vector(v, |idx| {
1981            Self::first_kernel_value(scale, pv[idx], qv[idx], af[[idx, axis]], g)
1982        });
1983        Ok(raw)
1984    }
1985
1986    /// Compute (∂X/∂ψ_d) u for a given axis d and vector u of length p_out.
1987    ///
1988    /// Returns a vector of length n.
1989    ///
1990    /// Formula: for each data point i,
1991    ///   result_i = Σ_j q_{ij} · s_{d,ij} · u_knot_j
1992    /// where u_knot = Z · u_smooth (unprojected back to knot space).
1993    pub fn forward_mul(&self, axis: usize, u: &ArrayView1<f64>) -> Result<Array1<f64>, BasisError> {
1994        if self.logarithmic_correction.is_some() {
1995            return self
1996                .logarithmic_forward(ProjectedJetKey::FirstRaw(axis), &self.unproject(u).view());
1997        }
1998        let values = self.forward_mul_unprojected(axis, u)?;
1999        Ok(match self.row_projection.as_ref() {
2000            Some(row_projection) => row_projection.projector.project_vector_owned(values),
2001            None => values,
2002        })
2003    }
2004
2005    fn forward_mul_unprojected(
2006        &self,
2007        axis: usize,
2008        u: &ArrayView1<f64>,
2009    ) -> Result<Array1<f64>, BasisError> {
2010        assert!(
2011            axis < self.n_axes(),
2012            "implicit psi first forward axis out of bounds: axis={axis}, n_axes={}",
2013            self.n_axes()
2014        );
2015        assert_eq!(
2016            u.len(),
2017            self.p_out(),
2018            "implicit psi first forward coefficient length mismatch"
2019        );
2020        let u_knot = self.unproject(u);
2021        if self.axis_combinations.is_some() {
2022            let combo = self.transformed_axis_combination(axis);
2023            if self.is_streaming() {
2024                let scale = self.chart_scale;
2025                let g = self.effective_share(axis);
2026                return self.streaming_forward_mul(&u_knot, |phi, q, _, sb| {
2027                    let s_combo = combo
2028                        .iter()
2029                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2030                        .sum();
2031                    Self::first_kernel_value(scale, phi, q, s_combo, g)
2032                });
2033            }
2034            let n = self.n;
2035            let k = self.n_knots;
2036            let scale = self.chart_scale;
2037            let g = self.effective_share(axis);
2038            if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
2039                let mut result = Array1::<f64>::zeros(n);
2040                let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
2041                let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
2042                    .into_par_iter()
2043                    .map(|chunk_idx| {
2044                        let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
2045                        let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
2046                        let mut local = vec![0.0; end - start];
2047                        for i in start..end {
2048                            let base = i * k;
2049                            let mut val = 0.0;
2050                            for j in 0..k {
2051                                let idx = base + j;
2052                                let s_combo =
2053                                    self.transformed_combo_axis_value_materialized(idx, combo);
2054                                val += Self::first_kernel_value(
2055                                    scale,
2056                                    self.phi_values[idx],
2057                                    self.q_values[idx],
2058                                    s_combo,
2059                                    g,
2060                                ) * u_knot[j];
2061                            }
2062                            local[i - start] = val;
2063                        }
2064                        (start, local)
2065                    })
2066                    .collect();
2067                for (start, vals) in chunk_results {
2068                    for (offset, &v) in vals.iter().enumerate() {
2069                        result[start + offset] = v;
2070                    }
2071                }
2072                return Ok(result);
2073            }
2074            let mut result = Array1::<f64>::zeros(n);
2075            for i in 0..n {
2076                let base = i * k;
2077                let mut val = 0.0;
2078                for j in 0..k {
2079                    let idx = base + j;
2080                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2081                    val += Self::first_kernel_value(
2082                        scale,
2083                        self.phi_values[idx],
2084                        self.q_values[idx],
2085                        s_combo,
2086                        g,
2087                    ) * u_knot[j];
2088                }
2089                result[i] = val;
2090            }
2091            return Ok(result);
2092        }
2093        if self.is_streaming() {
2094            let scale = self.chart_scale;
2095            let g = self.effective_share(axis);
2096            return self.streaming_forward_mul(&u_knot, |phi, q, _, sb| {
2097                Self::first_kernel_value(scale, phi, q, sb[axis], g)
2098            });
2099        }
2100        let n = self.n;
2101        let k = self.n_knots;
2102        let scale = self.chart_scale;
2103        let g = self.effective_share(axis);
2104        let af = &self.axis_components;
2105        let pv = &self.phi_values;
2106        let qv = &self.q_values;
2107
2108        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
2109            let mut result = Array1::<f64>::zeros(n);
2110            // Parallel over chunks of data points.
2111            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
2112            let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
2113                .into_par_iter()
2114                .map(|chunk_idx| {
2115                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
2116                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
2117                    let mut local = vec![0.0; end - start];
2118                    for i in start..end {
2119                        let base = i * k;
2120                        let mut val = 0.0;
2121                        for j in 0..k {
2122                            val += Self::first_kernel_value(
2123                                scale,
2124                                pv[base + j],
2125                                qv[base + j],
2126                                af[[base + j, axis]],
2127                                g,
2128                            ) * u_knot[j];
2129                        }
2130                        local[i - start] = val;
2131                    }
2132                    (start, local)
2133                })
2134                .collect();
2135            for (start, vals) in chunk_results {
2136                for (offset, &v) in vals.iter().enumerate() {
2137                    result[start + offset] = v;
2138                }
2139            }
2140            Ok(result)
2141        } else {
2142            let mut result = Array1::<f64>::zeros(n);
2143            for i in 0..n {
2144                let base = i * k;
2145                let mut val = 0.0;
2146                for j in 0..k {
2147                    val += Self::first_kernel_value(
2148                        scale,
2149                        pv[base + j],
2150                        qv[base + j],
2151                        af[[base + j, axis]],
2152                        g,
2153                    ) * u_knot[j];
2154                }
2155                result[i] = val;
2156            }
2157            Ok(result)
2158        }
2159    }
2160
2161    /// Compute (∂²X/∂ψ_d²)^T v — diagonal second derivative, same axis.
2162    ///
2163    /// Matrix-free variant of `materialize_second_diag`: avoids forming the
2164    /// full (n × p_out) matrix when only a single adjoint matvec is needed.
2165    pub fn transpose_mul_second_diag(
2166        &self,
2167        axis: usize,
2168        v: &ArrayView1<f64>,
2169    ) -> Result<Array1<f64>, BasisError> {
2170        if self.logarithmic_correction.is_some() {
2171            return self.logarithmic_transpose(ProjectedJetKey::SecondDiagonal(axis), v);
2172        }
2173        if let Some(row_projection) = self.row_projection.as_ref() {
2174            let projected = row_projection.projector.project_vector_owned(v.to_owned());
2175            return self.transpose_mul_second_diag_unprojected(axis, &projected.view());
2176        }
2177        self.transpose_mul_second_diag_unprojected(axis, v)
2178    }
2179
2180    fn transpose_mul_second_diag_unprojected(
2181        &self,
2182        axis: usize,
2183        v: &ArrayView1<f64>,
2184    ) -> Result<Array1<f64>, BasisError> {
2185        assert!(
2186            axis < self.n_axes(),
2187            "implicit psi second diagonal transpose axis out of bounds: axis={axis}, n_axes={}",
2188            self.n_axes()
2189        );
2190        assert_eq!(
2191            v.len(),
2192            self.n,
2193            "implicit psi second diagonal transpose row-adjoint length mismatch"
2194        );
2195        if self.axis_combinations.is_some() {
2196            let combo = self.transformed_axis_combination(axis);
2197            if self.is_streaming() {
2198                let scale = self.chart_scale;
2199                let g = self.effective_share(axis);
2200                let lam = self.chart_lambda(axis, axis);
2201                let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
2202                    let s_combo = combo
2203                        .iter()
2204                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2205                        .sum();
2206                    let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
2207                    Self::second_kernel_value(
2208                        scale, phi, q, t, s_combo, s_combo, overlap_s, g, g, lam,
2209                    )
2210                })?;
2211                return Ok(self.project_and_pad(&raw));
2212            }
2213            let scale = self.chart_scale;
2214            let g = self.effective_share(axis);
2215            let lam = self.chart_lambda(axis, axis);
2216            let raw = self.accumulate_knot_vector(v, |idx| {
2217                let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2218                let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
2219                Self::second_kernel_value(
2220                    scale,
2221                    self.phi_values[idx],
2222                    self.q_values[idx],
2223                    self.t_values[idx],
2224                    s_combo,
2225                    s_combo,
2226                    overlap_s,
2227                    g,
2228                    g,
2229                    lam,
2230                )
2231            });
2232            return Ok(self.project_and_pad(&raw));
2233        }
2234        if self.is_streaming() {
2235            let scale = self.chart_scale;
2236            let g = self.effective_share(axis);
2237            let lam = self.chart_lambda(axis, axis);
2238            let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
2239                let s = sb[axis];
2240                Self::second_kernel_value(scale, phi, q, t, s, s, s, g, g, lam)
2241            })?;
2242            return Ok(self.project_and_pad(&raw));
2243        }
2244        let scale = self.chart_scale;
2245        let g = self.effective_share(axis);
2246        let lam = self.chart_lambda(axis, axis);
2247        let af = &self.axis_components;
2248        let pv = &self.phi_values;
2249        let qv = &self.q_values;
2250        let tv = &self.t_values;
2251        let raw = self.accumulate_knot_vector(v, |idx| {
2252            let s = af[[idx, axis]];
2253            Self::second_kernel_value(scale, pv[idx], qv[idx], tv[idx], s, s, s, g, g, lam)
2254        });
2255        Ok(self.project_and_pad(&raw))
2256    }
2257
2258    /// Compute (∂²X/∂ψ_d∂ψ_e)^T v — cross second derivative (d ≠ e).
2259    pub fn transpose_mul_second_cross(
2260        &self,
2261        axis_d: usize,
2262        axis_e: usize,
2263        v: &ArrayView1<f64>,
2264    ) -> Result<Array1<f64>, BasisError> {
2265        if self.logarithmic_correction.is_some() {
2266            return self.logarithmic_transpose(ProjectedJetKey::SecondCross(axis_d, axis_e), v);
2267        }
2268        if let Some(row_projection) = self.row_projection.as_ref() {
2269            let projected = row_projection.projector.project_vector_owned(v.to_owned());
2270            return self.transpose_mul_second_cross_unprojected(axis_d, axis_e, &projected.view());
2271        }
2272        self.transpose_mul_second_cross_unprojected(axis_d, axis_e, v)
2273    }
2274
2275    fn transpose_mul_second_cross_unprojected(
2276        &self,
2277        axis_d: usize,
2278        axis_e: usize,
2279        v: &ArrayView1<f64>,
2280    ) -> Result<Array1<f64>, BasisError> {
2281        assert!(
2282            axis_d < self.n_axes(),
2283            "implicit psi second cross transpose first axis out of bounds: axis_d={axis_d}, n_axes={}",
2284            self.n_axes()
2285        );
2286        assert!(
2287            axis_e < self.n_axes(),
2288            "implicit psi second cross transpose second axis out of bounds: axis_e={axis_e}, n_axes={}",
2289            self.n_axes()
2290        );
2291        assert_ne!(
2292            axis_d, axis_e,
2293            "implicit psi second cross transpose requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
2294        );
2295        assert_eq!(
2296            v.len(),
2297            self.n,
2298            "implicit psi second cross transpose row-adjoint length mismatch"
2299        );
2300        if self.axis_combinations.is_some() {
2301            let combo_d = self.transformed_axis_combination(axis_d);
2302            let combo_e = self.transformed_axis_combination(axis_e);
2303            if self.is_streaming() {
2304                let scale = self.chart_scale;
2305                let g_d = self.effective_share(axis_d);
2306                let g_e = self.effective_share(axis_e);
2307                let lam = self.chart_lambda(axis_d, axis_e);
2308                let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
2309                    let s_d = combo_d
2310                        .iter()
2311                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2312                        .sum();
2313                    let s_e = combo_e
2314                        .iter()
2315                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2316                        .sum();
2317                    let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
2318                    Self::second_kernel_value(scale, phi, q, t, s_d, s_e, overlap_s, g_d, g_e, lam)
2319                })?;
2320                return Ok(self.project_and_pad(&raw));
2321            }
2322            let scale = self.chart_scale;
2323            let g_d = self.effective_share(axis_d);
2324            let g_e = self.effective_share(axis_e);
2325            let lam = self.chart_lambda(axis_d, axis_e);
2326            let raw = self.accumulate_knot_vector(v, |idx| {
2327                let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
2328                let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
2329                let overlap_s = self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
2330                Self::second_kernel_value(
2331                    scale,
2332                    self.phi_values[idx],
2333                    self.q_values[idx],
2334                    self.t_values[idx],
2335                    s_d,
2336                    s_e,
2337                    overlap_s,
2338                    g_d,
2339                    g_e,
2340                    lam,
2341                )
2342            });
2343            return Ok(self.project_and_pad(&raw));
2344        }
2345        if self.is_streaming() {
2346            let scale = self.chart_scale;
2347            let g_d = self.effective_share(axis_d);
2348            let g_e = self.effective_share(axis_e);
2349            let lam = self.chart_lambda(axis_d, axis_e);
2350            let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
2351                Self::second_kernel_value(
2352                    scale, phi, q, t, sb[axis_d], sb[axis_e], 0.0, g_d, g_e, lam,
2353                )
2354            })?;
2355            return Ok(self.project_and_pad(&raw));
2356        }
2357        let scale = self.chart_scale;
2358        let g_d = self.effective_share(axis_d);
2359        let g_e = self.effective_share(axis_e);
2360        let lam = self.chart_lambda(axis_d, axis_e);
2361        let af = &self.axis_components;
2362        let pv = &self.phi_values;
2363        let qv = &self.q_values;
2364        let tv = &self.t_values;
2365        let raw = self.accumulate_knot_vector(v, |idx| {
2366            Self::second_kernel_value(
2367                scale,
2368                pv[idx],
2369                qv[idx],
2370                tv[idx],
2371                af[[idx, axis_d]],
2372                af[[idx, axis_e]],
2373                0.0,
2374                g_d,
2375                g_e,
2376                lam,
2377            )
2378        });
2379        Ok(self.project_and_pad(&raw))
2380    }
2381
2382    /// Compute (∂²X/∂ψ_d²) u — forward diagonal second derivative.
2383    pub fn forward_mul_second_diag(
2384        &self,
2385        axis: usize,
2386        u: &ArrayView1<f64>,
2387    ) -> Result<Array1<f64>, BasisError> {
2388        if self.logarithmic_correction.is_some() {
2389            return self.logarithmic_forward(ProjectedJetKey::SecondDiagonal(axis), u);
2390        }
2391        let values = self.forward_mul_second_diag_unprojected(axis, u)?;
2392        Ok(match self.row_projection.as_ref() {
2393            Some(row_projection) => row_projection.projector.project_vector_owned(values),
2394            None => values,
2395        })
2396    }
2397
2398    fn forward_mul_second_diag_unprojected(
2399        &self,
2400        axis: usize,
2401        u: &ArrayView1<f64>,
2402    ) -> Result<Array1<f64>, BasisError> {
2403        assert!(
2404            axis < self.n_axes(),
2405            "implicit psi second diagonal forward axis out of bounds: axis={axis}, n_axes={}",
2406            self.n_axes()
2407        );
2408        assert_eq!(
2409            u.len(),
2410            self.p_out(),
2411            "implicit psi second diagonal forward coefficient length mismatch"
2412        );
2413        let u_knot = self.unproject(u);
2414        if self.axis_combinations.is_some() {
2415            let combo = self.transformed_axis_combination(axis);
2416            if self.is_streaming() {
2417                let scale = self.chart_scale;
2418                let g = self.effective_share(axis);
2419                let lam = self.chart_lambda(axis, axis);
2420                return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
2421                    let s_combo = combo
2422                        .iter()
2423                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2424                        .sum();
2425                    let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
2426                    Self::second_kernel_value(
2427                        scale, phi, q, t, s_combo, s_combo, overlap_s, g, g, lam,
2428                    )
2429                });
2430            }
2431            let n = self.n;
2432            let k = self.n_knots;
2433            let scale = self.chart_scale;
2434            let g = self.effective_share(axis);
2435            let lam = self.chart_lambda(axis, axis);
2436            let compute_row = |i: usize| -> f64 {
2437                let base = i * k;
2438                let mut val = 0.0;
2439                for j in 0..k {
2440                    let idx = base + j;
2441                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2442                    let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
2443                    val += Self::second_kernel_value(
2444                        scale,
2445                        self.phi_values[idx],
2446                        self.q_values[idx],
2447                        self.t_values[idx],
2448                        s_combo,
2449                        s_combo,
2450                        overlap_s,
2451                        g,
2452                        g,
2453                        lam,
2454                    ) * u_knot[j];
2455                }
2456                val
2457            };
2458            if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
2459                let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
2460                let mut result = Array1::<f64>::zeros(n);
2461                let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
2462                    .into_par_iter()
2463                    .map(|chunk_idx| {
2464                        let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
2465                        let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
2466                        let local: Vec<f64> = (start..end).map(compute_row).collect();
2467                        (start, local)
2468                    })
2469                    .collect();
2470                for (start, vals) in chunk_results {
2471                    for (offset, &value) in vals.iter().enumerate() {
2472                        result[start + offset] = value;
2473                    }
2474                }
2475                return Ok(result);
2476            }
2477            return Ok(Array1::from_vec((0..n).map(compute_row).collect()));
2478        }
2479        if self.is_streaming() {
2480            let scale = self.chart_scale;
2481            let g = self.effective_share(axis);
2482            let lam = self.chart_lambda(axis, axis);
2483            return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
2484                let s = sb[axis];
2485                Self::second_kernel_value(scale, phi, q, t, s, s, s, g, g, lam)
2486            });
2487        }
2488        let n = self.n;
2489        let k = self.n_knots;
2490        let scale = self.chart_scale;
2491        let g = self.effective_share(axis);
2492        let lam = self.chart_lambda(axis, axis);
2493        let af = &self.axis_components;
2494        let pv = &self.phi_values;
2495        let qv = &self.q_values;
2496        let tv = &self.t_values;
2497        let compute_row = |i: usize| -> f64 {
2498            let base = i * k;
2499            let mut val = 0.0;
2500            for j in 0..k {
2501                let s = af[[base + j, axis]];
2502                val += Self::second_kernel_value(
2503                    scale,
2504                    pv[base + j],
2505                    qv[base + j],
2506                    tv[base + j],
2507                    s,
2508                    s,
2509                    s,
2510                    g,
2511                    g,
2512                    lam,
2513                ) * u_knot[j];
2514            }
2515            val
2516        };
2517
2518        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
2519            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
2520            let mut result = Array1::<f64>::zeros(n);
2521            let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
2522                .into_par_iter()
2523                .map(|chunk_idx| {
2524                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
2525                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
2526                    let local: Vec<f64> = (start..end).map(compute_row).collect();
2527                    (start, local)
2528                })
2529                .collect();
2530            for (start, vals) in chunk_results {
2531                for (offset, &value) in vals.iter().enumerate() {
2532                    result[start + offset] = value;
2533                }
2534            }
2535            Ok(result)
2536        } else {
2537            Ok(Array1::from_vec((0..n).map(compute_row).collect()))
2538        }
2539    }
2540
2541    /// Compute (∂²X/∂ψ_d∂ψ_e) u — forward cross second derivative.
2542    pub fn forward_mul_second_cross(
2543        &self,
2544        axis_d: usize,
2545        axis_e: usize,
2546        u: &ArrayView1<f64>,
2547    ) -> Result<Array1<f64>, BasisError> {
2548        if self.logarithmic_correction.is_some() {
2549            return self.logarithmic_forward(ProjectedJetKey::SecondCross(axis_d, axis_e), u);
2550        }
2551        let values = self.forward_mul_second_cross_unprojected(axis_d, axis_e, u)?;
2552        Ok(match self.row_projection.as_ref() {
2553            Some(row_projection) => row_projection.projector.project_vector_owned(values),
2554            None => values,
2555        })
2556    }
2557
2558    fn forward_mul_second_cross_unprojected(
2559        &self,
2560        axis_d: usize,
2561        axis_e: usize,
2562        u: &ArrayView1<f64>,
2563    ) -> Result<Array1<f64>, BasisError> {
2564        assert!(
2565            axis_d < self.n_axes(),
2566            "implicit psi second cross forward first axis out of bounds: axis_d={axis_d}, n_axes={}",
2567            self.n_axes()
2568        );
2569        assert!(
2570            axis_e < self.n_axes(),
2571            "implicit psi second cross forward second axis out of bounds: axis_e={axis_e}, n_axes={}",
2572            self.n_axes()
2573        );
2574        assert_ne!(
2575            axis_d, axis_e,
2576            "implicit psi second cross forward requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
2577        );
2578        assert_eq!(
2579            u.len(),
2580            self.p_out(),
2581            "implicit psi second cross forward coefficient length mismatch"
2582        );
2583        let u_knot = self.unproject(u);
2584        if self.axis_combinations.is_some() {
2585            let combo_d = self.transformed_axis_combination(axis_d);
2586            let combo_e = self.transformed_axis_combination(axis_e);
2587            if self.is_streaming() {
2588                let scale = self.chart_scale;
2589                let g_d = self.effective_share(axis_d);
2590                let g_e = self.effective_share(axis_e);
2591                let lam = self.chart_lambda(axis_d, axis_e);
2592                return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
2593                    let s_d = combo_d
2594                        .iter()
2595                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2596                        .sum();
2597                    let s_e = combo_e
2598                        .iter()
2599                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2600                        .sum();
2601                    let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
2602                    Self::second_kernel_value(scale, phi, q, t, s_d, s_e, overlap_s, g_d, g_e, lam)
2603                });
2604            }
2605            let n = self.n;
2606            let k = self.n_knots;
2607            let scale = self.chart_scale;
2608            let g_d = self.effective_share(axis_d);
2609            let g_e = self.effective_share(axis_e);
2610            let lam = self.chart_lambda(axis_d, axis_e);
2611            let compute_row = |i: usize| -> f64 {
2612                let base = i * k;
2613                let mut val = 0.0;
2614                for j in 0..k {
2615                    let idx = base + j;
2616                    let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
2617                    let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
2618                    let overlap_s =
2619                        self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
2620                    val += Self::second_kernel_value(
2621                        scale,
2622                        self.phi_values[idx],
2623                        self.q_values[idx],
2624                        self.t_values[idx],
2625                        s_d,
2626                        s_e,
2627                        overlap_s,
2628                        g_d,
2629                        g_e,
2630                        lam,
2631                    ) * u_knot[j];
2632                }
2633                val
2634            };
2635            if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
2636                let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
2637                let mut result = Array1::<f64>::zeros(n);
2638                let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
2639                    .into_par_iter()
2640                    .map(|chunk_idx| {
2641                        let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
2642                        let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
2643                        let local: Vec<f64> = (start..end).map(compute_row).collect();
2644                        (start, local)
2645                    })
2646                    .collect();
2647                for (start, vals) in chunk_results {
2648                    for (offset, &value) in vals.iter().enumerate() {
2649                        result[start + offset] = value;
2650                    }
2651                }
2652                return Ok(result);
2653            }
2654            return Ok(Array1::from_vec((0..n).map(compute_row).collect()));
2655        }
2656        if self.is_streaming() {
2657            let scale = self.chart_scale;
2658            let g_d = self.effective_share(axis_d);
2659            let g_e = self.effective_share(axis_e);
2660            let lam = self.chart_lambda(axis_d, axis_e);
2661            return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
2662                Self::second_kernel_value(
2663                    scale, phi, q, t, sb[axis_d], sb[axis_e], 0.0, g_d, g_e, lam,
2664                )
2665            });
2666        }
2667        let n = self.n;
2668        let k = self.n_knots;
2669        let scale = self.chart_scale;
2670        let g_d = self.effective_share(axis_d);
2671        let g_e = self.effective_share(axis_e);
2672        let lam = self.chart_lambda(axis_d, axis_e);
2673        let af = &self.axis_components;
2674        let pv = &self.phi_values;
2675        let qv = &self.q_values;
2676        let tv = &self.t_values;
2677        let compute_row = |i: usize| -> f64 {
2678            let base = i * k;
2679            let mut val = 0.0;
2680            for j in 0..k {
2681                val += Self::second_kernel_value(
2682                    scale,
2683                    pv[base + j],
2684                    qv[base + j],
2685                    tv[base + j],
2686                    af[[base + j, axis_d]],
2687                    af[[base + j, axis_e]],
2688                    0.0,
2689                    g_d,
2690                    g_e,
2691                    lam,
2692                ) * u_knot[j];
2693            }
2694            val
2695        };
2696
2697        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
2698            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
2699            let mut result = Array1::<f64>::zeros(n);
2700            let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
2701                .into_par_iter()
2702                .map(|chunk_idx| {
2703                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
2704                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
2705                    let local: Vec<f64> = (start..end).map(compute_row).collect();
2706                    (start, local)
2707                })
2708                .collect();
2709            for (start, vals) in chunk_results {
2710                for (offset, &value) in vals.iter().enumerate() {
2711                    result[start + offset] = value;
2712                }
2713            }
2714            Ok(result)
2715        } else {
2716            Ok(Array1::from_vec((0..n).map(compute_row).collect()))
2717        }
2718    }
2719
2720    /// Materialize the full (n × p_out) first-derivative matrix for axis d.
2721    ///
2722    /// Efficient O(n * k) construction: builds the raw (n × k) kernel derivative
2723    /// matrix directly, then projects through identifiability transforms.
2724    /// This is used when the dense matrix is needed temporarily (e.g., for
2725    /// HyperCoord construction) while avoiding simultaneous storage of all D axes.
2726    pub fn materialize_first(&self, axis: usize) -> Result<Array2<f64>, BasisError> {
2727        if self.logarithmic_correction.is_some() {
2728            if self.enforces_dense_materialization_budget() {
2729                assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2730            }
2731            return self.row_chunk_first(axis, 0..self.n);
2732        }
2733        assert!(
2734            axis < self.n_axes(),
2735            "implicit psi first materialization axis out of bounds: axis={axis}, n_axes={}",
2736            self.n_axes()
2737        );
2738        if self.enforces_dense_materialization_budget() {
2739            assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2740        }
2741        if self.axis_combinations.is_some() {
2742            let combo = self.transformed_axis_combination(axis);
2743            if self.is_streaming() {
2744                let scale = self.chart_scale;
2745                let g = self.effective_share(axis);
2746                return self.streaming_materialize(|phi, q, _, sb| {
2747                    let s_combo = combo
2748                        .iter()
2749                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2750                        .sum();
2751                    Self::first_kernel_value(scale, phi, q, s_combo, g)
2752                });
2753            }
2754            let n = self.n;
2755            let k = self.n_knots;
2756            let scale = self.chart_scale;
2757            let g = self.effective_share(axis);
2758            let mut raw = Array2::<f64>::zeros((n, k));
2759            for i in 0..n {
2760                let base = i * k;
2761                for j in 0..k {
2762                    let idx = base + j;
2763                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2764                    raw[[i, j]] = Self::first_kernel_value(
2765                        scale,
2766                        self.phi_values[idx],
2767                        self.q_values[idx],
2768                        s_combo,
2769                        g,
2770                    );
2771                }
2772            }
2773            return Ok(self.project_matrix(raw));
2774        }
2775        if self.is_streaming() {
2776            let scale = self.chart_scale;
2777            let g = self.effective_share(axis);
2778            return self.streaming_materialize(|phi, q, _, sb| {
2779                Self::first_kernel_value(scale, phi, q, sb[axis], g)
2780            });
2781        }
2782        let n = self.n;
2783        let k = self.n_knots;
2784        let scale = self.chart_scale;
2785        let g = self.effective_share(axis);
2786        let mut raw = Array2::<f64>::zeros((n, k));
2787        for i in 0..n {
2788            let base = i * k;
2789            for j in 0..k {
2790                raw[[i, j]] = Self::first_kernel_value(
2791                    scale,
2792                    self.phi_values[base + j],
2793                    self.q_values[base + j],
2794                    self.axis_components[[base + j, axis]],
2795                    g,
2796                );
2797            }
2798        }
2799        Ok(self.project_matrix(raw))
2800    }
2801
2802    /// Materialize the full (n × p_out) second diagonal derivative matrix for axis d.
2803    pub fn materialize_second_diag(&self, axis: usize) -> Result<Array2<f64>, BasisError> {
2804        if self.logarithmic_correction.is_some() {
2805            if self.enforces_dense_materialization_budget() {
2806                assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2807            }
2808            return self.row_chunk_second_diag(axis, 0..self.n);
2809        }
2810        assert!(
2811            axis < self.n_axes(),
2812            "implicit psi second diagonal materialization axis out of bounds: axis={axis}, n_axes={}",
2813            self.n_axes()
2814        );
2815        if self.enforces_dense_materialization_budget() {
2816            assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2817        }
2818        if self.axis_combinations.is_some() {
2819            let combo = self.transformed_axis_combination(axis);
2820            if self.is_streaming() {
2821                let scale = self.chart_scale;
2822                let g = self.effective_share(axis);
2823                let lam = self.chart_lambda(axis, axis);
2824                return self.streaming_materialize(|phi, q, t, sb| {
2825                    let s_combo = combo
2826                        .iter()
2827                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2828                        .sum();
2829                    let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
2830                    Self::second_kernel_value(
2831                        scale, phi, q, t, s_combo, s_combo, overlap_s, g, g, lam,
2832                    )
2833                });
2834            }
2835            let n = self.n;
2836            let k = self.n_knots;
2837            let scale = self.chart_scale;
2838            let g = self.effective_share(axis);
2839            let lam = self.chart_lambda(axis, axis);
2840            let mut raw = Array2::<f64>::zeros((n, k));
2841            for i in 0..n {
2842                let base = i * k;
2843                for j in 0..k {
2844                    let idx = base + j;
2845                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2846                    let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
2847                    raw[[i, j]] = Self::second_kernel_value(
2848                        scale,
2849                        self.phi_values[idx],
2850                        self.q_values[idx],
2851                        self.t_values[idx],
2852                        s_combo,
2853                        s_combo,
2854                        overlap_s,
2855                        g,
2856                        g,
2857                        lam,
2858                    );
2859                }
2860            }
2861            return Ok(self.project_matrix(raw));
2862        }
2863        if self.is_streaming() {
2864            let scale = self.chart_scale;
2865            let g = self.effective_share(axis);
2866            let lam = self.chart_lambda(axis, axis);
2867            return self.streaming_materialize(|phi, q, t, sb| {
2868                let s = sb[axis];
2869                Self::second_kernel_value(scale, phi, q, t, s, s, s, g, g, lam)
2870            });
2871        }
2872        let n = self.n;
2873        let k = self.n_knots;
2874        let scale = self.chart_scale;
2875        let g = self.effective_share(axis);
2876        let lam = self.chart_lambda(axis, axis);
2877        let mut raw = Array2::<f64>::zeros((n, k));
2878        for i in 0..n {
2879            let base = i * k;
2880            for j in 0..k {
2881                let s = self.axis_components[[base + j, axis]];
2882                raw[[i, j]] = Self::second_kernel_value(
2883                    scale,
2884                    self.phi_values[base + j],
2885                    self.q_values[base + j],
2886                    self.t_values[base + j],
2887                    s,
2888                    s,
2889                    s,
2890                    g,
2891                    g,
2892                    lam,
2893                );
2894            }
2895        }
2896        Ok(self.project_matrix(raw))
2897    }
2898
2899    /// Materialize the full (n × p_out) cross second derivative matrix for axes (d, e).
2900    ///
2901    /// Dense materialization of the t · s_d · s_e cross coupling.
2902    pub fn materialize_second_cross(
2903        &self,
2904        axis_d: usize,
2905        axis_e: usize,
2906    ) -> Result<Array2<f64>, BasisError> {
2907        if self.logarithmic_correction.is_some() {
2908            if self.enforces_dense_materialization_budget() {
2909                assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2910            }
2911            return self.row_chunk_second_cross(axis_d, axis_e, 0..self.n);
2912        }
2913        assert!(
2914            axis_d < self.n_axes(),
2915            "implicit psi second cross materialization first axis out of bounds: axis_d={axis_d}, n_axes={}",
2916            self.n_axes()
2917        );
2918        assert!(
2919            axis_e < self.n_axes(),
2920            "implicit psi second cross materialization second axis out of bounds: axis_e={axis_e}, n_axes={}",
2921            self.n_axes()
2922        );
2923        assert_ne!(
2924            axis_d, axis_e,
2925            "implicit psi second cross materialization requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
2926        );
2927        if self.enforces_dense_materialization_budget() {
2928            assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2929        }
2930        if self.axis_combinations.is_some() {
2931            let combo_d = self.transformed_axis_combination(axis_d);
2932            let combo_e = self.transformed_axis_combination(axis_e);
2933            if self.is_streaming() {
2934                let scale = self.chart_scale;
2935                let g_d = self.effective_share(axis_d);
2936                let g_e = self.effective_share(axis_e);
2937                let lam = self.chart_lambda(axis_d, axis_e);
2938                return self.streaming_materialize(|phi, q, t, sb| {
2939                    let s_d = combo_d
2940                        .iter()
2941                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2942                        .sum();
2943                    let s_e = combo_e
2944                        .iter()
2945                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2946                        .sum();
2947                    let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
2948                    Self::second_kernel_value(scale, phi, q, t, s_d, s_e, overlap_s, g_d, g_e, lam)
2949                });
2950            }
2951            let n = self.n;
2952            let k = self.n_knots;
2953            let scale = self.chart_scale;
2954            let g_d = self.effective_share(axis_d);
2955            let g_e = self.effective_share(axis_e);
2956            let lam = self.chart_lambda(axis_d, axis_e);
2957            let mut raw = Array2::<f64>::zeros((n, k));
2958            for i in 0..n {
2959                let base = i * k;
2960                for j in 0..k {
2961                    let idx = base + j;
2962                    let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
2963                    let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
2964                    let overlap_s =
2965                        self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
2966                    raw[[i, j]] = Self::second_kernel_value(
2967                        scale,
2968                        self.phi_values[idx],
2969                        self.q_values[idx],
2970                        self.t_values[idx],
2971                        s_d,
2972                        s_e,
2973                        overlap_s,
2974                        g_d,
2975                        g_e,
2976                        lam,
2977                    );
2978                }
2979            }
2980            return Ok(self.project_matrix(raw));
2981        }
2982        if self.is_streaming() {
2983            let scale = self.chart_scale;
2984            let g_d = self.effective_share(axis_d);
2985            let g_e = self.effective_share(axis_e);
2986            let lam = self.chart_lambda(axis_d, axis_e);
2987            return self.streaming_materialize(|phi, q, t, sb| {
2988                Self::second_kernel_value(
2989                    scale, phi, q, t, sb[axis_d], sb[axis_e], 0.0, g_d, g_e, lam,
2990                )
2991            });
2992        }
2993        let n = self.n;
2994        let k = self.n_knots;
2995        let scale = self.chart_scale;
2996        let g_d = self.effective_share(axis_d);
2997        let g_e = self.effective_share(axis_e);
2998        let lam = self.chart_lambda(axis_d, axis_e);
2999        let mut raw = Array2::<f64>::zeros((n, k));
3000        for i in 0..n {
3001            let base = i * k;
3002            for j in 0..k {
3003                raw[[i, j]] = Self::second_kernel_value(
3004                    scale,
3005                    self.phi_values[base + j],
3006                    self.q_values[base + j],
3007                    self.t_values[base + j],
3008                    self.axis_components[[base + j, axis_d]],
3009                    self.axis_components[[base + j, axis_e]],
3010                    0.0,
3011                    g_d,
3012                    g_e,
3013                    lam,
3014                );
3015            }
3016        }
3017        Ok(self.project_matrix(raw))
3018    }
3019
3020    /// Project a raw (n × k) kernel-space matrix through all transforms to
3021    /// produce an (n × p_out) matrix: Z_kernel → pad poly → full ident.
3022    pub(crate) fn project_matrix(&self, raw: Array2<f64>) -> Array2<f64> {
3023        // Step 1: kernel constraint projection.
3024        let constrained = match &self.ident_transform {
3025            Some(z) => fast_ab(&raw, z),
3026            None => raw,
3027        };
3028
3029        // Step 2: polynomial padding.
3030        let padded = if self.n_poly > 0 {
3031            let cols = constrained.ncols();
3032            let mut out = Array2::<f64>::zeros((self.n, cols + self.n_poly));
3033            out.slice_mut(s![.., ..cols]).assign(&constrained);
3034            out
3035        } else {
3036            constrained
3037        };
3038
3039        // Step 3: full identifiability transform.
3040        let projected = match &self.full_ident_transform {
3041            Some(zf) => fast_ab(&padded, zf),
3042            None => padded,
3043        };
3044        match self.row_projection.as_ref() {
3045            Some(row_projection) => row_projection.projector.project_matrix_owned(projected),
3046            None => projected,
3047        }
3048    }
3049
3050    pub(crate) fn project_matrix_rows(&self, raw: Array2<f64>) -> Array2<f64> {
3051        let nrows = raw.nrows();
3052        let constrained = match &self.ident_transform {
3053            Some(z) => fast_ab(&raw, z),
3054            None => raw,
3055        };
3056        let padded = if self.n_poly > 0 {
3057            let cols = constrained.ncols();
3058            let mut out = Array2::<f64>::zeros((nrows, cols + self.n_poly));
3059            out.slice_mut(s![.., ..cols]).assign(&constrained);
3060            out
3061        } else {
3062            constrained
3063        };
3064        match &self.full_ident_transform {
3065            Some(zf) => fast_ab(&padded, zf),
3066            None => padded,
3067        }
3068    }
3069
3070    pub(crate) fn row_chunk_with_kernel<G>(
3071        &self,
3072        rows: std::ops::Range<usize>,
3073        deriv_fn: G,
3074    ) -> Result<Array2<f64>, BasisError>
3075    where
3076        G: Fn(f64, f64, f64, &[f64], usize) -> f64,
3077    {
3078        let raw = self.row_chunk_with_kernel_raw(rows, deriv_fn)?;
3079        Ok(self.project_matrix_rows(raw))
3080    }
3081
3082    /// Like `row_chunk_with_kernel` but returns the raw (chunk × n_knots)
3083    /// kernel scalars without the identifiability/padding projection. Used
3084    /// by `forward_mul_matrix`, which does the projection on the rank side
3085    /// instead (`unproject_matrix(F)`) so the (n × p_out) projected
3086    /// derivative is never materialized for large-scale row counts.
3087    pub(crate) fn row_chunk_with_kernel_raw<G>(
3088        &self,
3089        rows: std::ops::Range<usize>,
3090        deriv_fn: G,
3091    ) -> Result<Array2<f64>, BasisError>
3092    where
3093        G: Fn(f64, f64, f64, &[f64], usize) -> f64,
3094    {
3095        let mut raw = Array2::<f64>::zeros((rows.end - rows.start, self.n_knots));
3096        if let Some(st) = self.streaming.as_ref() {
3097            let mut sb = vec![0.0; self.n_axes];
3098            if let Some(cache) = st.ensure_triplet_cache() {
3099                for (local, i) in rows.enumerate() {
3100                    let base = i * self.n_knots;
3101                    for j in 0..self.n_knots {
3102                        let idx = base + j;
3103                        st.fill_s_buf(i, j, &mut sb);
3104                        raw[[local, j]] =
3105                            deriv_fn(cache.phi[idx], cache.q[idx], cache.t[idx], &sb, idx);
3106                    }
3107                }
3108            } else {
3109                for (local, i) in rows.enumerate() {
3110                    for j in 0..self.n_knots {
3111                        let (phi, q, t) = st.compute_pair(i, j, &mut sb)?;
3112                        raw[[local, j]] = deriv_fn(phi, q, t, &sb, i * self.n_knots + j);
3113                    }
3114                }
3115            }
3116        } else {
3117            for (local, i) in rows.enumerate() {
3118                let base = i * self.n_knots;
3119                for j in 0..self.n_knots {
3120                    let idx = base + j;
3121                    raw[[local, j]] = deriv_fn(
3122                        self.phi_values[idx],
3123                        self.q_values[idx],
3124                        self.t_values[idx],
3125                        &[],
3126                        idx,
3127                    );
3128                }
3129            }
3130        }
3131        Ok(raw)
3132    }
3133
3134    pub fn row_chunk_first(
3135        &self,
3136        axis: usize,
3137        rows: std::ops::Range<usize>,
3138    ) -> Result<Array2<f64>, BasisError> {
3139        let raw = self.row_chunk_first_raw(axis, rows)?;
3140        Ok(self.project_matrix_rows(raw))
3141    }
3142
3143    /// Raw (chunk × n_knots) first-order kernel scalars for axis d, without
3144    /// the identifiability/padding projection. Pairs with `unproject_matrix`
3145    /// in `forward_mul_matrix`: the kernel scalars stay in raw knot space
3146    /// while the rank side (F) is unprojected to knot space, so the per-chunk
3147    /// GEMM is (chunk × n_knots) · (n_knots × rank) rather than (chunk × p_out)
3148    /// · (p_out × rank). Saves both flops and a (chunk × p_out) intermediate.
3149    pub fn row_chunk_first_raw(
3150        &self,
3151        axis: usize,
3152        rows: std::ops::Range<usize>,
3153    ) -> Result<Array2<f64>, BasisError> {
3154        assert!(
3155            axis < self.n_axes(),
3156            "implicit psi first raw row chunk axis out of bounds: axis={axis}, n_axes={}",
3157            self.n_axes()
3158        );
3159        let scale = self.chart_scale;
3160        let g = self.effective_share(axis);
3161        let mut raw = if self.axis_combinations.is_some() {
3162            let combo = self.transformed_axis_combination(axis);
3163            self.row_chunk_with_kernel_raw(rows.clone(), |phi, q, _, sb, idx| {
3164                let s_combo = if sb.is_empty() {
3165                    self.transformed_combo_axis_value_materialized(idx, combo)
3166                } else {
3167                    combo
3168                        .iter()
3169                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
3170                        .sum()
3171                };
3172                Self::first_kernel_value(scale, phi, q, s_combo, g)
3173            })?
3174        } else {
3175            self.row_chunk_with_kernel_raw(rows.clone(), |phi, q, _, sb, idx| {
3176                let s = if sb.is_empty() {
3177                    self.axis_components[[idx, axis]]
3178                } else {
3179                    sb[axis]
3180                };
3181                Self::first_kernel_value(scale, phi, q, s, g)
3182            })?
3183        };
3184        self.add_logarithmic_correction(ProjectedJetKey::FirstRaw(axis), rows.clone(), &mut raw);
3185        self.subtract_projected_row_chunk_correction(
3186            ProjectedJetKey::FirstRaw(axis),
3187            rows,
3188            &mut raw,
3189        )?;
3190        Ok(raw)
3191    }
3192
3193    pub fn row_chunk_second_diag(
3194        &self,
3195        axis: usize,
3196        rows: std::ops::Range<usize>,
3197    ) -> Result<Array2<f64>, BasisError> {
3198        assert!(
3199            axis < self.n_axes(),
3200            "implicit psi second diagonal row chunk axis out of bounds: axis={axis}, n_axes={}",
3201            self.n_axes()
3202        );
3203        let scale = self.chart_scale;
3204        let g = self.effective_share(axis);
3205        let lam = self.chart_lambda(axis, axis);
3206        let mut chunk = if self.axis_combinations.is_some() {
3207            let combo = self.transformed_axis_combination(axis);
3208            self.row_chunk_with_kernel(rows.clone(), |phi, q, t, sb, idx| {
3209                let s_combo = if sb.is_empty() {
3210                    self.transformed_combo_axis_value_materialized(idx, combo)
3211                } else {
3212                    combo
3213                        .iter()
3214                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
3215                        .sum()
3216                };
3217                let overlap = if sb.is_empty() {
3218                    self.transformed_combo_overlap_materialized(idx, combo, combo)
3219                } else {
3220                    Self::transformed_combo_overlap_streaming(combo, combo, sb)
3221                };
3222                Self::second_kernel_value(scale, phi, q, t, s_combo, s_combo, overlap, g, g, lam)
3223            })?
3224        } else {
3225            self.row_chunk_with_kernel(rows.clone(), |phi, q, t, sb, idx| {
3226                let s = if sb.is_empty() {
3227                    self.axis_components[[idx, axis]]
3228                } else {
3229                    sb[axis]
3230                };
3231                Self::second_kernel_value(scale, phi, q, t, s, s, s, g, g, lam)
3232            })?
3233        };
3234        self.add_logarithmic_correction(
3235            ProjectedJetKey::SecondDiagonal(axis),
3236            rows.clone(),
3237            &mut chunk,
3238        );
3239        self.subtract_projected_row_chunk_correction(
3240            ProjectedJetKey::SecondDiagonal(axis),
3241            rows,
3242            &mut chunk,
3243        )?;
3244        Ok(chunk)
3245    }
3246
3247    pub fn row_chunk_second_cross(
3248        &self,
3249        axis_d: usize,
3250        axis_e: usize,
3251        rows: std::ops::Range<usize>,
3252    ) -> Result<Array2<f64>, BasisError> {
3253        assert!(
3254            axis_d < self.n_axes(),
3255            "implicit psi second cross row chunk first axis out of bounds: axis_d={axis_d}, n_axes={}",
3256            self.n_axes()
3257        );
3258        assert!(
3259            axis_e < self.n_axes(),
3260            "implicit psi second cross row chunk second axis out of bounds: axis_e={axis_e}, n_axes={}",
3261            self.n_axes()
3262        );
3263        assert_ne!(
3264            axis_d, axis_e,
3265            "implicit psi second cross row chunk requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
3266        );
3267        let scale = self.chart_scale;
3268        let g_d = self.effective_share(axis_d);
3269        let g_e = self.effective_share(axis_e);
3270        let lam = self.chart_lambda(axis_d, axis_e);
3271        let mut chunk = if self.axis_combinations.is_some() {
3272            let combo_d = self.transformed_axis_combination(axis_d);
3273            let combo_e = self.transformed_axis_combination(axis_e);
3274            self.row_chunk_with_kernel(rows.clone(), |phi, q, t, sb, idx| {
3275                let s_d = if sb.is_empty() {
3276                    self.transformed_combo_axis_value_materialized(idx, combo_d)
3277                } else {
3278                    combo_d
3279                        .iter()
3280                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
3281                        .sum()
3282                };
3283                let s_e = if sb.is_empty() {
3284                    self.transformed_combo_axis_value_materialized(idx, combo_e)
3285                } else {
3286                    combo_e
3287                        .iter()
3288                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
3289                        .sum()
3290                };
3291                let overlap = if sb.is_empty() {
3292                    self.transformed_combo_overlap_materialized(idx, combo_d, combo_e)
3293                } else {
3294                    Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb)
3295                };
3296                Self::second_kernel_value(scale, phi, q, t, s_d, s_e, overlap, g_d, g_e, lam)
3297            })?
3298        } else {
3299            self.row_chunk_with_kernel(rows.clone(), |phi, q, t, sb, idx| {
3300                let sd = if sb.is_empty() {
3301                    self.axis_components[[idx, axis_d]]
3302                } else {
3303                    sb[axis_d]
3304                };
3305                let se = if sb.is_empty() {
3306                    self.axis_components[[idx, axis_e]]
3307                } else {
3308                    sb[axis_e]
3309                };
3310                Self::second_kernel_value(scale, phi, q, t, sd, se, 0.0, g_d, g_e, lam)
3311            })?
3312        };
3313        self.add_logarithmic_correction(
3314            ProjectedJetKey::SecondCross(axis_d, axis_e),
3315            rows.clone(),
3316            &mut chunk,
3317        );
3318        self.subtract_projected_row_chunk_correction(
3319            ProjectedJetKey::SecondCross(axis_d, axis_e),
3320            rows,
3321            &mut chunk,
3322        )?;
3323        Ok(chunk)
3324    }
3325
3326    /// Single-row specialization of `row_chunk_first(axis, row..row+1)` that
3327    /// writes the length-`p_out` row directly into the caller-provided buffer.
3328    ///
3329    /// This is the row-local API used by `CustomFamilyPsiLinearMapRef::row_vector`
3330    /// for survival rowwise exact-Hessian paths, which previously applied a
3331    /// unit-vector `transpose_mul` trick (O(n·K) per row) to recover a single
3332    /// row. Avoids allocating a temporary (1 × p_out) matrix per row call.
3333    pub fn row_vector_first_into(
3334        &self,
3335        axis: usize,
3336        row: usize,
3337        mut out: ArrayViewMut1<'_, f64>,
3338    ) -> Result<(), BasisError> {
3339        assert!(
3340            row < self.n,
3341            "implicit psi row-vector request out of bounds: row={row}, n={}",
3342            self.n
3343        );
3344        assert_eq!(
3345            out.len(),
3346            self.p_out(),
3347            "implicit psi row-vector output length mismatch"
3348        );
3349        let chunk = self.row_chunk_first(axis, row..row + 1)?;
3350        out.assign(&chunk.row(0));
3351        Ok(())
3352    }
3353
3354    pub(crate) fn transformed_axis_combination(&self, axis: usize) -> &[(usize, f64)] {
3355        self.axis_combinations
3356            .as_ref()
3357            .expect("transformed axis combinations")
3358            .get(axis)
3359            .map(Vec::as_slice)
3360            .expect("transformed axis index")
3361    }
3362
3363    #[inline]
3364    pub(crate) fn transformed_combo_axis_value_materialized(
3365        &self,
3366        idx: usize,
3367        combo: &[(usize, f64)],
3368    ) -> f64 {
3369        combo
3370            .iter()
3371            .map(|(raw_axis, coeff)| coeff * self.axis_components[[idx, *raw_axis]])
3372            .sum()
3373    }
3374
3375    #[inline]
3376    pub(crate) fn transformed_combo_overlap_streaming(
3377        combo_left: &[(usize, f64)],
3378        combo_right: &[(usize, f64)],
3379        sb: &[f64],
3380    ) -> f64 {
3381        let mut overlap = 0.0;
3382        for &(left_axis, left_coeff) in combo_left {
3383            for &(right_axis, right_coeff) in combo_right {
3384                if left_axis == right_axis {
3385                    overlap += left_coeff * right_coeff * sb[left_axis];
3386                }
3387            }
3388        }
3389        overlap
3390    }
3391
3392    #[inline]
3393    pub(crate) fn transformed_combo_overlap_materialized(
3394        &self,
3395        idx: usize,
3396        combo_left: &[(usize, f64)],
3397        combo_right: &[(usize, f64)],
3398    ) -> f64 {
3399        let mut overlap = 0.0;
3400        for &(left_axis, left_coeff) in combo_left {
3401            for &(right_axis, right_coeff) in combo_right {
3402                if left_axis == right_axis {
3403                    overlap += left_coeff * right_coeff * self.axis_components[[idx, left_axis]];
3404                }
3405            }
3406        }
3407        overlap
3408    }
3409
3410    /// One first-order kernel-derivative scalar under the chart:
3411    /// `scale · (q·s + g·φ)` with `g = c + L_a` (gam#979).
3412    /// A negative `s` is the unambiguous exact-collision marker installed by
3413    /// `eval_per_axis_psi_carriers`; there `q` is already the algebraic
3414    /// remainder after removing `c·φ`, because no geometric carrier exists.
3415    #[inline]
3416    pub(crate) fn first_kernel_value(scale: f64, phi: f64, q: f64, s: f64, g: f64) -> f64 {
3417        if s == ALGEBRAIC_PER_AXIS_COMPONENT {
3418            scale * (q + g * phi)
3419        } else {
3420            scale * (q * s + g * phi)
3421        }
3422    }
3423
3424    /// One second-order kernel-derivative scalar under the chart:
3425    /// `scale · (t s_a s_b + 2 q·overlap + q (g_b s_a + g_a s_b) + (g_a g_b + Λ_ab) φ)`
3426    /// — the raw chain rule with the chart's first jets folded into the
3427    /// effective shares and its second jet as the extra `Λ_ab φ` term
3428    /// (gam#979). `overlap` is `s_a` on the diagonal of raw axes and the
3429    /// combination overlap otherwise.
3430    #[inline]
3431    pub(crate) fn second_kernel_value(
3432        scale: f64,
3433        phi: f64,
3434        q: f64,
3435        t: f64,
3436        s_a: f64,
3437        s_b: f64,
3438        overlap: f64,
3439        g_a: f64,
3440        g_b: f64,
3441        lam: f64,
3442    ) -> f64 {
3443        if s_a == ALGEBRAIC_PER_AXIS_COMPONENT && s_b == ALGEBRAIC_PER_AXIS_COMPONENT {
3444            scale * (t + (g_a + g_b) * q + (g_a * g_b + lam) * phi)
3445        } else {
3446            scale
3447                * (t * s_a * s_b
3448                    + 2.0 * q * overlap
3449                    + q * (g_b * s_a + g_a * s_b)
3450                    + (g_a * g_b + lam) * phi)
3451        }
3452    }
3453}
3454
3455/// The kernel chart a design ψ-derivative builder must differentiate under
3456/// (gam#979): the amplitude `scale` the forward basis multiplies into the
3457/// kernel block, and the center pair whose kernel magnitude defines it.
3458/// `scale == 1.0` is the identity chart (Matérn, thin-plate, sphere, and any
3459/// Duchon block whose kernel did not underflow).
3460#[derive(Clone, Copy, Debug)]
3461pub struct DesignKernelChart {
3462    pub scale: f64,
3463    pub reference_pair: Option<(usize, usize)>,
3464}
3465
3466impl DesignKernelChart {
3467    pub const IDENTITY: Self = Self {
3468        scale: 1.0,
3469        reference_pair: None,
3470    };
3471}
3472
3473/// The chart's ψ-jets in the operator's own coordinates: `∂ ln scale/∂ψ_a`
3474/// per raw axis and `∂² ln scale/∂ψ_a∂ψ_b` per raw axis pair.
3475#[derive(Clone, Debug)]
3476pub(crate) struct DesignChartJets {
3477    pub(crate) scale: f64,
3478    pub(crate) first: Vec<f64>,
3479    pub(crate) second: Array2<f64>,
3480}
3481
3482/// Form the chart's ψ-jets from the reference pair's radial jets, with the
3483/// SAME kernel-value rule the operator applies to every pair. With
3484/// `M = |K(r*)|` and `scale = 1/M`:
3485///
3486/// ```text
3487///   ∂ ln scale/∂ψ_a          = −K_a / K
3488///   ∂² ln scale/∂ψ_a∂ψ_b     = −K_ab / K + (K_a / K)(K_b / K)
3489/// ```
3490///
3491/// where `K_a`, `K_ab` are the operator's own first/second kernel values at
3492/// the reference pair under the raw share `c`. Homogeneous kernels use the
3493/// geometric component `r²`; low-dimensional partial-fraction Duchon uses its
3494/// direct scalar ψ carrier instead, because the finite-part representative can
3495/// have non-scaling ψ derivatives even at a center collision. Thus the chart
3496/// and every data/center pair consume the same derivative authority.
3497pub(crate) fn design_chart_jets(
3498    chart: DesignKernelChart,
3499    centers: ArrayView2<'_, f64>,
3500    eta: Option<&[f64]>,
3501    radial_kind: &RadialScalarKind,
3502    per_axis: bool,
3503    share_c: f64,
3504) -> Result<Option<DesignChartJets>, BasisError> {
3505    if chart.scale == 1.0 {
3506        return Ok(None);
3507    }
3508    let Some((i, j)) = chart.reference_pair else {
3509        return Err(BasisError::InvalidInput(format!(
3510            "design kernel chart is amplified (scale={}) but names no reference center pair",
3511            chart.scale
3512        )));
3513    };
3514    let dim = centers.ncols();
3515    let metric =
3516        centered_aniso_metric_weights(&eta.map(<[f64]>::to_vec).unwrap_or_else(|| vec![0.0; dim]));
3517    let mut components = vec![0.0_f64; dim];
3518    for a in 0..dim {
3519        let h = centers[[i, a]] - centers[[j, a]];
3520        components[a] = metric[a] * h * h;
3521    }
3522    let r2: f64 = components.iter().sum();
3523    let r = r2.sqrt();
3524    let (phi, q, t, scalar_component) = if per_axis {
3525        let (phi, q, t) = radial_kind.eval_design_triplet(r)?;
3526        (phi, q, t, r2)
3527    } else {
3528        radial_kind.eval_scalar_total_psi_carriers(r)?
3529    };
3530    if !(phi.is_finite() && phi != 0.0) {
3531        return Err(BasisError::InvalidInput(format!(
3532            "design kernel chart reference pair ({i}, {j}) at r={r:.6e} has kernel value {phi:e}; \
3533             the chart's log-derivative is undefined there"
3534        )));
3535    }
3536    let s_axes: Vec<f64> = if per_axis {
3537        components
3538    } else {
3539        vec![scalar_component]
3540    };
3541    let n_axes = s_axes.len();
3542    let (log_value, log_radial) = if per_axis {
3543        DuchonLogarithmicPsiCorrection::coefficients(radial_kind)
3544            .map(|coefficients| DuchonLogarithmicPsiCorrection::evaluate(&coefficients, r2))
3545            .unwrap_or((0.0, 0.0))
3546    } else {
3547        (0.0, 0.0)
3548    };
3549    let mut first = vec![0.0_f64; n_axes];
3550    for (a, &s_a) in s_axes.iter().enumerate() {
3551        let k_a = ImplicitDesignPsiDerivative::first_kernel_value(1.0, phi, q, s_a, share_c)
3552            + log_value / dim as f64;
3553        first[a] = -k_a / phi;
3554    }
3555    let mut second = Array2::<f64>::zeros((n_axes, n_axes));
3556    for (a, &s_a) in s_axes.iter().enumerate() {
3557        for (b, &s_b) in s_axes.iter().enumerate() {
3558            let overlap = if a == b { s_a } else { 0.0 };
3559            let k_ab = ImplicitDesignPsiDerivative::second_kernel_value(
3560                1.0, phi, q, t, s_a, s_b, overlap, share_c, share_c, 0.0,
3561            ) + (log_radial * (s_a + s_b) + 2.0 * share_c * log_value) / dim as f64;
3562            second[[a, b]] = -k_ab / phi + first[a] * first[b];
3563        }
3564    }
3565    Ok(Some(DesignChartJets {
3566        scale: chart.scale,
3567        first,
3568        second,
3569    }))
3570}
3571
3572fn install_design_chart(
3573    op: ImplicitDesignPsiDerivative,
3574    jets: &Option<DesignChartJets>,
3575) -> ImplicitDesignPsiDerivative {
3576    match jets {
3577        Some(jets) => op.with_kernel_chart(jets.scale, jets.first.clone(), jets.second.clone()),
3578        None => op,
3579    }
3580}
3581
3582pub(crate) fn build_aniso_design_psi_derivatives_shared(
3583    data: ArrayView2<'_, f64>,
3584    centers: ArrayView2<'_, f64>,
3585    eta: &[f64],
3586    p_final: usize,
3587    ident_transform: Option<Array2<f64>>,
3588    full_ident_transform: Option<Array2<f64>>,
3589    n_poly: usize,
3590    radial_kind: RadialScalarKind,
3591    chart: DesignKernelChart,
3592) -> Result<AnisoBasisPsiDerivatives, BasisError> {
3593    let n = data.nrows();
3594    let k = centers.nrows();
3595    let dim = data.ncols();
3596    if eta.len() != dim {
3597        crate::bail_dim_basis!(
3598            "aniso design derivatives: eta.len()={} != data dimension {dim}",
3599            eta.len()
3600        );
3601    }
3602    let chart_jets = design_chart_jets(
3603        chart,
3604        centers,
3605        Some(eta),
3606        &radial_kind,
3607        true,
3608        radial_kind.raw_psi_isotropic_share(),
3609    )?;
3610
3611    let logarithmic_correction =
3612        DuchonLogarithmicPsiCorrection::new(data, centers, eta, &radial_kind);
3613    let policy = gam_runtime::resource::ResourcePolicy::default_library();
3614    let force_operator = radial_kind.is_duchon_family();
3615    let dense_derivatives_exceed_budget =
3616        should_use_implicit_operators_with_policy(n, p_final, dim, &policy);
3617    let operator_only = force_operator || dense_derivatives_exceed_budget;
3618    let cache_radial_components = should_cache_implicit_radial_components(n, k, dim, &policy);
3619    // gam#1376 — the per-axis ψ derivatives this operator produces are ALREADY
3620    // the derivatives w.r.t. the κ-optimizer's raw coordinate, so NO cross-axis
3621    // centering projection is installed (for any family). The optimizer's per-
3622    // axis coordinate `psi_a` is decoded into both the global length scale
3623    // `ℓ = exp(−mean(psi))` and the centered contrast `eta_a = psi_a − mean(psi)`
3624    // simultaneously; in the kernel argument `x² = r²/ℓ² = Σ_a exp(2·psi_a)·h_a²`
3625    // the `mean(psi)` cancels, so the effective per-axis exponent is the raw
3626    // `psi_a` and `∂φ/∂psi_a = q·s_a` is the native per-axis ψ derivative. The
3627    // earlier `with_raw_eta_centering` projection annihilated the all-ones
3628    // (global-scale) direction and broke the analytic↔FD match (rel≈0.85). The
3629    // dense path (`build_matern_basis_log_kappa_aniso_derivatives`) is corrected
3630    // identically — it no longer centers downstream.
3631
3632    // ── Streaming path: large scale ─────────────────────────────────────
3633    // When even the compact radial cache would exceed the operator-cache
3634    // budget, store only data/centers/eta/radial_kind and recompute
3635    // (q, t, s_a) chunkwise during each matvec. Otherwise the operator-only
3636    // path below caches phi/q/t/s_a without materializing dense derivative
3637    // matrices.
3638    if operator_only && !cache_radial_components {
3639        let op = ImplicitDesignPsiDerivative::new_streaming(
3640            shared_owned_data_matrix_from_view(data),
3641            shared_owned_centers_matrix_from_view(centers),
3642            eta.to_vec(),
3643            radial_kind,
3644            ident_transform,
3645            full_ident_transform,
3646            n_poly,
3647        );
3648        let op = install_design_chart(op, &chart_jets)
3649            .with_logarithmic_correction(logarithmic_correction);
3650        return Ok(AnisoBasisPsiDerivatives {
3651            design_first: Vec::new(),
3652            design_second_diag: Vec::new(),
3653            design_second_cross: Vec::new(),
3654            design_second_cross_pairs: Vec::new(),
3655            penalties_first: vec![Vec::new(); dim],
3656            penalties_second_diag: vec![Vec::new(); dim],
3657            penalties_cross_pairs: Vec::new(),
3658            penalties_cross_provider: None,
3659            implicit_operator: Some(op),
3660        });
3661    }
3662
3663    // ── Materialized radial-cache path ────────────────────────────────────
3664    // Allocate O(n*k) arrays up front and fill with parallel chunks that
3665    // write directly into preallocated storage via raw pointers. No
3666    // intermediate Vec<(i, q_row, t_row, s_row)> collection.
3667    let nk = n.checked_mul(k).ok_or_else(|| {
3668        BasisError::InvalidInput("aniso radial cache has too many data-center pairs".to_string())
3669    })?;
3670    if nk.checked_mul(dim).is_none() {
3671        crate::bail_invalid_basis!("aniso radial cache axis component storage is too large");
3672    }
3673    let mut phi_values = Array1::<f64>::zeros(nk);
3674    let mut q_values = Array1::<f64>::zeros(nk);
3675    let mut t_values = Array1::<f64>::zeros(nk);
3676    let mut axis_components = Array2::<f64>::zeros((nk, dim));
3677
3678    let psi_scale_share = radial_kind.raw_psi_isotropic_share();
3679
3680    let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
3681    let nc = n.div_ceil(cs);
3682    // Capture the *first* underlying radial-evaluation error rather than a
3683    // bare boolean: at an extreme trial hyperparameter the anisotropic
3684    // distance `r` can push the Duchon/Matérn radial kernel out of its
3685    // evaluable range, and the caller (the spatial-κ optimizer) needs the
3686    // real cause to decide whether the trial point is merely infeasible
3687    // (retreat) versus a genuine invariant violation (abort). Swallowing it
3688    // as "radial scalar evaluation failed" hid both the cause and the
3689    // recoverability.
3690    let first_err: std::sync::Mutex<Option<BasisError>> = std::sync::Mutex::new(None);
3691    // For large sweeps, replace per-pair exact radial evaluation with a
3692    // certified 1-D Chebyshev profile built once from a distance-only
3693    // pre-pass over the radius range (see `radial_profile`): at the 16-D
3694    // power-9 hybrid Duchon configuration a single exact triplet costs tens
3695    // of microseconds across its partial-fraction blocks, and this n·k
3696    // sweep was the dominant per-κ-trial cost of large-scale fits (#979).
3697    // Out-of-range radii and uncertified builds fall back to the exact
3698    // evaluator per pair.
3699    // One set of per-axis scales for the whole sweep (the per-pair form
3700    // recomputed `d` exponentials and allocated per pair).
3701    let aniso_scales = aniso_axis_scales(eta);
3702    let profile = if nk >= RADIAL_PROFILE_MIN_PAIRS {
3703        let mut r_lo = f64::INFINITY;
3704        let mut r_hi = 0.0_f64;
3705        let mut drb = vec![0.0; dim];
3706        let mut cb = vec![0.0; dim];
3707        for i in 0..n {
3708            for a in 0..dim {
3709                drb[a] = data[[i, a]];
3710            }
3711            for j in 0..k {
3712                for a in 0..dim {
3713                    cb[a] = centers[[j, a]];
3714                }
3715                let r = aniso_distance_with_scales(&drb, &cb, &aniso_scales);
3716                if r > 0.0 {
3717                    r_lo = r_lo.min(r);
3718                    r_hi = r_hi.max(r);
3719                }
3720            }
3721        }
3722        if r_lo.is_finite() && r_hi > r_lo {
3723            radial_profile::RadialProfile::build(&radial_kind, r_lo, r_hi)
3724        } else {
3725            None
3726        }
3727    } else {
3728        None
3729    };
3730    {
3731        let pp = SendPtr(phi_values.as_mut_ptr());
3732        let qp = SendPtr(q_values.as_mut_ptr());
3733        let tp = SendPtr(t_values.as_mut_ptr());
3734        let ap = SendPtr(axis_components.as_mut_ptr());
3735        let ferr = &first_err;
3736        let profile_ref = profile.as_ref();
3737        let aniso_scales_ref = &aniso_scales;
3738        (0..nc).into_par_iter().for_each(move |ci| {
3739            let start = ci * cs;
3740            let end = start.saturating_add(cs).min(n);
3741            let mut drb = vec![0.0; dim];
3742            let mut cb = vec![0.0; dim];
3743            let mut sv = vec![0.0; dim];
3744            for i in start..end {
3745                for a in 0..dim {
3746                    drb[a] = data[[i, a]];
3747                }
3748                for j in 0..k {
3749                    for a in 0..dim {
3750                        cb[a] = centers[[j, a]];
3751                    }
3752                    let r = aniso_distance_and_components_with_scales(
3753                        &drb,
3754                        &cb,
3755                        aniso_scales_ref,
3756                        &mut sv,
3757                    );
3758                    let collision = if r == 0.0 {
3759                        Some(radial_kind.eval_per_axis_psi_carriers(r))
3760                    } else {
3761                        None
3762                    };
3763                    let triplet = match collision {
3764                        Some(result) => result,
3765                        None => match profile_ref {
3766                            Some(profile) => profile
3767                                .eval_or_exact(&radial_kind, r)
3768                                .map(|(phi, q, t)| (phi, q, t, false)),
3769                            None => radial_kind
3770                                .eval_design_triplet(r)
3771                                .map(|(phi, q, t)| (phi, q, t, false)),
3772                        },
3773                    };
3774                    let (phi, q, t, marked) = match triplet {
3775                        Ok(p) => p,
3776                        Err(e) => {
3777                            let mut slot = ferr.lock().unwrap_or_else(|p| p.into_inner());
3778                            if slot.is_none() {
3779                                *slot = Some(e);
3780                            }
3781                            return;
3782                        }
3783                    };
3784                    if marked {
3785                        sv.fill(ALGEBRAIC_PER_AXIS_COMPONENT);
3786                    }
3787                    let flat = i * k + j;
3788                    // SAFETY: each Rayon chunk owns a disjoint i-row range,
3789                    // so flat=i*k+j stays in 0..nk for phi/q/t and
3790                    // flat*dim+a stays in 0..nk*dim for axis_components.
3791                    unsafe {
3792                        *pp.add(flat) = phi;
3793                        *qp.add(flat) = q;
3794                        *tp.add(flat) = t;
3795                        for a in 0..dim {
3796                            *ap.add(flat * dim + a) = sv[a];
3797                        }
3798                    }
3799                }
3800            }
3801        });
3802    }
3803    if let Some(cause) = first_err.into_inner().unwrap_or_else(|p| p.into_inner()) {
3804        return Err(BasisError::InvalidInput(format!(
3805            "radial scalar evaluation failed during aniso derivative construction \
3806             (eta={eta:?}): {cause}"
3807        )));
3808    }
3809
3810    let op = ImplicitDesignPsiDerivative::new(
3811        phi_values,
3812        q_values,
3813        t_values,
3814        axis_components,
3815        ident_transform,
3816        full_ident_transform,
3817        n,
3818        k,
3819        n_poly,
3820        dim,
3821    )
3822    .with_psi_scale_share(psi_scale_share)
3823    .with_logarithmic_correction(logarithmic_correction);
3824    let op = install_design_chart(op, &chart_jets);
3825
3826    // gam#1376 — the operator stays in the NATIVE per-axis ψ frame (no
3827    // `with_raw_eta_centering`): the κ-optimizer coordinate `psi_a` already maps
3828    // to the effective per-axis exponent `psi_a` of the kernel argument (the
3829    // `mean(psi)` it injects into the centered contrast is exactly cancelled by
3830    // the `ℓ = exp(−mean(psi))` it injects into the length scale), so the native
3831    // `∂φ/∂psi_a` produced by `materialize_first`/`materialize_second_*` (and by
3832    // the operator matvecs) is the correct raw-coordinate derivative. The
3833    // earlier centering broke the analytic↔FD match — see the comment above.
3834
3835    if operator_only {
3836        return Ok(AnisoBasisPsiDerivatives {
3837            design_first: Vec::new(),
3838            design_second_diag: Vec::new(),
3839            design_second_cross: Vec::new(),
3840            design_second_cross_pairs: Vec::new(),
3841            penalties_first: vec![Vec::new(); dim],
3842            penalties_second_diag: vec![Vec::new(); dim],
3843            penalties_cross_pairs: Vec::new(),
3844            penalties_cross_provider: None,
3845            implicit_operator: Some(op),
3846        });
3847    }
3848
3849    let design_first = (0..dim)
3850        .map(|a| op.materialize_first(a))
3851        .collect::<Result<Vec<_>, _>>()?;
3852    let design_second_diag = (0..dim)
3853        .map(|a| op.materialize_second_diag(a))
3854        .collect::<Result<Vec<_>, _>>()?;
3855
3856    Ok(AnisoBasisPsiDerivatives {
3857        design_first,
3858        design_second_diag,
3859        design_second_cross: Vec::new(),
3860        design_second_cross_pairs: Vec::new(),
3861        penalties_first: vec![Vec::new(); dim],
3862        penalties_second_diag: vec![Vec::new(); dim],
3863        penalties_cross_pairs: Vec::new(),
3864        penalties_cross_provider: None,
3865        implicit_operator: Some(op),
3866    })
3867}
3868
3869#[derive(Debug, Clone)]
3870pub(crate) struct ScalarDesignPsiDerivatives {
3871    pub(crate) design_first: Array2<f64>,
3872    pub(crate) design_second_diag: Array2<f64>,
3873    pub(crate) implicit_operator: Option<ImplicitDesignPsiDerivative>,
3874}
3875
3876pub(crate) fn build_scalar_design_psi_derivatives_shared(
3877    data: ArrayView2<'_, f64>,
3878    centers: ArrayView2<'_, f64>,
3879    fixed_eta: Option<&[f64]>,
3880    p_final: usize,
3881    ident_transform: Option<Array2<f64>>,
3882    full_ident_transform: Option<Array2<f64>>,
3883    n_poly: usize,
3884    radial_kind: RadialScalarKind,
3885    psi_scale_share: f64,
3886    chart: DesignKernelChart,
3887) -> Result<ScalarDesignPsiDerivatives, BasisError> {
3888    let n = data.nrows();
3889    let k = centers.nrows();
3890    let dim = data.ncols();
3891    if let Some(eta) = fixed_eta
3892        && eta.len() != dim
3893    {
3894        crate::bail_dim_basis!(
3895            "scalar design derivatives: eta.len()={} != data dimension {dim}",
3896            eta.len()
3897        );
3898    }
3899    let chart_jets = design_chart_jets(
3900        chart,
3901        centers,
3902        fixed_eta,
3903        &radial_kind,
3904        false,
3905        psi_scale_share,
3906    )?;
3907
3908    let policy = gam_runtime::resource::ResourcePolicy::default_library();
3909    let force_operator = radial_kind.is_duchon_family();
3910    let dense_derivatives_exceed_budget =
3911        should_use_implicit_operators_with_policy(n, p_final, 1, &policy);
3912    let operator_only = force_operator || dense_derivatives_exceed_budget;
3913    let cache_radial_components = should_cache_implicit_radial_components(n, k, 1, &policy);
3914    if operator_only && !cache_radial_components {
3915        let metric_eta = fixed_eta
3916            .map(|eta| eta.to_vec())
3917            .unwrap_or_else(|| vec![0.0; dim]);
3918        let op = ImplicitDesignPsiDerivative::new_streaming_scalar(
3919            shared_owned_data_matrix_from_view(data),
3920            shared_owned_centers_matrix_from_view(centers),
3921            metric_eta,
3922            radial_kind,
3923            ident_transform,
3924            full_ident_transform,
3925            n_poly,
3926        )
3927        .with_psi_scale_share(psi_scale_share);
3928        let op = install_design_chart(op, &chart_jets);
3929        return Ok(ScalarDesignPsiDerivatives {
3930            design_first: Array2::<f64>::zeros((0, 0)),
3931            design_second_diag: Array2::<f64>::zeros((0, 0)),
3932            implicit_operator: Some(op),
3933        });
3934    }
3935
3936    let nk = n.checked_mul(k).ok_or_else(|| {
3937        BasisError::InvalidInput("scalar radial cache has too many data-center pairs".to_string())
3938    })?;
3939    let mut phi_values = Array1::<f64>::zeros(nk);
3940    let mut q_values = Array1::<f64>::zeros(nk);
3941    let mut t_values = Array1::<f64>::zeros(nk);
3942    let mut axis_components = Array2::<f64>::zeros((nk, 1));
3943
3944    let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
3945    let nc = n.div_ceil(cs);
3946    let first_err: std::sync::Mutex<Option<BasisError>> = std::sync::Mutex::new(None);
3947    // Same certified radial-profile amortization as the per-axis sweep
3948    // above: one distance-only pre-pass for the radius range, one profile
3949    // build, Clenshaw per pair, exact fallback out of range (#979).
3950    let fixed_scales: Option<Vec<f64>> = fixed_eta.map(aniso_axis_scales);
3951    let pair_r = |i: usize, j: usize, drb: &mut [f64], cb: &mut [f64]| -> f64 {
3952        if let Some(scales) = fixed_scales.as_deref() {
3953            for a in 0..dim {
3954                drb[a] = data[[i, a]];
3955                cb[a] = centers[[j, a]];
3956            }
3957            aniso_distance_with_scales(drb, cb, scales)
3958        } else {
3959            stable_euclidean_norm((0..dim).map(|a| data[[i, a]] - centers[[j, a]]))
3960        }
3961    };
3962    let profile = if nk >= RADIAL_PROFILE_MIN_PAIRS {
3963        let mut r_lo = f64::INFINITY;
3964        let mut r_hi = 0.0_f64;
3965        let mut drb = vec![0.0; dim];
3966        let mut cb = vec![0.0; dim];
3967        for i in 0..n {
3968            for j in 0..k {
3969                let r = pair_r(i, j, &mut drb, &mut cb);
3970                if r > 0.0 {
3971                    r_lo = r_lo.min(r);
3972                    r_hi = r_hi.max(r);
3973                }
3974            }
3975        }
3976        if r_lo.is_finite() && r_hi > r_lo {
3977            radial_profile::RadialProfile::build(&radial_kind, r_lo, r_hi)
3978        } else {
3979            None
3980        }
3981    } else {
3982        None
3983    };
3984    {
3985        let pp = SendPtr(phi_values.as_mut_ptr());
3986        let qp = SendPtr(q_values.as_mut_ptr());
3987        let tp = SendPtr(t_values.as_mut_ptr());
3988        let ap = SendPtr(axis_components.as_mut_ptr());
3989        let ferr = &first_err;
3990        let profile_ref = profile.as_ref();
3991        let exact_scalar_carrier = matches!(
3992            radial_kind,
3993            RadialScalarKind::Duchon {
3994                p_order,
3995                s_order,
3996                dim,
3997                ..
3998            } if !duchon_hybrid_stable_integral_applies(p_order, s_order, dim)
3999        );
4000        (0..nc).into_par_iter().for_each(move |ci| {
4001            let start = ci * cs;
4002            let end = start.saturating_add(cs).min(n);
4003            let mut data_row_buf = vec![0.0; dim];
4004            let mut center_buf = vec![0.0; dim];
4005            let mut component_buf = vec![0.0; dim];
4006            for i in start..end {
4007                for a in 0..dim {
4008                    data_row_buf[a] = data[[i, a]];
4009                }
4010                for j in 0..k {
4011                    let (r, scalar_component) = if let Some(scales) = fixed_scales.as_deref() {
4012                        for a in 0..dim {
4013                            center_buf[a] = centers[[j, a]];
4014                        }
4015                        let r = aniso_distance_and_components_with_scales(
4016                            &data_row_buf,
4017                            &center_buf,
4018                            scales,
4019                            &mut component_buf,
4020                        );
4021                        (r, component_buf.iter().sum::<f64>())
4022                    } else {
4023                        let r =
4024                            stable_euclidean_norm((0..dim).map(|a| data[[i, a]] - centers[[j, a]]));
4025                        (r, r * r)
4026                    };
4027                    let carrier_triplet = if exact_scalar_carrier {
4028                        radial_kind.eval_scalar_total_psi_carriers(r)
4029                    } else {
4030                        let triplet = match profile_ref {
4031                            Some(profile) => profile.eval_or_exact(&radial_kind, r),
4032                            None => radial_kind.eval_design_triplet(r),
4033                        };
4034                        triplet.map(|(phi, q, t)| (phi, q, t, scalar_component))
4035                    };
4036                    let (phi, q, t, carrier_component) = match carrier_triplet {
4037                        Ok(p) => p,
4038                        Err(e) => {
4039                            let mut slot = ferr.lock().unwrap_or_else(|p| p.into_inner());
4040                            if slot.is_none() {
4041                                *slot = Some(e);
4042                            }
4043                            return;
4044                        }
4045                    };
4046                    let flat = i * k + j;
4047                    // SAFETY: each Rayon chunk owns a disjoint i-row range
4048                    // of the nk-long phi/q/t/axis buffers, so flat=i*k+j is
4049                    // in-bounds for every write and never aliases another worker.
4050                    unsafe {
4051                        *pp.add(flat) = phi;
4052                        *qp.add(flat) = q;
4053                        *tp.add(flat) = t;
4054                        *ap.add(flat) = carrier_component;
4055                    }
4056                }
4057            }
4058        });
4059    }
4060    if let Some(cause) = first_err.into_inner().unwrap_or_else(|p| p.into_inner()) {
4061        return Err(BasisError::InvalidInput(format!(
4062            "radial scalar evaluation failed during scalar derivative construction: {cause}"
4063        )));
4064    }
4065
4066    let op = ImplicitDesignPsiDerivative::new(
4067        phi_values,
4068        q_values,
4069        t_values,
4070        axis_components,
4071        ident_transform,
4072        full_ident_transform,
4073        n,
4074        k,
4075        n_poly,
4076        1,
4077    )
4078    .with_psi_scale_share(psi_scale_share);
4079    let op = install_design_chart(op, &chart_jets);
4080
4081    if operator_only {
4082        return Ok(ScalarDesignPsiDerivatives {
4083            design_first: Array2::<f64>::zeros((0, 0)),
4084            design_second_diag: Array2::<f64>::zeros((0, 0)),
4085            implicit_operator: Some(op),
4086        });
4087    }
4088
4089    Ok(ScalarDesignPsiDerivatives {
4090        design_first: op.materialize_first(0)?,
4091        design_second_diag: op.materialize_second_diag(0)?,
4092        implicit_operator: Some(op),
4093    })
4094}
4095
4096#[cfg(test)]
4097mod fixed_row_space_value_tests {
4098    use super::*;
4099
4100    fn frobenius(matrix: &Array2<f64>) -> f64 {
4101        matrix.iter().map(|value| value * value).sum::<f64>().sqrt()
4102    }
4103
4104    #[test]
4105    fn projected_value_design_exports_raw_constraint_correction() {
4106        // The third column is a rescaled duplicate of the first. This pins the
4107        // rank-deficient/scaled case: the exported correction need not be the
4108        // minimum-norm raw coefficient vector, but C*R must be exactly the
4109        // projector's removed row-space component.
4110        let constraint = Array2::from_shape_vec(
4111            (5, 3),
4112            vec![
4113                1.0, -2.0, 7.0, 1.0, -1.0, 7.0, 1.0, 0.0, 7.0, 1.0, 1.0, 7.0, 1.0, 2.0, 7.0,
4114            ],
4115        )
4116        .expect("constraint shape");
4117        let value = Array2::from_shape_vec(
4118            (5, 2),
4119            vec![0.3, -1.0, 2.0, 0.5, -0.7, 3.0, 1.4, -0.2, 4.0, 1.1],
4120        )
4121        .expect("value shape");
4122        let projector =
4123            FixedRowSpaceProjector::from_constraint_block(constraint.view()).expect("projector");
4124        assert_eq!(projector.rank(), 2);
4125
4126        let mut expected = value.clone();
4127        projector
4128            .project_matrix_in_place(&mut expected)
4129            .expect("dense projection");
4130        let (lazy, correction) = projector
4131            .project_design(DesignMatrix::from(value.clone()), "unit value")
4132            .expect("lazy projection");
4133        let actual = lazy.to_dense();
4134        let reconstructed = &value - &constraint.dot(&correction);
4135        let scale = frobenius(&expected).max(1.0);
4136        assert!(
4137            frobenius(&(&actual - &expected)) / scale < 1.0e-12,
4138            "lazy value projection must equal the dense projector"
4139        );
4140        assert!(
4141            frobenius(&(&reconstructed - &expected)) / scale < 1.0e-12,
4142            "raw constraint correction must replay the same projected value"
4143        );
4144        assert!(
4145            frobenius(&constraint.t().dot(&actual))
4146                / (frobenius(&constraint) * frobenius(&actual)).max(1.0e-300)
4147                < 1.0e-12,
4148            "projected value must be collection-orthogonal"
4149        );
4150    }
4151}