Skip to main content

gam_terms/basis/
implicit_psi_derivative.rs

1use super::*;
2
3/// Implicit representation of ∂X/∂ψ_d that supports matrix-vector products
4/// without materializing the full (n x p) derivative matrices.
5///
6/// For anisotropic Matern / Duchon terms with D axes, the dense path creates
7/// D matrices of size (n x p_smooth) for dX/dpsi_d. At n=400K, p=2000, D=16,
8/// that is ~100 GB.
9///
10/// Two storage modes:
11///
12/// **Materialized** (small-to-medium problems): stores pre-computed arrays
13/// - `phi_values[i*n_knots + j]` = phi(r_{ij})
14/// - `q_values[i*n_knots + j]` = phi'(r_{ij}) / r_{ij}
15/// - `t_values[i*n_knots + j]` = (phi''(r_{ij}) - q_{ij}) / r_{ij}^2
16/// - `axis_components[i*n_knots + j, d]` = exp(2 eta_d) * (x_{id} - c_{jd})^2
17/// Memory: O(n * k * (D + 2)).
18///
19/// **Streaming** (large scale): stores only data/centers/eta/kernel params
20/// and recomputes (q, t, s_a) on the fly during each matvec.
21/// Memory: O(n*d + k*d) -- no per-(data,knot) storage.
22///
23/// The raw-psi chain rule:
24///   shape_a   = q * s_a
25///   shape_ab  = t * s_a * s_b + 2 q s_a 1[a=b]
26///   dphi/dpsi_a         = shape_a + c * phi
27///   d2phi/(dpsi_a dpsi_b) = shape_ab + c (shape_a + shape_b) + c^2 phi
28/// where `c = 0` for Matérn and `c = delta / d` for hybrid Duchon.
29#[derive(Debug, Clone)]
30pub struct ImplicitDesignPsiDerivative {
31    /// Pre-computed kernel values (materialized mode).
32    /// Shape: (n * n_knots,). Empty in streaming mode.
33    pub(crate) phi_values: Array1<f64>,
34
35    /// Pre-computed per (data, knot) pair axis components (materialized mode).
36    /// Shape: (n * n_knots, D) stored in row-major order.
37    /// Empty (0x0) in streaming mode.
38    pub(crate) axis_components: Array2<f64>,
39
40    /// Pre-computed R-operator first scalar (materialized mode).
41    /// Shape: (n * n_knots,). Empty in streaming mode.
42    pub(crate) q_values: Array1<f64>,
43
44    /// Pre-computed R-operator second scalar (materialized mode).
45    /// Shape: (n * n_knots,). Empty in streaming mode.
46    pub(crate) t_values: Array1<f64>,
47
48    /// When set, enables streaming recomputation of q/t/s from raw inputs
49    /// instead of reading from the pre-computed arrays above.
50    pub(crate) streaming: Option<StreamingRadialState>,
51
52    /// Identifiability/constraint transform Z: (n_knots x p_constrained).
53    /// Gauge ownership is upstream; the implicit operator stores this frozen
54    /// section only so forward/transpose matvecs can apply the already-gauged
55    /// chart without materializing derivative matrices. For Duchon this is the
56    /// kernel-constraint nullspace Z_kernel; for Matern with identifiability
57    /// constraints, it is the corresponding Z. `None` means the identity.
58    pub(crate) ident_transform: Option<Array2<f64>>,
59
60    /// Optional full identifiability transform applied after Z_kernel + padding.
61    /// This is likewise replay/application metadata for the matrix-free
62    /// operator, not a second coefficient-coordinate owner. For Duchon terms
63    /// that have an additional global identifiability transform, this is applied
64    /// after the kernel constraint and polynomial padding.
65    /// Shape: (p_constrained + n_poly, p_final).
66    pub(crate) full_ident_transform: Option<Array2<f64>>,
67
68    /// Number of data points.
69    pub(crate) n: usize,
70
71    /// Number of knots (raw basis functions before identifiability transform).
72    pub(crate) n_knots: usize,
73
74    /// Number of polynomial columns appended after the smooth part.
75    /// These have zero derivative with respect to psi_d.
76    pub(crate) n_poly: usize,
77
78    /// Number of axes (dimension D).
79    pub(crate) n_axes: usize,
80
81    /// Isotropic scaling contribution per raw anisotropic psi axis.
82    pub(crate) psi_scale_share: f64,
83
84    /// Optional exposed-axis to raw-axis linear combinations.
85    /// When present, axis `a` represents Σ_i coeff_i * raw_axis_i.
86    pub(crate) axis_combinations: Option<Vec<Vec<(usize, f64)>>>,
87}
88
89/// Streaming design derivative for one per-row latent coordinate `t[n, a]`.
90///
91/// The operator stores the shared latent matrix plus either radial-kernel
92/// ingredients or a precomputed non-radial derivative jet. Individual REML
93/// hyper-directions carry only a flat coordinate index and call
94/// `forward_mul_axis` / `transpose_mul_axis` to expose the corresponding
95/// one-row design derivative on demand.
96pub struct LatentCoordDesignDerivative {
97    pub(crate) provider: Arc<dyn LocalDesignJacobianProvider>,
98}
99
100#[derive(Debug, Clone)]
101pub(crate) struct RadialLatentCoordLocalDesignJacobian {
102    pub(crate) latent: Arc<crate::latent::LatentCoordValues>,
103    /// Kernel centers in the STANDARDIZED frame, as `BasisMetadata` stores them.
104    pub(crate) centers: Arc<Array2<f64>>,
105    /// The frame `centers` and `radial_kind` live in, relative to the RAW
106    /// latent coordinates the optimizer moves (#2643).
107    ///
108    /// The realized design is `phi(||t/sigma - c||; ell/sigma)`, so a Jacobian
109    /// with respect to `t` must standardize `t` before forming radii AND carry
110    /// the `1/sigma` chain factor. Both were missing: the operator compared raw
111    /// `t` against standardized centers at an original-units range.
112    pub(crate) input_scale: crate::IsotropicScale,
113    pub(crate) radial_kind: RadialScalarKind,
114    pub(crate) ident_transform: Option<Array2<f64>>,
115    pub(crate) full_ident_transform: Option<Array2<f64>>,
116    pub(crate) n_poly: usize,
117    pub(crate) polynomial_order: Option<DuchonNullspaceOrder>,
118}
119
120#[derive(Debug, Clone)]
121pub(crate) struct JetLatentCoordLocalDesignJacobian {
122    pub(crate) latent: Arc<crate::latent::LatentCoordValues>,
123    pub(crate) jet: Arc<Array3<f64>>,
124    pub(crate) ident_transform: Option<Array2<f64>>,
125}
126
127impl std::fmt::Debug for LatentCoordDesignDerivative {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("LatentCoordDesignDerivative")
130            .field("n_data", &self.n_data())
131            .field("latent_dim", &self.latent_dim())
132            .field("n_axes", &self.n_axes())
133            .field("p_out", &self.p_out())
134            .field("provider", &self.provider)
135            .finish()
136    }
137}
138
139impl Clone for LatentCoordDesignDerivative {
140    fn clone(&self) -> Self {
141        Self {
142            provider: Arc::clone(&self.provider),
143        }
144    }
145}
146
147impl RadialLatentCoordLocalDesignJacobian {
148    pub(crate) fn p_constrained(&self) -> usize {
149        self.ident_transform
150            .as_ref()
151            .map_or(self.centers.nrows(), Array2::ncols)
152    }
153
154    pub(crate) fn p_after_pad(&self) -> usize {
155        self.p_constrained() + self.n_poly
156    }
157
158    pub(crate) fn p_out(&self) -> usize {
159        self.full_ident_transform
160            .as_ref()
161            .map_or(self.p_after_pad(), Array2::ncols)
162    }
163}
164
165impl JetLatentCoordLocalDesignJacobian {
166    pub(crate) fn p_out(&self) -> usize {
167        self.ident_transform
168            .as_ref()
169            .map_or(self.jet.shape()[1], Array2::ncols)
170    }
171}
172
173/// The complete contract a per-row latent / novel-manifold coordinate type must
174/// supply to participate in the REML design-derivative operator surface.
175///
176/// Onboarding a new coordinate type (the SAE / novel-manifold frontier) reduces
177/// to implementing the small set of *required* methods below — the coordinate
178/// geometry (`n_data`, `latent_dim`, `n_axes`) plus the single genuinely-new
179/// payload `local_design_jacobian_row` (the local block ∂(design row)/∂(coord)).
180/// The streaming operator surface consumed by `LatentCoordDerivativeOp` in
181/// `src/solver/reml/mod.rs` — forward matvec, transpose matvec, and dense
182/// materialization, together with the flat-axis → (row, axis) decode — is
183/// inherited as *default* methods and never re-implemented per coordinate type.
184///
185/// This is the close condition for #767: a new coordinate type touches zero
186/// operator-surface code; it provides only its local Jacobian and geometry.
187pub trait LocalDesignJacobianProvider: Send + Sync + std::fmt::Debug {
188    /// Number of data rows `n` the operator spans.
189    fn n_data(&self) -> usize;
190
191    /// Latent coordinate dimension `d` (perturbation axes per row).
192    fn latent_dim(&self) -> usize;
193
194    /// Number of flat hyper-axes `n · d` (one per (row, coordinate-axis) pair).
195    fn n_axes(&self) -> usize;
196
197    /// Number of output-basis columns in each local design-Jacobian row.
198    fn p_out(&self) -> usize;
199
200    /// The only per-coordinate payload: the projected local design-Jacobian row
201    /// ∂(design row `row`)/∂(coordinate axis `axis`) in output-basis columns.
202    fn local_design_jacobian_row(&self, row: usize, axis: usize)
203    -> Result<Array1<f64>, BasisError>;
204
205    /// Decode a flat hyper-axis into its `(row, coordinate axis)`. Row-major over
206    /// `(row, axis)` with stride `latent_dim`; uniform across coordinate types.
207    fn row_axis(&self, flat_axis: usize) -> (usize, usize) {
208        let d = self.latent_dim();
209        (flat_axis / d, flat_axis % d)
210    }
211
212    /// Forward matvec for one flat hyper-axis: place `J_row · u` at `row`.
213    fn forward_mul_axis(
214        &self,
215        flat_axis: usize,
216        u: &ArrayView1<'_, f64>,
217    ) -> Result<Array1<f64>, BasisError> {
218        assert!(
219            flat_axis < self.n_axes(),
220            "latent-coordinate derivative flat axis out of bounds in forward_mul_axis: flat_axis={flat_axis}, n_axes={}",
221            self.n_axes()
222        );
223        let (row, axis) = self.row_axis(flat_axis);
224        let local_jacobian = self.local_design_jacobian_row(row, axis)?;
225        assert_eq!(
226            u.len(),
227            local_jacobian.len(),
228            "latent-coordinate derivative coefficient length mismatch in forward_mul_axis"
229        );
230        let value = local_jacobian.dot(u);
231        let mut out = Array1::<f64>::zeros(self.n_data());
232        out[row] = value;
233        Ok(out)
234    }
235
236    /// Transpose matvec for one flat hyper-axis: scatter `v[row] · J_rowᵀ`.
237    fn transpose_mul_axis(
238        &self,
239        flat_axis: usize,
240        v: &ArrayView1<'_, f64>,
241    ) -> Result<Array1<f64>, BasisError> {
242        assert!(
243            flat_axis < self.n_axes(),
244            "latent-coordinate derivative flat axis out of bounds in transpose_mul_axis: flat_axis={flat_axis}, n_axes={}",
245            self.n_axes()
246        );
247        assert_eq!(
248            v.len(),
249            self.n_data(),
250            "latent-coordinate derivative row-adjoint length mismatch in transpose_mul_axis"
251        );
252        let (row, axis) = self.row_axis(flat_axis);
253        let scale = v[row];
254        Ok(self
255            .local_design_jacobian_row(row, axis)?
256            .mapv(|value| scale * value))
257    }
258
259    /// Dense `(n_data × p_out)` materialization of one flat hyper-axis: the local
260    /// Jacobian row placed at `row`, all other rows zero.
261    fn materialize_axis(&self, flat_axis: usize) -> Result<Array2<f64>, BasisError> {
262        assert!(
263            flat_axis < self.n_axes(),
264            "latent-coordinate derivative flat axis out of bounds in materialize_axis: flat_axis={flat_axis}, n_axes={}",
265            self.n_axes()
266        );
267        let (row, axis) = self.row_axis(flat_axis);
268        let projected = self.local_design_jacobian_row(row, axis)?;
269        let mut out = Array2::<f64>::zeros((self.n_data(), projected.len()));
270        out.row_mut(row).assign(&projected);
271        Ok(out)
272    }
273}
274
275/// The rayon chunk size for parallel implicit matvec operations.
276/// Each chunk processes this many data points before reducing.
277pub(crate) const IMPLICIT_MATVEC_CHUNK_SIZE: usize = 1000;
278
279/// Minimum data size to activate parallel iteration for implicit matvecs.
280pub(crate) const IMPLICIT_MATVEC_PAR_THRESHOLD: usize = 10_000;
281
282/// Number of lower-triangular center rows per tile when assembling dense
283/// ThinPlate penalty ψ-derivative kernel blocks.
284pub(crate) const THIN_PLATE_PENALTY_PSI_TILE_ROWS: usize = 32;
285
286impl LatentCoordDesignDerivative {
287    pub(crate) fn from_local_design_jacobian_provider(
288        provider: Arc<dyn LocalDesignJacobianProvider>,
289    ) -> Self {
290        Self { provider }
291    }
292
293    /// `input_scale` and `length_scale` are the metadata's own pair: `centers`
294    /// are standardized by `input_scale`, and `length_scale` is the range in
295    /// ORIGINAL units. Taking both, and doing the one conversion here, is what
296    /// stops a caller pairing a standardized center set with an unconverted
297    /// range (#2643); the frame tags make the pairing checkable (#2636).
298    pub fn new_matern(
299        latent: Arc<crate::latent::LatentCoordValues>,
300        centers: Arc<Array2<f64>>,
301        input_scale: crate::IsotropicScale,
302        length_scale: crate::OriginalUnits,
303        nu: MaternNu,
304        include_intercept: bool,
305        ident_transform: Option<Array2<f64>>,
306    ) -> Result<Self, BasisError> {
307        if latent.latent_dim() != centers.ncols() {
308            crate::bail_dim_basis!(
309                "LatentCoordDesignDerivative Matérn dimension mismatch: latent d={} centers d={}",
310                latent.latent_dim(),
311                centers.ncols()
312            );
313        }
314        let length_scale = input_scale
315            .to_standardized_units(length_scale)
316            .standardized_value();
317        Ok(Self::from_local_design_jacobian_provider(Arc::new(
318            RadialLatentCoordLocalDesignJacobian {
319                latent,
320                centers,
321                input_scale,
322                radial_kind: RadialScalarKind::Matern { length_scale, nu },
323                ident_transform,
324                full_ident_transform: None,
325                n_poly: usize::from(include_intercept),
326                polynomial_order: None,
327            },
328        )))
329    }
330
331    /// See [`Self::new_matern`] for why this takes the metadata's frame pair
332    /// rather than a bare range.
333    pub fn new_duchon(
334        latent: Arc<crate::latent::LatentCoordValues>,
335        centers: Arc<Array2<f64>>,
336        input_scale: crate::IsotropicScale,
337        length_scale: Option<crate::OriginalUnits>,
338        power: f64,
339        nullspace_order: DuchonNullspaceOrder,
340        full_ident_transform: Option<Array2<f64>>,
341    ) -> Result<Self, BasisError> {
342        if latent.latent_dim() != centers.ncols() {
343            crate::bail_dim_basis!(
344                "LatentCoordDesignDerivative Duchon dimension mismatch: latent d={} centers d={}",
345                latent.latent_dim(),
346                centers.ncols()
347            );
348        }
349        let effective_order = duchon_effective_nullspace_order(centers.view(), nullspace_order);
350        let p_order = duchon_p_from_nullspace_order(effective_order);
351        let s_order = power.max(0.0).round() as usize;
352        // The range must reach BOTH the kernel and the partial-fraction
353        // expansion in the standardized frame: `duchon_partial_fraction_coeffs`
354        // is built at `kappa = 1/ell`, so an unconverted range builds the whole
355        // expansion at the wrong kappa, not merely the kernel (#2643).
356        let length_scale = length_scale.map(|ell| {
357            input_scale
358                .to_standardized_units(ell)
359                .standardized_value()
360        });
361        let radial_kind = if let Some(length_scale) = length_scale {
362            RadialScalarKind::Duchon {
363                length_scale,
364                p_order,
365                s_order,
366                dim: centers.ncols(),
367                coeffs: duchon_partial_fraction_coeffs(
368                    p_order,
369                    s_order,
370                    1.0 / length_scale.max(1e-300),
371                ),
372            }
373        } else {
374            RadialScalarKind::PureDuchon {
375                block_order: pure_duchon_block_order(p_order, power).max(1.0) as usize,
376                p_order,
377                s_order,
378                dim: centers.ncols(),
379            }
380        };
381        let mut workspace = BasisWorkspace::default();
382        let ident_transform =
383            kernel_constraint_nullspace(centers.view(), effective_order, &mut workspace.cache)?;
384        let n_poly = polynomial_block_from_order(centers.view(), effective_order).ncols();
385        Ok(Self::from_local_design_jacobian_provider(Arc::new(
386            RadialLatentCoordLocalDesignJacobian {
387                latent,
388                centers,
389                input_scale,
390                radial_kind,
391                ident_transform: Some(ident_transform),
392                full_ident_transform,
393                n_poly,
394                polynomial_order: Some(effective_order),
395            },
396        )))
397    }
398
399    pub fn new_sphere(
400        latent: Arc<crate::latent::LatentCoordValues>,
401        centers: Arc<Array2<f64>>,
402        penalty_order: usize,
403        ident_transform: Option<Array2<f64>>,
404    ) -> Result<Self, BasisError> {
405        if latent.latent_dim() != centers.ncols() {
406            crate::bail_dim_basis!(
407                "LatentCoordDesignDerivative sphere dimension mismatch: latent d={} centers d={}",
408                latent.latent_dim(),
409                centers.ncols()
410            );
411        }
412        let raw_jet = sphere_first_derivative_nd(
413            latent.as_matrix().view(),
414            centers.view(),
415            penalty_order,
416            true,
417        )?;
418        let jet = latent.design_gradient_wrt_t_dispatch(
419            crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
420        )?;
421        Self::from_jet(latent, jet, ident_transform)
422    }
423
424    pub fn new_periodic_bspline(
425        latent: Arc<crate::latent::LatentCoordValues>,
426        data_range: (f64, f64),
427        degree: usize,
428        num_basis: usize,
429        ident_transform: Option<Array2<f64>>,
430    ) -> Result<Self, BasisError> {
431        let raw_jet = periodic_bspline_first_derivative_nd(
432            latent.as_matrix().view(),
433            data_range,
434            degree,
435            num_basis,
436        )?;
437        let jet = latent.design_gradient_wrt_t_dispatch(
438            crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
439        )?;
440        Self::from_jet(latent, jet, ident_transform)
441    }
442
443    pub fn new_tensor_bspline(
444        latent: Arc<crate::latent::LatentCoordValues>,
445        knots_per_axis: Vec<Array1<f64>>,
446        degrees: Vec<usize>,
447        ident_transform: Option<Array2<f64>>,
448    ) -> Result<Self, BasisError> {
449        let knot_views = knots_per_axis
450            .iter()
451            .map(|knots| knots.view())
452            .collect::<Vec<_>>();
453        let raw_jet =
454            bspline_tensor_first_derivative(latent.as_matrix().view(), &knot_views, &degrees)?;
455        let jet = latent.design_gradient_wrt_t_dispatch(
456            crate::latent::InputLocationDerivative::Jet(raw_jet.view()),
457        )?;
458        Self::from_jet(latent, jet, ident_transform)
459    }
460
461    pub fn new_pca(
462        latent: Arc<crate::latent::LatentCoordValues>,
463        basis_matrix: Arc<Array2<f64>>,
464    ) -> Result<Self, BasisError> {
465        if latent.latent_dim() != basis_matrix.nrows() {
466            crate::bail_dim_basis!(
467                "LatentCoordDesignDerivative Pca dimension mismatch: latent d={} basis rows={}",
468                latent.latent_dim(),
469                basis_matrix.nrows()
470            );
471        }
472        let mut jet =
473            Array3::<f64>::zeros((latent.n_obs(), basis_matrix.ncols(), basis_matrix.nrows()));
474        for row in 0..latent.n_obs() {
475            for axis in 0..basis_matrix.nrows() {
476                for col in 0..basis_matrix.ncols() {
477                    jet[[row, col, axis]] = basis_matrix[[axis, col]];
478                }
479            }
480        }
481        Self::from_jet(latent, jet, None)
482    }
483
484    pub fn from_jet(
485        latent: Arc<crate::latent::LatentCoordValues>,
486        jet: Array3<f64>,
487        ident_transform: Option<Array2<f64>>,
488    ) -> Result<Self, BasisError> {
489        if jet.shape()[0] != latent.n_obs() || jet.shape()[2] != latent.latent_dim() {
490            crate::bail_dim_basis!(
491                "LatentCoordDesignDerivative jet shape {:?} does not match latent shape ({}, {}, {})",
492                jet.shape(),
493                latent.n_obs(),
494                jet.shape()[1],
495                latent.latent_dim()
496            );
497        }
498        if let Some(z) = ident_transform.as_ref()
499            && z.nrows() != jet.shape()[1]
500        {
501            crate::bail_dim_basis!(
502                "LatentCoordDesignDerivative identifiability transform has {} rows but derivative jet has {} basis columns",
503                z.nrows(),
504                jet.shape()[1]
505            );
506        }
507        Ok(Self::from_local_design_jacobian_provider(Arc::new(
508            JetLatentCoordLocalDesignJacobian {
509                latent,
510                jet: Arc::new(jet),
511                ident_transform,
512            },
513        )))
514    }
515
516    pub(crate) fn n_data(&self) -> usize {
517        self.provider.n_data()
518    }
519
520    pub(crate) fn latent_dim(&self) -> usize {
521        self.provider.latent_dim()
522    }
523
524    pub fn n_axes(&self) -> usize {
525        self.provider.n_axes()
526    }
527
528    pub fn p_out(&self) -> usize {
529        self.provider.p_out()
530    }
531}
532
533impl RadialLatentCoordLocalDesignJacobian {
534    pub(crate) fn project_and_pad(
535        &self,
536        raw_knot: &Array1<f64>,
537        raw_poly: &Array1<f64>,
538    ) -> Result<Array1<f64>, BasisError> {
539        let constrained = match &self.ident_transform {
540            Some(z) => z.t().dot(raw_knot),
541            None => raw_knot.clone(),
542        };
543        let mut padded = Array1::<f64>::zeros(constrained.len() + self.n_poly);
544        padded
545            .slice_mut(s![..constrained.len()])
546            .assign(&constrained);
547        if self.n_poly > 0 {
548            padded.slice_mut(s![constrained.len()..]).assign(raw_poly);
549        }
550        Ok(match &self.full_ident_transform {
551            Some(zf) => zf.t().dot(&padded),
552            None => padded,
553        })
554    }
555
556    pub(crate) fn kernel_axis_scalar(
557        &self,
558        row: usize,
559        center: usize,
560        axis: usize,
561    ) -> Result<f64, BasisError> {
562        // `centers` and the kernel range are standardized; the latent values
563        // the optimizer moves are raw. Standardize `t` before forming the
564        // radius so all three meet in one frame (#2643).
565        let t_row = self.latent.row(row);
566        let reciprocal = self.input_scale.reciprocal();
567        let mut r2 = 0.0_f64;
568        for a in 0..self.latent.latent_dim() {
569            let delta = t_row[a] * reciprocal - self.centers[[center, a]];
570            r2 += delta * delta;
571        }
572        let r = r2.sqrt();
573        if r == 0.0 {
574            // At a center collision the axis component s_axis = (t − c)_axis
575            // is exactly zero. The product q · s_axis is therefore 0 for any
576            // kernel whose q has a finite limit; for kernels where q diverges
577            // the value is genuinely indeterminate (0 · ∞) and we must not
578            // pretend it is zero. Defer to the kernel's classification.
579            if self.radial_kind.is_smooth_at_collision() {
580                return Ok(0.0);
581            }
582            return Err(BasisError::DegenerateAtCollision {
583                kernel: "RadialScalarKind (design axis)",
584                dim: self.latent.latent_dim(),
585                m: 0.0,
586                message: "radial scalar q = φ'/r has no finite limit at r = 0; \
587                          the design row axis component is undefined",
588            });
589        }
590        let (_, q, _) = self.radial_kind.eval_design_triplet(r)?;
591        // d/dt phi(||t/sigma - c||) = q * (t/sigma - c)_axis * (1/sigma):
592        // the axis component is standardized like the radius, and the trailing
593        // `reciprocal` is the chain factor for the standardization itself.
594        Ok(q * (t_row[axis] * reciprocal - self.centers[[center, axis]]) * reciprocal)
595    }
596
597    pub(crate) fn polynomial_axis_values(&self, row: usize, axis: usize) -> Array1<f64> {
598        let Some(order) = self.polynomial_order else {
599            return Array1::<f64>::zeros(self.n_poly);
600        };
601        let max_degree = match order {
602            DuchonNullspaceOrder::Zero => 0usize,
603            DuchonNullspaceOrder::Linear => 1usize,
604            DuchonNullspaceOrder::Degree(k) => k,
605        };
606        // The realized polynomial block is built on the STANDARDIZED
607        // coordinates, and its constraint nullspace was built on standardized
608        // centers, so the monomials must be evaluated at `t/sigma` and carry
609        // the same `1/sigma` chain factor as the kernel block (#2643).
610        let t_row = self.latent.row(row);
611        let reciprocal = self.input_scale.reciprocal();
612        let exponents = monomial_exponents(self.latent.latent_dim(), max_degree);
613        let mut out = Array1::<f64>::zeros(exponents.len());
614        for (col, alpha) in exponents.iter().enumerate() {
615            let a_axis = alpha[axis];
616            if a_axis == 0 {
617                continue;
618            }
619            let mut value = a_axis as f64 * reciprocal;
620            for a in 0..self.latent.latent_dim() {
621                let exp_a = if a == axis { a_axis - 1 } else { alpha[a] };
622                if exp_a != 0 {
623                    value *= (t_row[a] * reciprocal).powi(exp_a as i32);
624                }
625            }
626            out[col] = value;
627        }
628        out
629    }
630}
631
632impl JetLatentCoordLocalDesignJacobian {
633    pub(crate) fn project_jet(&self, raw_knot: &Array1<f64>) -> Result<Array1<f64>, BasisError> {
634        Ok(match &self.ident_transform {
635            Some(z) => z.t().dot(raw_knot),
636            None => raw_knot.clone(),
637        })
638    }
639}
640
641impl LocalDesignJacobianProvider for LatentCoordDesignDerivative {
642    fn n_data(&self) -> usize {
643        self.provider.n_data()
644    }
645
646    fn latent_dim(&self) -> usize {
647        self.provider.latent_dim()
648    }
649
650    fn n_axes(&self) -> usize {
651        self.provider.n_axes()
652    }
653
654    fn p_out(&self) -> usize {
655        self.provider.p_out()
656    }
657
658    fn local_design_jacobian_row(
659        &self,
660        row: usize,
661        axis: usize,
662    ) -> Result<Array1<f64>, BasisError> {
663        self.provider.local_design_jacobian_row(row, axis)
664    }
665}
666
667impl LocalDesignJacobianProvider for RadialLatentCoordLocalDesignJacobian {
668    fn n_data(&self) -> usize {
669        self.latent.n_obs()
670    }
671
672    fn latent_dim(&self) -> usize {
673        self.latent.latent_dim()
674    }
675
676    fn n_axes(&self) -> usize {
677        self.latent.len()
678    }
679
680    fn p_out(&self) -> usize {
681        Self::p_out(self)
682    }
683
684    fn local_design_jacobian_row(
685        &self,
686        row: usize,
687        axis: usize,
688    ) -> Result<Array1<f64>, BasisError> {
689        let mut raw_knot = Array1::<f64>::zeros(self.centers.nrows());
690        for center in 0..self.centers.nrows() {
691            raw_knot[center] = self.kernel_axis_scalar(row, center, axis)?;
692        }
693        let raw_poly = self.polynomial_axis_values(row, axis);
694        self.project_and_pad(&raw_knot, &raw_poly)
695    }
696}
697
698impl LocalDesignJacobianProvider for JetLatentCoordLocalDesignJacobian {
699    fn n_data(&self) -> usize {
700        self.latent.n_obs()
701    }
702
703    fn latent_dim(&self) -> usize {
704        self.latent.latent_dim()
705    }
706
707    fn n_axes(&self) -> usize {
708        self.latent.len()
709    }
710
711    fn p_out(&self) -> usize {
712        Self::p_out(self)
713    }
714
715    fn local_design_jacobian_row(
716        &self,
717        row: usize,
718        axis: usize,
719    ) -> Result<Array1<f64>, BasisError> {
720        let mut raw_knot = Array1::<f64>::zeros(self.jet.shape()[1]);
721        for basis_col in 0..self.jet.shape()[1] {
722            raw_knot[basis_col] = self.jet[[row, basis_col, axis]];
723        }
724        self.project_jet(&raw_knot)
725    }
726}
727
728impl ImplicitDesignPsiDerivative {
729    /// Construct from pre-computed radial jet scalars.
730    ///
731    /// # Arguments
732    /// - `q_values`: (n * n_knots,) — φ'(r)/r for each (data, knot) pair.
733    /// - `t_values`: (n * n_knots,) — (φ''(r) - q) / r² for each pair.
734    /// - `axis_components`: (n * n_knots, D) — s_{d,ij} = exp(2η_d) · h_d² for each pair/axis.
735    /// - `ident_transform`: optional (n_knots × p_constrained) constraint projection.
736    /// - `full_ident_transform`: optional further projection after padding.
737    /// - `n`, `n_knots`, `n_poly`, `n_axes`: dimensions.
738    /// Construct from pre-computed (materialized) radial jet scalars.
739    /// This is the original path for small-to-medium problems where
740    /// O(n*k*(d+2)) storage is acceptable.
741    pub fn new(
742        phi_values: Array1<f64>,
743        q_values: Array1<f64>,
744        t_values: Array1<f64>,
745        axis_components: Array2<f64>,
746        ident_transform: Option<Array2<f64>>,
747        full_ident_transform: Option<Array2<f64>>,
748        n: usize,
749        n_knots: usize,
750        n_poly: usize,
751        n_axes: usize,
752    ) -> Self {
753        assert_eq!(
754            phi_values.len(),
755            n * n_knots,
756            "implicit psi derivative phi length mismatch: expected n*n_knots={}*{}={}, got {}",
757            n,
758            n_knots,
759            n * n_knots,
760            phi_values.len()
761        );
762        assert_eq!(
763            q_values.len(),
764            n * n_knots,
765            "implicit psi derivative q length mismatch: expected n*n_knots={}*{}={}, got {}",
766            n,
767            n_knots,
768            n * n_knots,
769            q_values.len()
770        );
771        assert_eq!(
772            t_values.len(),
773            n * n_knots,
774            "implicit psi derivative t length mismatch: expected n*n_knots={}*{}={}, got {}",
775            n,
776            n_knots,
777            n * n_knots,
778            t_values.len()
779        );
780        assert_eq!(
781            axis_components.nrows(),
782            n * n_knots,
783            "implicit psi derivative axis-component row mismatch: expected n*n_knots={}*{}={}, got {}",
784            n,
785            n_knots,
786            n * n_knots,
787            axis_components.nrows()
788        );
789        assert_eq!(
790            axis_components.ncols(),
791            n_axes,
792            "implicit psi derivative axis-component column mismatch: expected n_axes={n_axes}, got {}",
793            axis_components.ncols()
794        );
795        Self {
796            phi_values,
797            axis_components,
798            q_values,
799            t_values,
800            streaming: None,
801            ident_transform,
802            full_ident_transform,
803            n,
804            n_knots,
805            n_poly,
806            n_axes,
807            psi_scale_share: 0.0,
808            axis_combinations: None,
809        }
810    }
811
812    pub(crate) fn with_psi_scale_share(mut self, psi_scale_share: f64) -> Self {
813        self.psi_scale_share = psi_scale_share;
814        self
815    }
816
817    /// Construct a streaming operator that recomputes (q, t, s_a) on the fly
818    /// from raw data/centers/eta during each matvec. No O(n*k) arrays are stored.
819    /// This is the large-scale path.
820    ///
821    /// `pub` like the sibling `new_*` constructors: after the engine crate carve
822    /// (#1521) the REML planner tests live in `gam-solve` and build streaming
823    /// operators as fixtures, so this constructor is part of the cross-crate
824    /// surface, not a crate-private helper.
825    pub fn new_streaming(
826        data: Arc<Array2<f64>>,
827        centers: Arc<Array2<f64>>,
828        eta: Vec<f64>,
829        radial_kind: RadialScalarKind,
830        ident_transform: Option<Array2<f64>>,
831        full_ident_transform: Option<Array2<f64>>,
832        n_poly: usize,
833    ) -> Self {
834        let n = data.nrows();
835        let n_knots = centers.nrows();
836        let n_axes = data.ncols();
837        let psi_scale_share = radial_kind.raw_psi_isotropic_share();
838        assert_eq!(eta.len(), n_axes);
839        assert_eq!(
840            centers.ncols(),
841            n_axes,
842            "streaming radial centers have {} columns but data/eta have {n_axes}",
843            centers.ncols()
844        );
845        let metric_weights: Arc<[f64]> = Arc::from(centered_aniso_metric_weights(&eta));
846        Self {
847            // Empty arrays -- not used in streaming mode.
848            phi_values: Array1::<f64>::zeros(0),
849            axis_components: Array2::<f64>::zeros((0, 0)),
850            q_values: Array1::<f64>::zeros(0),
851            t_values: Array1::<f64>::zeros(0),
852            streaming: Some(StreamingRadialState {
853                data,
854                centers,
855                axis_mode: StreamingAxisMode::PerAxis { metric_weights },
856                radial_kind,
857                triplet_cache: Arc::new(std::sync::OnceLock::new()),
858            }),
859            ident_transform,
860            full_ident_transform,
861            n,
862            n_knots,
863            n_poly,
864            n_axes,
865            psi_scale_share,
866            axis_combinations: None,
867        }
868    }
869
870    /// Construct a streaming operator for a scalar ψ derivative. The operator
871    /// exposes a single axis component equal to the full scaled squared
872    /// distance r² under the fixed metric defined by `eta`.
873    pub(crate) fn new_streaming_scalar(
874        data: Arc<Array2<f64>>,
875        centers: Arc<Array2<f64>>,
876        eta: Vec<f64>,
877        radial_kind: RadialScalarKind,
878        ident_transform: Option<Array2<f64>>,
879        full_ident_transform: Option<Array2<f64>>,
880        n_poly: usize,
881    ) -> Self {
882        let n = data.nrows();
883        let n_knots = centers.nrows();
884        let dim = data.ncols();
885        assert_eq!(eta.len(), dim);
886        assert_eq!(
887            centers.ncols(),
888            dim,
889            "streaming scalar radial centers have {} columns but data/eta have {dim}",
890            centers.ncols()
891        );
892        let metric_weights: Arc<[f64]> = Arc::from(centered_aniso_metric_weights(&eta));
893        Self {
894            phi_values: Array1::<f64>::zeros(0),
895            axis_components: Array2::<f64>::zeros((0, 0)),
896            q_values: Array1::<f64>::zeros(0),
897            t_values: Array1::<f64>::zeros(0),
898            streaming: Some(StreamingRadialState {
899                data,
900                centers,
901                axis_mode: StreamingAxisMode::ScalarTotal { metric_weights },
902                radial_kind,
903                triplet_cache: Arc::new(std::sync::OnceLock::new()),
904            }),
905            ident_transform,
906            full_ident_transform,
907            n,
908            n_knots,
909            n_poly,
910            n_axes: 1,
911            psi_scale_share: 0.0,
912            axis_combinations: None,
913        }
914    }
915
916    /// Whether this operator is in streaming (recompute-on-the-fly) mode.
917    #[inline]
918    pub(crate) fn is_streaming(&self) -> bool {
919        self.streaming.is_some()
920    }
921
922    /// Number of data points.
923    pub fn n_data(&self) -> usize {
924        self.n
925    }
926
927    /// Number of axes (D).
928    pub fn n_axes(&self) -> usize {
929        self.axis_combinations
930            .as_ref()
931            .map_or(self.n_axes, Vec::len)
932    }
933
934    pub fn is_duchon_family(&self) -> bool {
935        self.streaming.as_ref().is_some_and(|state| {
936            matches!(
937                state.radial_kind,
938                RadialScalarKind::Duchon { .. } | RadialScalarKind::PureDuchon { .. }
939            )
940        }) || self.psi_scale_share != 0.0
941    }
942
943    /// Whether this operator is wired up by a basis whose large-scale path
944    /// is supposed to stay implicit, so a dense `(n × p)` materialization
945    /// here is a regression rather than a normal compute path. Duchon-family
946    /// terms qualify because they are streaming-only at any scale; ThinPlate
947    /// qualifies because the new scalar-streaming routing relies on the
948    /// implicit operator above the policy threshold and a sneaky
949    /// `materialize_dense()` would silently re-introduce the n × p
950    /// allocation we just removed. The flag is consulted by the
951    /// materialize_first / materialize_second_diag / materialize_second_cross
952    /// guards to fire `assert_no_dense_derivative_materialization` for these
953    /// kinds whenever the resource policy says the materialization would
954    /// exceed budget. Small-n problems still pass the assertion and get the
955    /// dense fast path.
956    pub(crate) fn enforces_dense_materialization_budget(&self) -> bool {
957        if self
958            .streaming
959            .as_ref()
960            .is_some_and(|state| state.radial_kind.enforces_dense_materialization_budget())
961        {
962            return true;
963        }
964        // The materialized-mode path keeps no `radial_kind` to inspect, but
965        // a non-zero psi_scale_share is the unambiguous Duchon-family
966        // signature there (Matern uses 0, ThinPlate uses 0). Materialized
967        // ThinPlate / Matern terms are in the dense fast path and the
968        // guard does not need to fire for them.
969        self.psi_scale_share != 0.0
970    }
971
972    /// Output dimension: total basis columns in the final space.
973    pub fn p_out(&self) -> usize {
974        if let Some(ref zf) = self.full_ident_transform {
975            zf.ncols()
976        } else {
977            self.p_after_pad()
978        }
979    }
980
981    pub fn append_full_transform(mut self, transform: &Array2<f64>) -> Result<Self, BasisError> {
982        if transform.nrows() != self.p_out() {
983            crate::bail_dim_basis!(
984                "implicit psi derivative transform has {} rows but operator has {} output columns",
985                transform.nrows(),
986                self.p_out()
987            );
988        }
989        self.full_ident_transform = Some(match self.full_ident_transform.take() {
990            Some(existing) => fast_ab(&existing, transform),
991            None => transform.clone(),
992        });
993        Ok(self)
994    }
995
996    /// Dimension after kernel constraint + polynomial padding (before full ident).
997    pub(crate) fn p_after_pad(&self) -> usize {
998        let p_constrained = self.p_constrained();
999        p_constrained + self.n_poly
1000    }
1001
1002    /// Dimension after kernel constraint projection (before poly padding).
1003    pub(crate) fn p_constrained(&self) -> usize {
1004        match &self.ident_transform {
1005            Some(z) => z.ncols(),
1006            None => self.n_knots,
1007        }
1008    }
1009
1010    /// Accumulate raw knot-space vector from weighted (data, knot) contributions.
1011    /// Returns a vector of length n_knots: Σ_i w_i · scalar_{ij} for each knot j.
1012    ///
1013    /// This is the core primitive: for each data point i, accumulate
1014    /// `v[i] * per_pair_scalar(i,j)` into knot j.
1015    pub(crate) fn accumulate_knot_vector<F>(&self, v: &ArrayView1<f64>, per_pair: F) -> Array1<f64>
1016    where
1017        F: Fn(usize) -> f64 + Send + Sync,
1018    {
1019        let n = self.n;
1020        let k = self.n_knots;
1021
1022        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1023            // Parallel path: chunk data points and reduce.
1024            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1025            let partial_sums: Vec<Array1<f64>> = (0..n_chunks)
1026                .into_par_iter()
1027                .map(|chunk_idx| {
1028                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1029                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1030                    let mut local = Array1::<f64>::zeros(k);
1031                    for i in start..end {
1032                        let vi = v[i];
1033                        if vi == 0.0 {
1034                            continue;
1035                        }
1036                        let base = i * k;
1037                        for j in 0..k {
1038                            local[j] += vi * per_pair(base + j);
1039                        }
1040                    }
1041                    local
1042                })
1043                .collect();
1044            let mut total = Array1::<f64>::zeros(k);
1045            for p in partial_sums {
1046                total += &p;
1047            }
1048            total
1049        } else {
1050            // Sequential path.
1051            let mut total = Array1::<f64>::zeros(k);
1052            for i in 0..n {
1053                let vi = v[i];
1054                if vi == 0.0 {
1055                    continue;
1056                }
1057                let base = i * k;
1058                for j in 0..k {
1059                    total[j] += vi * per_pair(base + j);
1060                }
1061            }
1062            total
1063        }
1064    }
1065
1066    /// Streaming accumulate knot vector from on-the-fly radial scalars.
1067    pub(crate) fn streaming_accumulate_knot_vector<G>(
1068        &self,
1069        v: &ArrayView1<f64>,
1070        deriv_fn: G,
1071    ) -> Result<Array1<f64>, BasisError>
1072    where
1073        G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1074    {
1075        let Some(st) = self.streaming.as_ref() else {
1076            return Err(BasisError::InvalidInput(
1077                "streaming_accumulate_knot_vector needs the streaming radial state, but this implicit \
1078                 ψ-derivative operator was built without one"
1079                    .to_string(),
1080            ));
1081        };
1082        let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1083        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1084            let err_flag = std::sync::atomic::AtomicBool::new(false);
1085            let nc = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1086            let ps: Vec<Array1<f64>> = (0..nc)
1087                .into_par_iter()
1088                .map(|ci| {
1089                    let s = ci * IMPLICIT_MATVEC_CHUNK_SIZE;
1090                    let e = (s + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1091                    let mut loc = Array1::<f64>::zeros(k);
1092                    let mut sb = vec![0.0; dim];
1093                    for i in s..e {
1094                        let vi = v[i];
1095                        if vi == 0.0 {
1096                            continue;
1097                        }
1098                        for j in 0..k {
1099                            match st.compute_pair(i, j, &mut sb) {
1100                                Ok((phi, q, t)) => {
1101                                    loc[j] += vi * deriv_fn(phi, q, t, &sb);
1102                                }
1103                                Err(_) => {
1104                                    err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
1105                                    return loc;
1106                                }
1107                            }
1108                        }
1109                    }
1110                    loc
1111                })
1112                .collect();
1113            if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1114                crate::bail_invalid_basis!(
1115                    "radial scalar evaluation failed during streaming accumulate_knot_vector"
1116                        .into(),
1117                );
1118            }
1119            let mut tot = Array1::<f64>::zeros(k);
1120            for p in ps {
1121                tot += &p;
1122            }
1123            Ok(tot)
1124        } else {
1125            let mut tot = Array1::<f64>::zeros(k);
1126            let mut sb = vec![0.0; dim];
1127            for i in 0..n {
1128                let vi = v[i];
1129                if vi == 0.0 {
1130                    continue;
1131                }
1132                for j in 0..k {
1133                    let (phi, q, t) = st.compute_pair(i,j,&mut sb).map_err(|e| BasisError::InvalidInput(
1134                        format!("radial scalar evaluation failed during streaming accumulate_knot_vector: {e}"),
1135                    ))?;
1136                    tot[j] += vi * deriv_fn(phi, q, t, &sb);
1137                }
1138            }
1139            Ok(tot)
1140        }
1141    }
1142    /// Streaming forward multiply.
1143    pub(crate) fn streaming_forward_mul<G>(
1144        &self,
1145        u_knot: &Array1<f64>,
1146        deriv_fn: G,
1147    ) -> Result<Array1<f64>, BasisError>
1148    where
1149        G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1150    {
1151        let Some(st) = self.streaming.as_ref() else {
1152            return Err(BasisError::InvalidInput(
1153                "streaming_forward_mul needs the streaming radial state, but this implicit \
1154                 ψ-derivative operator was built without one"
1155                    .to_string(),
1156            ));
1157        };
1158        let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1159        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1160            let err_flag = std::sync::atomic::AtomicBool::new(false);
1161            let nc = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1162            let cr: Vec<(usize, Vec<f64>)> = (0..nc)
1163                .into_par_iter()
1164                .map(|ci| {
1165                    let s = ci * IMPLICIT_MATVEC_CHUNK_SIZE;
1166                    let e = (s + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1167                    let mut loc = vec![0.0; e - s];
1168                    let mut sb = vec![0.0; dim];
1169                    for i in s..e {
1170                        let mut val = 0.0;
1171                        for j in 0..k {
1172                            match st.compute_pair(i, j, &mut sb) {
1173                                Ok((phi, q, t)) => {
1174                                    val += deriv_fn(phi, q, t, &sb) * u_knot[j];
1175                                }
1176                                Err(_) => {
1177                                    err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
1178                                    break;
1179                                }
1180                            }
1181                        }
1182                        loc[i - s] = val;
1183                    }
1184                    (s, loc)
1185                })
1186                .collect();
1187            if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1188                crate::bail_invalid_basis!(
1189                    "radial scalar evaluation failed during streaming forward_mul".into(),
1190                );
1191            }
1192            let mut res = Array1::<f64>::zeros(n);
1193            for (s, vs) in cr {
1194                for (o, &v) in vs.iter().enumerate() {
1195                    res[s + o] = v;
1196                }
1197            }
1198            Ok(res)
1199        } else {
1200            let mut res = Array1::<f64>::zeros(n);
1201            let mut sb = vec![0.0; dim];
1202            for i in 0..n {
1203                let mut val = 0.0;
1204                for j in 0..k {
1205                    let (phi, q, t) = st.compute_pair(i, j, &mut sb).map_err(|e| {
1206                        BasisError::InvalidInput(format!(
1207                            "radial scalar evaluation failed during streaming forward_mul: {e}"
1208                        ))
1209                    })?;
1210                    val += deriv_fn(phi, q, t, &sb) * u_knot[j];
1211                }
1212                res[i] = val;
1213            }
1214            Ok(res)
1215        }
1216    }
1217    /// Streaming materialization: build (n x k) raw matrix then project.
1218    pub(crate) fn streaming_materialize<G>(&self, deriv_fn: G) -> Result<Array2<f64>, BasisError>
1219    where
1220        G: Fn(f64, f64, f64, &[f64]) -> f64 + Send + Sync,
1221    {
1222        let Some(st) = self.streaming.as_ref() else {
1223            return Err(BasisError::InvalidInput(
1224                "streaming_materialize needs the streaming radial state, but this implicit \
1225                 ψ-derivative operator was built without one"
1226                    .to_string(),
1227            ));
1228        };
1229        let (n, k, dim) = (self.n, self.n_knots, self.n_axes);
1230        let mut raw = Array2::<f64>::zeros((n, k));
1231        let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
1232        let nc = n.div_ceil(cs);
1233        let err_flag = std::sync::atomic::AtomicBool::new(false);
1234        {
1235            let rp = SendPtr(raw.as_mut_ptr());
1236            let ef = &err_flag;
1237            (0..nc).into_par_iter().for_each(move |ci| {
1238                let s = ci * cs;
1239                let e = (s + cs).min(n);
1240                let mut sb = vec![0.0; dim];
1241                for i in s..e {
1242                    for j in 0..k {
1243                        match st.compute_pair(i, j, &mut sb) {
1244                            // SAFETY: chunk ci owns rows [s..e) of the raw n×k buffer,
1245                            // so offsets i*k+j for i ∈ [s,e), j ∈ [0,k) are pairwise
1246                            // disjoint across workers and stay within n*k = raw.len().
1247                            Ok((phi, q, t)) => unsafe {
1248                                *rp.add(i * k + j) = deriv_fn(phi, q, t, &sb);
1249                            },
1250                            Err(_) => {
1251                                ef.store(true, std::sync::atomic::Ordering::Relaxed);
1252                                return;
1253                            }
1254                        }
1255                    }
1256                }
1257            });
1258        }
1259        if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
1260            crate::bail_invalid_basis!(
1261                "radial scalar evaluation failed during streaming materialize".into(),
1262            );
1263        }
1264        Ok(self.project_matrix(raw))
1265    }
1266
1267    /// Project a raw knot-space vector through the identifiability transform
1268    /// and pad with zeros for polynomial columns.
1269    pub(crate) fn project_and_pad(&self, raw_knot_vec: &Array1<f64>) -> Array1<f64> {
1270        // Step 1: apply kernel constraint Z (if present).
1271        let constrained = match &self.ident_transform {
1272            Some(z) => z.t().dot(raw_knot_vec),
1273            None => raw_knot_vec.clone(),
1274        };
1275
1276        // Step 2: pad with polynomial zeros.
1277        let p_padded = constrained.len() + self.n_poly;
1278        let mut padded = Array1::<f64>::zeros(p_padded);
1279        padded
1280            .slice_mut(s![..constrained.len()])
1281            .assign(&constrained);
1282
1283        // Step 3: apply full identifiability transform (if present).
1284        match &self.full_ident_transform {
1285            Some(zf) => zf.t().dot(&padded),
1286            None => padded,
1287        }
1288    }
1289
1290    /// Expand a coefficient vector from the final space back to raw knot space.
1291    /// This is the transpose path: p_out → (padded) → (constrained) → n_knots.
1292    pub(crate) fn unproject(&self, u: &ArrayView1<f64>) -> Array1<f64> {
1293        // Step 1: undo full identifiability transform.
1294        let after_full = match &self.full_ident_transform {
1295            Some(zf) => zf.dot(u),
1296            None => u.to_owned(),
1297        };
1298
1299        // Step 2: extract smooth part (drop polynomial padding).
1300        let p_constrained = self.p_constrained();
1301        let smooth_part = after_full.slice(s![..p_constrained]);
1302
1303        // Step 3: undo kernel constraint Z.
1304        match &self.ident_transform {
1305            Some(z) => z.dot(&smooth_part),
1306            None => smooth_part.to_owned(),
1307        }
1308    }
1309
1310    /// Batched `unproject` for a (p_out × rank) coefficient matrix.
1311    /// Returns (n_knots × rank) via two BLAS3 matmuls — the same algebra as
1312    /// `unproject`, but amortized across all rank columns of `u`. Used by
1313    /// `forward_mul_matrix` so per-axis trace evaluations can be a single
1314    /// chunked GEMM rather than rank-many `forward_mul` calls.
1315    pub fn unproject_matrix(&self, u: &ArrayView2<f64>) -> Array2<f64> {
1316        assert_eq!(u.nrows(), self.p_out());
1317        // Step 1: undo full identifiability transform → (p_after_pad, rank).
1318        let after_full = match &self.full_ident_transform {
1319            Some(zf) => fast_ab(zf, u),
1320            None => u.to_owned(),
1321        };
1322        // Step 2: drop polynomial padding rows → (p_constrained, rank).
1323        let p_constrained = self.p_constrained();
1324        let smooth_part = after_full.slice(s![..p_constrained, ..]);
1325        // Step 3: undo kernel constraint Z → (n_knots, rank).
1326        match &self.ident_transform {
1327            Some(z) => fast_ab(z, &smooth_part),
1328            None => smooth_part.to_owned(),
1329        }
1330    }
1331
1332    /// Compute (∂X/∂ψ_d)^T v for a given axis d and vector v of length n.
1333    ///
1334    /// Returns a vector of length p_out (total basis dimension after all transforms).
1335    ///
1336    /// Formula in raw knot space:
1337    ///   \[raw\]_j = Σ_i v_i · q_{ij} · s_{d,ij}
1338    /// then project through Z and pad.
1339    ///
1340    /// Note: q = φ_r/r and s_d = exp(2ψ_d)·h_d² are UNNORMALIZED axis components.
1341    /// With this convention, q·s_d = (φ_r/r)·(exp(2ψ_d)·h_d²) = φ_r·(s_d/r),
1342    /// which equals the correct ∂φ/∂ψ_d = φ_r·∂r/∂ψ_d = φ_r·s_d/r.
1343    /// No r² correction is needed — that would be required only if s_d were
1344    /// the fractional quantity s_d/r².
1345    pub fn transpose_mul(
1346        &self,
1347        axis: usize,
1348        v: &ArrayView1<f64>,
1349    ) -> Result<Array1<f64>, BasisError> {
1350        assert!(
1351            axis < self.n_axes(),
1352            "implicit psi first transpose axis out of bounds: axis={axis}, n_axes={}",
1353            self.n_axes()
1354        );
1355        assert_eq!(
1356            v.len(),
1357            self.n,
1358            "implicit psi first transpose row-adjoint length mismatch"
1359        );
1360        if self.axis_combinations.is_some() {
1361            let combo = self.transformed_axis_combination(axis);
1362            let combo_sum = Self::transformed_combo_sum(combo);
1363            if self.is_streaming() {
1364                let c = self.psi_scale_share;
1365                let raw = self.streaming_accumulate_knot_vector(v, |phi, q, _, sb| {
1366                    let s_combo = combo
1367                        .iter()
1368                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1369                        .sum();
1370                    Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
1371                })?;
1372                return Ok(self.project_and_pad(&raw));
1373            }
1374            let c = self.psi_scale_share;
1375            let raw = self.accumulate_knot_vector(v, |idx| {
1376                let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1377                Self::transformed_first_kernel_value(
1378                    self.phi_values[idx],
1379                    self.q_values[idx],
1380                    s_combo,
1381                    combo_sum,
1382                    c,
1383                )
1384            });
1385            return Ok(self.project_and_pad(&raw));
1386        }
1387        if self.is_streaming() {
1388            let c = self.psi_scale_share;
1389            let raw =
1390                self.streaming_accumulate_knot_vector(v, |phi, q, _, sb| q * sb[axis] + c * phi)?;
1391            return Ok(self.project_and_pad(&raw));
1392        }
1393        let c = self.psi_scale_share;
1394        let af = &self.axis_components;
1395        let pv = &self.phi_values;
1396        let qv = &self.q_values;
1397        let raw = self.accumulate_knot_vector(v, |idx| qv[idx] * af[[idx, axis]] + c * pv[idx]);
1398        Ok(self.project_and_pad(&raw))
1399    }
1400
1401    /// Compute (∂X/∂ψ_d) u for a given axis d and vector u of length p_out.
1402    ///
1403    /// Returns a vector of length n.
1404    ///
1405    /// Formula: for each data point i,
1406    ///   result_i = Σ_j q_{ij} · s_{d,ij} · u_knot_j
1407    /// where u_knot = Z · u_smooth (unprojected back to knot space).
1408    pub fn forward_mul(&self, axis: usize, u: &ArrayView1<f64>) -> Result<Array1<f64>, BasisError> {
1409        assert!(
1410            axis < self.n_axes(),
1411            "implicit psi first forward axis out of bounds: axis={axis}, n_axes={}",
1412            self.n_axes()
1413        );
1414        assert_eq!(
1415            u.len(),
1416            self.p_out(),
1417            "implicit psi first forward coefficient length mismatch"
1418        );
1419        let u_knot = self.unproject(u);
1420        if self.axis_combinations.is_some() {
1421            let combo = self.transformed_axis_combination(axis);
1422            let combo_sum = Self::transformed_combo_sum(combo);
1423            if self.is_streaming() {
1424                let c = self.psi_scale_share;
1425                return self.streaming_forward_mul(&u_knot, |phi, q, _, sb| {
1426                    let s_combo = combo
1427                        .iter()
1428                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1429                        .sum();
1430                    Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
1431                });
1432            }
1433            let n = self.n;
1434            let k = self.n_knots;
1435            let c = self.psi_scale_share;
1436            if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1437                let mut result = Array1::<f64>::zeros(n);
1438                let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1439                let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1440                    .into_par_iter()
1441                    .map(|chunk_idx| {
1442                        let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1443                        let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1444                        let mut local = vec![0.0; end - start];
1445                        for i in start..end {
1446                            let base = i * k;
1447                            let mut val = 0.0;
1448                            for j in 0..k {
1449                                let idx = base + j;
1450                                let s_combo =
1451                                    self.transformed_combo_axis_value_materialized(idx, combo);
1452                                val += Self::transformed_first_kernel_value(
1453                                    self.phi_values[idx],
1454                                    self.q_values[idx],
1455                                    s_combo,
1456                                    combo_sum,
1457                                    c,
1458                                ) * u_knot[j];
1459                            }
1460                            local[i - start] = val;
1461                        }
1462                        (start, local)
1463                    })
1464                    .collect();
1465                for (start, vals) in chunk_results {
1466                    for (offset, &v) in vals.iter().enumerate() {
1467                        result[start + offset] = v;
1468                    }
1469                }
1470                return Ok(result);
1471            }
1472            let mut result = Array1::<f64>::zeros(n);
1473            for i in 0..n {
1474                let base = i * k;
1475                let mut val = 0.0;
1476                for j in 0..k {
1477                    let idx = base + j;
1478                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1479                    val += Self::transformed_first_kernel_value(
1480                        self.phi_values[idx],
1481                        self.q_values[idx],
1482                        s_combo,
1483                        combo_sum,
1484                        c,
1485                    ) * u_knot[j];
1486                }
1487                result[i] = val;
1488            }
1489            return Ok(result);
1490        }
1491        if self.is_streaming() {
1492            let c = self.psi_scale_share;
1493            return self.streaming_forward_mul(&u_knot, |phi, q, _, sb| q * sb[axis] + c * phi);
1494        }
1495        let n = self.n;
1496        let k = self.n_knots;
1497        let c = self.psi_scale_share;
1498        let af = &self.axis_components;
1499        let pv = &self.phi_values;
1500        let qv = &self.q_values;
1501
1502        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1503            let mut result = Array1::<f64>::zeros(n);
1504            // Parallel over chunks of data points.
1505            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1506            let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1507                .into_par_iter()
1508                .map(|chunk_idx| {
1509                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1510                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1511                    let mut local = vec![0.0; end - start];
1512                    for i in start..end {
1513                        let base = i * k;
1514                        let mut val = 0.0;
1515                        for j in 0..k {
1516                            val += (qv[base + j] * af[[base + j, axis]] + c * pv[base + j])
1517                                * u_knot[j];
1518                        }
1519                        local[i - start] = val;
1520                    }
1521                    (start, local)
1522                })
1523                .collect();
1524            for (start, vals) in chunk_results {
1525                for (offset, &v) in vals.iter().enumerate() {
1526                    result[start + offset] = v;
1527                }
1528            }
1529            Ok(result)
1530        } else {
1531            let mut result = Array1::<f64>::zeros(n);
1532            for i in 0..n {
1533                let base = i * k;
1534                let mut val = 0.0;
1535                for j in 0..k {
1536                    val += (qv[base + j] * af[[base + j, axis]] + c * pv[base + j]) * u_knot[j];
1537                }
1538                result[i] = val;
1539            }
1540            Ok(result)
1541        }
1542    }
1543
1544    /// Compute (∂²X/∂ψ_d²)^T v — diagonal second derivative, same axis.
1545    ///
1546    /// Matrix-free variant of `materialize_second_diag`: avoids forming the
1547    /// full (n × p_out) matrix when only a single adjoint matvec is needed.
1548    pub fn transpose_mul_second_diag(
1549        &self,
1550        axis: usize,
1551        v: &ArrayView1<f64>,
1552    ) -> Result<Array1<f64>, BasisError> {
1553        assert!(
1554            axis < self.n_axes(),
1555            "implicit psi second diagonal transpose axis out of bounds: axis={axis}, n_axes={}",
1556            self.n_axes()
1557        );
1558        assert_eq!(
1559            v.len(),
1560            self.n,
1561            "implicit psi second diagonal transpose row-adjoint length mismatch"
1562        );
1563        if self.axis_combinations.is_some() {
1564            let combo = self.transformed_axis_combination(axis);
1565            let combo_sum = Self::transformed_combo_sum(combo);
1566            if self.is_streaming() {
1567                let c = self.psi_scale_share;
1568                let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1569                    let s_combo = combo
1570                        .iter()
1571                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1572                        .sum();
1573                    let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
1574                    Self::transformed_second_kernel_value(
1575                        phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap_s, c,
1576                    )
1577                })?;
1578                return Ok(self.project_and_pad(&raw));
1579            }
1580            let c = self.psi_scale_share;
1581            let raw = self.accumulate_knot_vector(v, |idx| {
1582                let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1583                let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
1584                Self::transformed_second_kernel_value(
1585                    self.phi_values[idx],
1586                    self.q_values[idx],
1587                    self.t_values[idx],
1588                    s_combo,
1589                    combo_sum,
1590                    s_combo,
1591                    combo_sum,
1592                    overlap_s,
1593                    c,
1594                )
1595            });
1596            return Ok(self.project_and_pad(&raw));
1597        }
1598        if self.is_streaming() {
1599            let c = self.psi_scale_share;
1600            let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1601                let s = sb[axis];
1602                2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
1603            })?;
1604            return Ok(self.project_and_pad(&raw));
1605        }
1606        let c = self.psi_scale_share;
1607        let af = &self.axis_components;
1608        let pv = &self.phi_values;
1609        let qv = &self.q_values;
1610        let tv = &self.t_values;
1611        let raw = self.accumulate_knot_vector(v, |idx| {
1612            let s = af[[idx, axis]];
1613            2.0 * qv[idx] * s + tv[idx] * s * s + 2.0 * c * qv[idx] * s + c * c * pv[idx]
1614        });
1615        Ok(self.project_and_pad(&raw))
1616    }
1617
1618    /// Compute (∂²X/∂ψ_d∂ψ_e)^T v — cross second derivative (d ≠ e).
1619    pub fn transpose_mul_second_cross(
1620        &self,
1621        axis_d: usize,
1622        axis_e: usize,
1623        v: &ArrayView1<f64>,
1624    ) -> Result<Array1<f64>, BasisError> {
1625        assert!(
1626            axis_d < self.n_axes(),
1627            "implicit psi second cross transpose first axis out of bounds: axis_d={axis_d}, n_axes={}",
1628            self.n_axes()
1629        );
1630        assert!(
1631            axis_e < self.n_axes(),
1632            "implicit psi second cross transpose second axis out of bounds: axis_e={axis_e}, n_axes={}",
1633            self.n_axes()
1634        );
1635        assert_ne!(
1636            axis_d, axis_e,
1637            "implicit psi second cross transpose requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
1638        );
1639        assert_eq!(
1640            v.len(),
1641            self.n,
1642            "implicit psi second cross transpose row-adjoint length mismatch"
1643        );
1644        if self.axis_combinations.is_some() {
1645            let combo_d = self.transformed_axis_combination(axis_d);
1646            let combo_e = self.transformed_axis_combination(axis_e);
1647            let sum_d = Self::transformed_combo_sum(combo_d);
1648            let sum_e = Self::transformed_combo_sum(combo_e);
1649            if self.is_streaming() {
1650                let c = self.psi_scale_share;
1651                let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1652                    let s_d = combo_d
1653                        .iter()
1654                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1655                        .sum();
1656                    let s_e = combo_e
1657                        .iter()
1658                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1659                        .sum();
1660                    let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
1661                    Self::transformed_second_kernel_value(
1662                        phi, q, t, s_d, sum_d, s_e, sum_e, overlap_s, c,
1663                    )
1664                })?;
1665                return Ok(self.project_and_pad(&raw));
1666            }
1667            let c = self.psi_scale_share;
1668            let raw = self.accumulate_knot_vector(v, |idx| {
1669                let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
1670                let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
1671                let overlap_s = self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
1672                Self::transformed_second_kernel_value(
1673                    self.phi_values[idx],
1674                    self.q_values[idx],
1675                    self.t_values[idx],
1676                    s_d,
1677                    sum_d,
1678                    s_e,
1679                    sum_e,
1680                    overlap_s,
1681                    c,
1682                )
1683            });
1684            return Ok(self.project_and_pad(&raw));
1685        }
1686        if self.is_streaming() {
1687            let c = self.psi_scale_share;
1688            let raw = self.streaming_accumulate_knot_vector(v, |phi, q, t, sb| {
1689                t * sb[axis_d] * sb[axis_e] + c * q * (sb[axis_d] + sb[axis_e]) + c * c * phi
1690            })?;
1691            return Ok(self.project_and_pad(&raw));
1692        }
1693        let c = self.psi_scale_share;
1694        let af = &self.axis_components;
1695        let pv = &self.phi_values;
1696        let qv = &self.q_values;
1697        let tv = &self.t_values;
1698        let raw = self.accumulate_knot_vector(v, |idx| {
1699            tv[idx] * af[[idx, axis_d]] * af[[idx, axis_e]]
1700                + c * qv[idx] * (af[[idx, axis_d]] + af[[idx, axis_e]])
1701                + c * c * pv[idx]
1702        });
1703        Ok(self.project_and_pad(&raw))
1704    }
1705
1706    /// Compute (∂²X/∂ψ_d²) u — forward diagonal second derivative.
1707    pub fn forward_mul_second_diag(
1708        &self,
1709        axis: usize,
1710        u: &ArrayView1<f64>,
1711    ) -> Result<Array1<f64>, BasisError> {
1712        assert!(
1713            axis < self.n_axes(),
1714            "implicit psi second diagonal forward axis out of bounds: axis={axis}, n_axes={}",
1715            self.n_axes()
1716        );
1717        assert_eq!(
1718            u.len(),
1719            self.p_out(),
1720            "implicit psi second diagonal forward coefficient length mismatch"
1721        );
1722        let u_knot = self.unproject(u);
1723        if self.axis_combinations.is_some() {
1724            let combo = self.transformed_axis_combination(axis);
1725            let combo_sum = Self::transformed_combo_sum(combo);
1726            if self.is_streaming() {
1727                let c = self.psi_scale_share;
1728                return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1729                    let s_combo = combo
1730                        .iter()
1731                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1732                        .sum();
1733                    let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
1734                    Self::transformed_second_kernel_value(
1735                        phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap_s, c,
1736                    )
1737                });
1738            }
1739            let n = self.n;
1740            let k = self.n_knots;
1741            let c = self.psi_scale_share;
1742            let compute_row = |i: usize| -> f64 {
1743                let base = i * k;
1744                let mut val = 0.0;
1745                for j in 0..k {
1746                    let idx = base + j;
1747                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
1748                    let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
1749                    val += Self::transformed_second_kernel_value(
1750                        self.phi_values[idx],
1751                        self.q_values[idx],
1752                        self.t_values[idx],
1753                        s_combo,
1754                        combo_sum,
1755                        s_combo,
1756                        combo_sum,
1757                        overlap_s,
1758                        c,
1759                    ) * u_knot[j];
1760                }
1761                val
1762            };
1763            if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1764                let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1765                let mut result = Array1::<f64>::zeros(n);
1766                let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1767                    .into_par_iter()
1768                    .map(|chunk_idx| {
1769                        let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1770                        let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1771                        let local: Vec<f64> = (start..end).map(compute_row).collect();
1772                        (start, local)
1773                    })
1774                    .collect();
1775                for (start, vals) in chunk_results {
1776                    for (offset, &value) in vals.iter().enumerate() {
1777                        result[start + offset] = value;
1778                    }
1779                }
1780                return Ok(result);
1781            }
1782            return Ok(Array1::from_vec((0..n).map(compute_row).collect()));
1783        }
1784        if self.is_streaming() {
1785            let c = self.psi_scale_share;
1786            return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1787                let s = sb[axis];
1788                2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
1789            });
1790        }
1791        let n = self.n;
1792        let k = self.n_knots;
1793        let c = self.psi_scale_share;
1794        let af = &self.axis_components;
1795        let pv = &self.phi_values;
1796        let qv = &self.q_values;
1797        let tv = &self.t_values;
1798        let compute_row = |i: usize| -> f64 {
1799            let base = i * k;
1800            let mut val = 0.0;
1801            for j in 0..k {
1802                let s = af[[base + j, axis]];
1803                val += (2.0 * qv[base + j] * s
1804                    + tv[base + j] * s * s
1805                    + 2.0 * c * qv[base + j] * s
1806                    + c * c * pv[base + j])
1807                    * u_knot[j];
1808            }
1809            val
1810        };
1811
1812        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1813            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1814            let mut result = Array1::<f64>::zeros(n);
1815            let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1816                .into_par_iter()
1817                .map(|chunk_idx| {
1818                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1819                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1820                    let local: Vec<f64> = (start..end).map(compute_row).collect();
1821                    (start, local)
1822                })
1823                .collect();
1824            for (start, vals) in chunk_results {
1825                for (offset, &value) in vals.iter().enumerate() {
1826                    result[start + offset] = value;
1827                }
1828            }
1829            Ok(result)
1830        } else {
1831            Ok(Array1::from_vec((0..n).map(compute_row).collect()))
1832        }
1833    }
1834
1835    /// Compute (∂²X/∂ψ_d∂ψ_e) u — forward cross second derivative.
1836    pub fn forward_mul_second_cross(
1837        &self,
1838        axis_d: usize,
1839        axis_e: usize,
1840        u: &ArrayView1<f64>,
1841    ) -> Result<Array1<f64>, BasisError> {
1842        assert!(
1843            axis_d < self.n_axes(),
1844            "implicit psi second cross forward first axis out of bounds: axis_d={axis_d}, n_axes={}",
1845            self.n_axes()
1846        );
1847        assert!(
1848            axis_e < self.n_axes(),
1849            "implicit psi second cross forward second axis out of bounds: axis_e={axis_e}, n_axes={}",
1850            self.n_axes()
1851        );
1852        assert_ne!(
1853            axis_d, axis_e,
1854            "implicit psi second cross forward requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
1855        );
1856        assert_eq!(
1857            u.len(),
1858            self.p_out(),
1859            "implicit psi second cross forward coefficient length mismatch"
1860        );
1861        let u_knot = self.unproject(u);
1862        if self.axis_combinations.is_some() {
1863            let combo_d = self.transformed_axis_combination(axis_d);
1864            let combo_e = self.transformed_axis_combination(axis_e);
1865            let sum_d = Self::transformed_combo_sum(combo_d);
1866            let sum_e = Self::transformed_combo_sum(combo_e);
1867            if self.is_streaming() {
1868                let c = self.psi_scale_share;
1869                return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1870                    let s_d = combo_d
1871                        .iter()
1872                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1873                        .sum();
1874                    let s_e = combo_e
1875                        .iter()
1876                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
1877                        .sum();
1878                    let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
1879                    Self::transformed_second_kernel_value(
1880                        phi, q, t, s_d, sum_d, s_e, sum_e, overlap_s, c,
1881                    )
1882                });
1883            }
1884            let n = self.n;
1885            let k = self.n_knots;
1886            let c = self.psi_scale_share;
1887            let compute_row = |i: usize| -> f64 {
1888                let base = i * k;
1889                let mut val = 0.0;
1890                for j in 0..k {
1891                    let idx = base + j;
1892                    let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
1893                    let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
1894                    let overlap_s =
1895                        self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
1896                    val += Self::transformed_second_kernel_value(
1897                        self.phi_values[idx],
1898                        self.q_values[idx],
1899                        self.t_values[idx],
1900                        s_d,
1901                        sum_d,
1902                        s_e,
1903                        sum_e,
1904                        overlap_s,
1905                        c,
1906                    ) * u_knot[j];
1907                }
1908                val
1909            };
1910            if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1911                let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1912                let mut result = Array1::<f64>::zeros(n);
1913                let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1914                    .into_par_iter()
1915                    .map(|chunk_idx| {
1916                        let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1917                        let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1918                        let local: Vec<f64> = (start..end).map(compute_row).collect();
1919                        (start, local)
1920                    })
1921                    .collect();
1922                for (start, vals) in chunk_results {
1923                    for (offset, &value) in vals.iter().enumerate() {
1924                        result[start + offset] = value;
1925                    }
1926                }
1927                return Ok(result);
1928            }
1929            return Ok(Array1::from_vec((0..n).map(compute_row).collect()));
1930        }
1931        if self.is_streaming() {
1932            let c = self.psi_scale_share;
1933            return self.streaming_forward_mul(&u_knot, |phi, q, t, sb| {
1934                t * sb[axis_d] * sb[axis_e] + c * q * (sb[axis_d] + sb[axis_e]) + c * c * phi
1935            });
1936        }
1937        let n = self.n;
1938        let k = self.n_knots;
1939        let c = self.psi_scale_share;
1940        let af = &self.axis_components;
1941        let pv = &self.phi_values;
1942        let qv = &self.q_values;
1943        let tv = &self.t_values;
1944        let compute_row = |i: usize| -> f64 {
1945            let base = i * k;
1946            let mut val = 0.0;
1947            for j in 0..k {
1948                val += (tv[base + j] * af[[base + j, axis_d]] * af[[base + j, axis_e]]
1949                    + c * qv[base + j] * (af[[base + j, axis_d]] + af[[base + j, axis_e]])
1950                    + c * c * pv[base + j])
1951                    * u_knot[j];
1952            }
1953            val
1954        };
1955
1956        if n >= IMPLICIT_MATVEC_PAR_THRESHOLD {
1957            let n_chunks = n.div_ceil(IMPLICIT_MATVEC_CHUNK_SIZE);
1958            let mut result = Array1::<f64>::zeros(n);
1959            let chunk_results: Vec<(usize, Vec<f64>)> = (0..n_chunks)
1960                .into_par_iter()
1961                .map(|chunk_idx| {
1962                    let start = chunk_idx * IMPLICIT_MATVEC_CHUNK_SIZE;
1963                    let end = (start + IMPLICIT_MATVEC_CHUNK_SIZE).min(n);
1964                    let local: Vec<f64> = (start..end).map(compute_row).collect();
1965                    (start, local)
1966                })
1967                .collect();
1968            for (start, vals) in chunk_results {
1969                for (offset, &value) in vals.iter().enumerate() {
1970                    result[start + offset] = value;
1971                }
1972            }
1973            Ok(result)
1974        } else {
1975            Ok(Array1::from_vec((0..n).map(compute_row).collect()))
1976        }
1977    }
1978
1979    /// Materialize the full (n × p_out) first-derivative matrix for axis d.
1980    ///
1981    /// Efficient O(n * k) construction: builds the raw (n × k) kernel derivative
1982    /// matrix directly, then projects through identifiability transforms.
1983    /// This is used when the dense matrix is needed temporarily (e.g., for
1984    /// HyperCoord construction) while avoiding simultaneous storage of all D axes.
1985    pub fn materialize_first(&self, axis: usize) -> Result<Array2<f64>, BasisError> {
1986        assert!(
1987            axis < self.n_axes(),
1988            "implicit psi first materialization axis out of bounds: axis={axis}, n_axes={}",
1989            self.n_axes()
1990        );
1991        if self.enforces_dense_materialization_budget() {
1992            assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
1993        }
1994        if self.axis_combinations.is_some() {
1995            let combo = self.transformed_axis_combination(axis);
1996            let combo_sum = Self::transformed_combo_sum(combo);
1997            if self.is_streaming() {
1998                let c = self.psi_scale_share;
1999                return self.streaming_materialize(|phi, q, _, sb| {
2000                    let s_combo = combo
2001                        .iter()
2002                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2003                        .sum();
2004                    Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
2005                });
2006            }
2007            let n = self.n;
2008            let k = self.n_knots;
2009            let c = self.psi_scale_share;
2010            let mut raw = Array2::<f64>::zeros((n, k));
2011            for i in 0..n {
2012                let base = i * k;
2013                for j in 0..k {
2014                    let idx = base + j;
2015                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2016                    raw[[i, j]] = Self::transformed_first_kernel_value(
2017                        self.phi_values[idx],
2018                        self.q_values[idx],
2019                        s_combo,
2020                        combo_sum,
2021                        c,
2022                    );
2023                }
2024            }
2025            return Ok(self.project_matrix(raw));
2026        }
2027        if self.is_streaming() {
2028            let c = self.psi_scale_share;
2029            return self.streaming_materialize(|phi, q, _, sb| q * sb[axis] + c * phi);
2030        }
2031        let n = self.n;
2032        let k = self.n_knots;
2033        let c = self.psi_scale_share;
2034        let mut raw = Array2::<f64>::zeros((n, k));
2035        for i in 0..n {
2036            let base = i * k;
2037            for j in 0..k {
2038                raw[[i, j]] = self.q_values[base + j] * self.axis_components[[base + j, axis]]
2039                    + c * self.phi_values[base + j];
2040            }
2041        }
2042        Ok(self.project_matrix(raw))
2043    }
2044
2045    /// Materialize the full (n × p_out) second diagonal derivative matrix for axis d.
2046    pub fn materialize_second_diag(&self, axis: usize) -> Result<Array2<f64>, BasisError> {
2047        assert!(
2048            axis < self.n_axes(),
2049            "implicit psi second diagonal materialization axis out of bounds: axis={axis}, n_axes={}",
2050            self.n_axes()
2051        );
2052        if self.enforces_dense_materialization_budget() {
2053            assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2054        }
2055        if self.axis_combinations.is_some() {
2056            let combo = self.transformed_axis_combination(axis);
2057            let combo_sum = Self::transformed_combo_sum(combo);
2058            if self.is_streaming() {
2059                let c = self.psi_scale_share;
2060                return self.streaming_materialize(|phi, q, t, sb| {
2061                    let s_combo = combo
2062                        .iter()
2063                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2064                        .sum();
2065                    let overlap_s = Self::transformed_combo_overlap_streaming(combo, combo, sb);
2066                    Self::transformed_second_kernel_value(
2067                        phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap_s, c,
2068                    )
2069                });
2070            }
2071            let n = self.n;
2072            let k = self.n_knots;
2073            let c = self.psi_scale_share;
2074            let mut raw = Array2::<f64>::zeros((n, k));
2075            for i in 0..n {
2076                let base = i * k;
2077                for j in 0..k {
2078                    let idx = base + j;
2079                    let s_combo = self.transformed_combo_axis_value_materialized(idx, combo);
2080                    let overlap_s = self.transformed_combo_overlap_materialized(idx, combo, combo);
2081                    raw[[i, j]] = Self::transformed_second_kernel_value(
2082                        self.phi_values[idx],
2083                        self.q_values[idx],
2084                        self.t_values[idx],
2085                        s_combo,
2086                        combo_sum,
2087                        s_combo,
2088                        combo_sum,
2089                        overlap_s,
2090                        c,
2091                    );
2092                }
2093            }
2094            return Ok(self.project_matrix(raw));
2095        }
2096        if self.is_streaming() {
2097            let c = self.psi_scale_share;
2098            return self.streaming_materialize(|phi, q, t, sb| {
2099                let s = sb[axis];
2100                2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
2101            });
2102        }
2103        let n = self.n;
2104        let k = self.n_knots;
2105        let c = self.psi_scale_share;
2106        let mut raw = Array2::<f64>::zeros((n, k));
2107        for i in 0..n {
2108            let base = i * k;
2109            for j in 0..k {
2110                let s = self.axis_components[[base + j, axis]];
2111                raw[[i, j]] = 2.0 * self.q_values[base + j] * s
2112                    + self.t_values[base + j] * s * s
2113                    + 2.0 * c * self.q_values[base + j] * s
2114                    + c * c * self.phi_values[base + j];
2115            }
2116        }
2117        Ok(self.project_matrix(raw))
2118    }
2119
2120    /// Materialize the full (n × p_out) cross second derivative matrix for axes (d, e).
2121    ///
2122    /// Dense materialization of the t · s_d · s_e cross coupling.
2123    pub fn materialize_second_cross(
2124        &self,
2125        axis_d: usize,
2126        axis_e: usize,
2127    ) -> Result<Array2<f64>, BasisError> {
2128        assert!(
2129            axis_d < self.n_axes(),
2130            "implicit psi second cross materialization first axis out of bounds: axis_d={axis_d}, n_axes={}",
2131            self.n_axes()
2132        );
2133        assert!(
2134            axis_e < self.n_axes(),
2135            "implicit psi second cross materialization second axis out of bounds: axis_e={axis_e}, n_axes={}",
2136            self.n_axes()
2137        );
2138        assert_ne!(
2139            axis_d, axis_e,
2140            "implicit psi second cross materialization requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
2141        );
2142        if self.enforces_dense_materialization_budget() {
2143            assert_no_dense_derivative_materialization(self.n, self.p_out(), self.n_axes());
2144        }
2145        if self.axis_combinations.is_some() {
2146            let combo_d = self.transformed_axis_combination(axis_d);
2147            let combo_e = self.transformed_axis_combination(axis_e);
2148            let sum_d = Self::transformed_combo_sum(combo_d);
2149            let sum_e = Self::transformed_combo_sum(combo_e);
2150            if self.is_streaming() {
2151                let c = self.psi_scale_share;
2152                return self.streaming_materialize(|phi, q, t, sb| {
2153                    let s_d = combo_d
2154                        .iter()
2155                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2156                        .sum();
2157                    let s_e = combo_e
2158                        .iter()
2159                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2160                        .sum();
2161                    let overlap_s = Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb);
2162                    Self::transformed_second_kernel_value(
2163                        phi, q, t, s_d, sum_d, s_e, sum_e, overlap_s, c,
2164                    )
2165                });
2166            }
2167            let n = self.n;
2168            let k = self.n_knots;
2169            let c = self.psi_scale_share;
2170            let mut raw = Array2::<f64>::zeros((n, k));
2171            for i in 0..n {
2172                let base = i * k;
2173                for j in 0..k {
2174                    let idx = base + j;
2175                    let s_d = self.transformed_combo_axis_value_materialized(idx, combo_d);
2176                    let s_e = self.transformed_combo_axis_value_materialized(idx, combo_e);
2177                    let overlap_s =
2178                        self.transformed_combo_overlap_materialized(idx, combo_d, combo_e);
2179                    raw[[i, j]] = Self::transformed_second_kernel_value(
2180                        self.phi_values[idx],
2181                        self.q_values[idx],
2182                        self.t_values[idx],
2183                        s_d,
2184                        sum_d,
2185                        s_e,
2186                        sum_e,
2187                        overlap_s,
2188                        c,
2189                    );
2190                }
2191            }
2192            return Ok(self.project_matrix(raw));
2193        }
2194        if self.is_streaming() {
2195            let c = self.psi_scale_share;
2196            return self.streaming_materialize(|phi, q, t, sb| {
2197                t * sb[axis_d] * sb[axis_e] + c * q * (sb[axis_d] + sb[axis_e]) + c * c * phi
2198            });
2199        }
2200        let n = self.n;
2201        let k = self.n_knots;
2202        let c = self.psi_scale_share;
2203        let mut raw = Array2::<f64>::zeros((n, k));
2204        for i in 0..n {
2205            let base = i * k;
2206            for j in 0..k {
2207                raw[[i, j]] = self.t_values[base + j]
2208                    * self.axis_components[[base + j, axis_d]]
2209                    * self.axis_components[[base + j, axis_e]]
2210                    + c * self.q_values[base + j]
2211                        * (self.axis_components[[base + j, axis_d]]
2212                            + self.axis_components[[base + j, axis_e]])
2213                    + c * c * self.phi_values[base + j];
2214            }
2215        }
2216        Ok(self.project_matrix(raw))
2217    }
2218
2219    /// Project a raw (n × k) kernel-space matrix through all transforms to
2220    /// produce an (n × p_out) matrix: Z_kernel → pad poly → full ident.
2221    pub(crate) fn project_matrix(&self, raw: Array2<f64>) -> Array2<f64> {
2222        // Step 1: kernel constraint projection.
2223        let constrained = match &self.ident_transform {
2224            Some(z) => fast_ab(&raw, z),
2225            None => raw,
2226        };
2227
2228        // Step 2: polynomial padding.
2229        let padded = if self.n_poly > 0 {
2230            let cols = constrained.ncols();
2231            let mut out = Array2::<f64>::zeros((self.n, cols + self.n_poly));
2232            out.slice_mut(s![.., ..cols]).assign(&constrained);
2233            out
2234        } else {
2235            constrained
2236        };
2237
2238        // Step 3: full identifiability transform.
2239        match &self.full_ident_transform {
2240            Some(zf) => fast_ab(&padded, zf),
2241            None => padded,
2242        }
2243    }
2244
2245    pub(crate) fn project_matrix_rows(&self, raw: Array2<f64>) -> Array2<f64> {
2246        let nrows = raw.nrows();
2247        let constrained = match &self.ident_transform {
2248            Some(z) => fast_ab(&raw, z),
2249            None => raw,
2250        };
2251        let padded = if self.n_poly > 0 {
2252            let cols = constrained.ncols();
2253            let mut out = Array2::<f64>::zeros((nrows, cols + self.n_poly));
2254            out.slice_mut(s![.., ..cols]).assign(&constrained);
2255            out
2256        } else {
2257            constrained
2258        };
2259        match &self.full_ident_transform {
2260            Some(zf) => fast_ab(&padded, zf),
2261            None => padded,
2262        }
2263    }
2264
2265    pub(crate) fn row_chunk_with_kernel<G>(
2266        &self,
2267        rows: std::ops::Range<usize>,
2268        deriv_fn: G,
2269    ) -> Result<Array2<f64>, BasisError>
2270    where
2271        G: Fn(f64, f64, f64, &[f64], usize) -> f64,
2272    {
2273        let raw = self.row_chunk_with_kernel_raw(rows, deriv_fn)?;
2274        Ok(self.project_matrix_rows(raw))
2275    }
2276
2277    /// Like `row_chunk_with_kernel` but returns the raw (chunk × n_knots)
2278    /// kernel scalars without the identifiability/padding projection. Used
2279    /// by `forward_mul_matrix`, which does the projection on the rank side
2280    /// instead (`unproject_matrix(F)`) so the (n × p_out) projected
2281    /// derivative is never materialized for large-scale row counts.
2282    pub(crate) fn row_chunk_with_kernel_raw<G>(
2283        &self,
2284        rows: std::ops::Range<usize>,
2285        deriv_fn: G,
2286    ) -> Result<Array2<f64>, BasisError>
2287    where
2288        G: Fn(f64, f64, f64, &[f64], usize) -> f64,
2289    {
2290        let mut raw = Array2::<f64>::zeros((rows.end - rows.start, self.n_knots));
2291        if let Some(st) = self.streaming.as_ref() {
2292            let mut sb = vec![0.0; self.n_axes];
2293            if let Some(cache) = st.ensure_triplet_cache() {
2294                for (local, i) in rows.enumerate() {
2295                    let base = i * self.n_knots;
2296                    for j in 0..self.n_knots {
2297                        let idx = base + j;
2298                        st.fill_s_buf(i, j, &mut sb);
2299                        raw[[local, j]] =
2300                            deriv_fn(cache.phi[idx], cache.q[idx], cache.t[idx], &sb, idx);
2301                    }
2302                }
2303            } else {
2304                for (local, i) in rows.enumerate() {
2305                    for j in 0..self.n_knots {
2306                        let (phi, q, t) = st.compute_pair(i, j, &mut sb)?;
2307                        raw[[local, j]] = deriv_fn(phi, q, t, &sb, i * self.n_knots + j);
2308                    }
2309                }
2310            }
2311        } else {
2312            for (local, i) in rows.enumerate() {
2313                let base = i * self.n_knots;
2314                for j in 0..self.n_knots {
2315                    let idx = base + j;
2316                    raw[[local, j]] = deriv_fn(
2317                        self.phi_values[idx],
2318                        self.q_values[idx],
2319                        self.t_values[idx],
2320                        &[],
2321                        idx,
2322                    );
2323                }
2324            }
2325        }
2326        Ok(raw)
2327    }
2328
2329    pub fn row_chunk_first(
2330        &self,
2331        axis: usize,
2332        rows: std::ops::Range<usize>,
2333    ) -> Result<Array2<f64>, BasisError> {
2334        assert!(
2335            axis < self.n_axes(),
2336            "implicit psi first row chunk axis out of bounds: axis={axis}, n_axes={}",
2337            self.n_axes()
2338        );
2339        let c = self.psi_scale_share;
2340        if self.axis_combinations.is_some() {
2341            let combo = self.transformed_axis_combination(axis);
2342            let combo_sum = Self::transformed_combo_sum(combo);
2343            return self.row_chunk_with_kernel(rows, |phi, q, _, sb, idx| {
2344                let s_combo = if sb.is_empty() {
2345                    self.transformed_combo_axis_value_materialized(idx, combo)
2346                } else {
2347                    combo
2348                        .iter()
2349                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2350                        .sum()
2351                };
2352                Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
2353            });
2354        }
2355        self.row_chunk_with_kernel(rows, |phi, q, _, sb, idx| {
2356            let s = if sb.is_empty() {
2357                self.axis_components[[idx, axis]]
2358            } else {
2359                sb[axis]
2360            };
2361            q * s + c * phi
2362        })
2363    }
2364
2365    /// Raw (chunk × n_knots) first-order kernel scalars for axis d, without
2366    /// the identifiability/padding projection. Pairs with `unproject_matrix`
2367    /// in `forward_mul_matrix`: the kernel scalars stay in raw knot space
2368    /// while the rank side (F) is unprojected to knot space, so the per-chunk
2369    /// GEMM is (chunk × n_knots) · (n_knots × rank) rather than (chunk × p_out)
2370    /// · (p_out × rank). Saves both flops and a (chunk × p_out) intermediate.
2371    pub fn row_chunk_first_raw(
2372        &self,
2373        axis: usize,
2374        rows: std::ops::Range<usize>,
2375    ) -> Result<Array2<f64>, BasisError> {
2376        assert!(
2377            axis < self.n_axes(),
2378            "implicit psi first raw row chunk axis out of bounds: axis={axis}, n_axes={}",
2379            self.n_axes()
2380        );
2381        let c = self.psi_scale_share;
2382        if self.axis_combinations.is_some() {
2383            let combo = self.transformed_axis_combination(axis);
2384            let combo_sum = Self::transformed_combo_sum(combo);
2385            return self.row_chunk_with_kernel_raw(rows, |phi, q, _, sb, idx| {
2386                let s_combo = if sb.is_empty() {
2387                    self.transformed_combo_axis_value_materialized(idx, combo)
2388                } else {
2389                    combo
2390                        .iter()
2391                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2392                        .sum()
2393                };
2394                Self::transformed_first_kernel_value(phi, q, s_combo, combo_sum, c)
2395            });
2396        }
2397        self.row_chunk_with_kernel_raw(rows, |phi, q, _, sb, idx| {
2398            let s = if sb.is_empty() {
2399                self.axis_components[[idx, axis]]
2400            } else {
2401                sb[axis]
2402            };
2403            q * s + c * phi
2404        })
2405    }
2406
2407    pub fn row_chunk_second_diag(
2408        &self,
2409        axis: usize,
2410        rows: std::ops::Range<usize>,
2411    ) -> Result<Array2<f64>, BasisError> {
2412        assert!(
2413            axis < self.n_axes(),
2414            "implicit psi second diagonal row chunk axis out of bounds: axis={axis}, n_axes={}",
2415            self.n_axes()
2416        );
2417        let c = self.psi_scale_share;
2418        if self.axis_combinations.is_some() {
2419            let combo = self.transformed_axis_combination(axis);
2420            let combo_sum = Self::transformed_combo_sum(combo);
2421            return self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2422                let s_combo = if sb.is_empty() {
2423                    self.transformed_combo_axis_value_materialized(idx, combo)
2424                } else {
2425                    combo
2426                        .iter()
2427                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2428                        .sum()
2429                };
2430                let overlap = if sb.is_empty() {
2431                    self.transformed_combo_overlap_materialized(idx, combo, combo)
2432                } else {
2433                    Self::transformed_combo_overlap_streaming(combo, combo, sb)
2434                };
2435                Self::transformed_second_kernel_value(
2436                    phi, q, t, s_combo, combo_sum, s_combo, combo_sum, overlap, c,
2437                )
2438            });
2439        }
2440        self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2441            let s = if sb.is_empty() {
2442                self.axis_components[[idx, axis]]
2443            } else {
2444                sb[axis]
2445            };
2446            2.0 * q * s + t * s * s + 2.0 * c * q * s + c * c * phi
2447        })
2448    }
2449
2450    pub fn row_chunk_second_cross(
2451        &self,
2452        axis_d: usize,
2453        axis_e: usize,
2454        rows: std::ops::Range<usize>,
2455    ) -> Result<Array2<f64>, BasisError> {
2456        assert!(
2457            axis_d < self.n_axes(),
2458            "implicit psi second cross row chunk first axis out of bounds: axis_d={axis_d}, n_axes={}",
2459            self.n_axes()
2460        );
2461        assert!(
2462            axis_e < self.n_axes(),
2463            "implicit psi second cross row chunk second axis out of bounds: axis_e={axis_e}, n_axes={}",
2464            self.n_axes()
2465        );
2466        assert_ne!(
2467            axis_d, axis_e,
2468            "implicit psi second cross row chunk requires distinct axes: axis_d={axis_d}, axis_e={axis_e}"
2469        );
2470        let c = self.psi_scale_share;
2471        if self.axis_combinations.is_some() {
2472            let combo_d = self.transformed_axis_combination(axis_d);
2473            let combo_e = self.transformed_axis_combination(axis_e);
2474            let sum_d = Self::transformed_combo_sum(combo_d);
2475            let sum_e = Self::transformed_combo_sum(combo_e);
2476            return self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2477                let s_d = if sb.is_empty() {
2478                    self.transformed_combo_axis_value_materialized(idx, combo_d)
2479                } else {
2480                    combo_d
2481                        .iter()
2482                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2483                        .sum()
2484                };
2485                let s_e = if sb.is_empty() {
2486                    self.transformed_combo_axis_value_materialized(idx, combo_e)
2487                } else {
2488                    combo_e
2489                        .iter()
2490                        .map(|(raw_axis, coeff)| coeff * sb[*raw_axis])
2491                        .sum()
2492                };
2493                let overlap = if sb.is_empty() {
2494                    self.transformed_combo_overlap_materialized(idx, combo_d, combo_e)
2495                } else {
2496                    Self::transformed_combo_overlap_streaming(combo_d, combo_e, sb)
2497                };
2498                Self::transformed_second_kernel_value(phi, q, t, s_d, sum_d, s_e, sum_e, overlap, c)
2499            });
2500        }
2501        self.row_chunk_with_kernel(rows, |phi, q, t, sb, idx| {
2502            let sd = if sb.is_empty() {
2503                self.axis_components[[idx, axis_d]]
2504            } else {
2505                sb[axis_d]
2506            };
2507            let se = if sb.is_empty() {
2508                self.axis_components[[idx, axis_e]]
2509            } else {
2510                sb[axis_e]
2511            };
2512            t * sd * se + c * q * (sd + se) + c * c * phi
2513        })
2514    }
2515
2516    /// Single-row specialization of `row_chunk_first(axis, row..row+1)` that
2517    /// writes the length-`p_out` row directly into the caller-provided buffer.
2518    ///
2519    /// This is the row-local API used by `CustomFamilyPsiLinearMapRef::row_vector`
2520    /// for survival rowwise exact-Hessian paths, which previously applied a
2521    /// unit-vector `transpose_mul` trick (O(n·K) per row) to recover a single
2522    /// row. Avoids allocating a temporary (1 × p_out) matrix per row call.
2523    pub fn row_vector_first_into(
2524        &self,
2525        axis: usize,
2526        row: usize,
2527        mut out: ArrayViewMut1<'_, f64>,
2528    ) -> Result<(), BasisError> {
2529        assert!(
2530            row < self.n,
2531            "implicit psi row-vector request out of bounds: row={row}, n={}",
2532            self.n
2533        );
2534        assert_eq!(
2535            out.len(),
2536            self.p_out(),
2537            "implicit psi row-vector output length mismatch"
2538        );
2539        let chunk = self.row_chunk_first(axis, row..row + 1)?;
2540        out.assign(&chunk.row(0));
2541        Ok(())
2542    }
2543
2544    pub(crate) fn transformed_axis_combination(&self, axis: usize) -> &[(usize, f64)] {
2545        self.axis_combinations
2546            .as_ref()
2547            .expect("transformed axis combinations")
2548            .get(axis)
2549            .map(Vec::as_slice)
2550            .expect("transformed axis index")
2551    }
2552
2553    #[inline]
2554    pub(crate) fn transformed_combo_sum(combo: &[(usize, f64)]) -> f64 {
2555        combo.iter().map(|(_, coeff)| *coeff).sum()
2556    }
2557
2558    #[inline]
2559    pub(crate) fn transformed_combo_axis_value_materialized(
2560        &self,
2561        idx: usize,
2562        combo: &[(usize, f64)],
2563    ) -> f64 {
2564        combo
2565            .iter()
2566            .map(|(raw_axis, coeff)| coeff * self.axis_components[[idx, *raw_axis]])
2567            .sum()
2568    }
2569
2570    #[inline]
2571    pub(crate) fn transformed_combo_overlap_streaming(
2572        combo_left: &[(usize, f64)],
2573        combo_right: &[(usize, f64)],
2574        sb: &[f64],
2575    ) -> f64 {
2576        let mut overlap = 0.0;
2577        for &(left_axis, left_coeff) in combo_left {
2578            for &(right_axis, right_coeff) in combo_right {
2579                if left_axis == right_axis {
2580                    overlap += left_coeff * right_coeff * sb[left_axis];
2581                }
2582            }
2583        }
2584        overlap
2585    }
2586
2587    #[inline]
2588    pub(crate) fn transformed_combo_overlap_materialized(
2589        &self,
2590        idx: usize,
2591        combo_left: &[(usize, f64)],
2592        combo_right: &[(usize, f64)],
2593    ) -> f64 {
2594        let mut overlap = 0.0;
2595        for &(left_axis, left_coeff) in combo_left {
2596            for &(right_axis, right_coeff) in combo_right {
2597                if left_axis == right_axis {
2598                    overlap += left_coeff * right_coeff * self.axis_components[[idx, left_axis]];
2599                }
2600            }
2601        }
2602        overlap
2603    }
2604
2605    #[inline]
2606    pub(crate) fn transformed_first_kernel_value(
2607        phi: f64,
2608        q: f64,
2609        s_combo: f64,
2610        coeff_sum: f64,
2611        psi_scale_share: f64,
2612    ) -> f64 {
2613        q * s_combo + psi_scale_share * coeff_sum * phi
2614    }
2615
2616    #[inline]
2617    pub(crate) fn transformed_second_kernel_value(
2618        phi: f64,
2619        q: f64,
2620        t: f64,
2621        s_left: f64,
2622        left_sum: f64,
2623        s_right: f64,
2624        right_sum: f64,
2625        overlap_s: f64,
2626        psi_scale_share: f64,
2627    ) -> f64 {
2628        t * s_left * s_right
2629            + 2.0 * q * overlap_s
2630            + psi_scale_share * q * (right_sum * s_left + left_sum * s_right)
2631            + psi_scale_share * psi_scale_share * left_sum * right_sum * phi
2632    }
2633}
2634
2635pub(crate) fn build_aniso_design_psi_derivatives_shared(
2636    data: ArrayView2<'_, f64>,
2637    centers: ArrayView2<'_, f64>,
2638    eta: &[f64],
2639    p_final: usize,
2640    ident_transform: Option<Array2<f64>>,
2641    full_ident_transform: Option<Array2<f64>>,
2642    n_poly: usize,
2643    radial_kind: RadialScalarKind,
2644) -> Result<AnisoBasisPsiDerivatives, BasisError> {
2645    let n = data.nrows();
2646    let k = centers.nrows();
2647    let dim = data.ncols();
2648    if eta.len() != dim {
2649        crate::bail_dim_basis!(
2650            "aniso design derivatives: eta.len()={} != data dimension {dim}",
2651            eta.len()
2652        );
2653    }
2654
2655    let policy = gam_runtime::resource::ResourcePolicy::default_library();
2656    let force_operator = radial_kind.is_duchon_family();
2657    let dense_derivatives_exceed_budget =
2658        should_use_implicit_operators_with_policy(n, p_final, dim, &policy);
2659    let operator_only = force_operator || dense_derivatives_exceed_budget;
2660    let cache_radial_components = should_cache_implicit_radial_components(n, k, dim, &policy);
2661    // gam#1376 — the per-axis ψ derivatives this operator produces are ALREADY
2662    // the derivatives w.r.t. the κ-optimizer's raw coordinate, so NO cross-axis
2663    // centering projection is installed (for any family). The optimizer's per-
2664    // axis coordinate `psi_a` is decoded into both the global length scale
2665    // `ℓ = exp(−mean(psi))` and the centered contrast `eta_a = psi_a − mean(psi)`
2666    // simultaneously; in the kernel argument `x² = r²/ℓ² = Σ_a exp(2·psi_a)·h_a²`
2667    // the `mean(psi)` cancels, so the effective per-axis exponent is the raw
2668    // `psi_a` and `∂φ/∂psi_a = q·s_a` is the native per-axis ψ derivative. The
2669    // earlier `with_raw_eta_centering` projection annihilated the all-ones
2670    // (global-scale) direction and broke the analytic↔FD match (rel≈0.85). The
2671    // dense path (`build_matern_basis_log_kappa_aniso_derivatives`) is corrected
2672    // identically — it no longer centers downstream.
2673
2674    // ── Streaming path: large scale ─────────────────────────────────────
2675    // When even the compact radial cache would exceed the operator-cache
2676    // budget, store only data/centers/eta/radial_kind and recompute
2677    // (q, t, s_a) chunkwise during each matvec. Otherwise the operator-only
2678    // path below caches phi/q/t/s_a without materializing dense derivative
2679    // matrices.
2680    if operator_only && !cache_radial_components {
2681        let op = ImplicitDesignPsiDerivative::new_streaming(
2682            shared_owned_data_matrix_from_view(data),
2683            shared_owned_centers_matrix_from_view(centers),
2684            eta.to_vec(),
2685            radial_kind,
2686            ident_transform,
2687            full_ident_transform,
2688            n_poly,
2689        );
2690        return Ok(AnisoBasisPsiDerivatives {
2691            design_first: Vec::new(),
2692            design_second_diag: Vec::new(),
2693            design_second_cross: Vec::new(),
2694            design_second_cross_pairs: Vec::new(),
2695            penalties_first: vec![Vec::new(); dim],
2696            penalties_second_diag: vec![Vec::new(); dim],
2697            penalties_cross_pairs: Vec::new(),
2698            penalties_cross_provider: None,
2699            implicit_operator: Some(op),
2700        });
2701    }
2702
2703    // ── Materialized radial-cache path ────────────────────────────────────
2704    // Allocate O(n*k) arrays up front and fill with parallel chunks that
2705    // write directly into preallocated storage via raw pointers. No
2706    // intermediate Vec<(i, q_row, t_row, s_row)> collection.
2707    let nk = n.checked_mul(k).ok_or_else(|| {
2708        BasisError::InvalidInput("aniso radial cache has too many data-center pairs".to_string())
2709    })?;
2710    if nk.checked_mul(dim).is_none() {
2711        crate::bail_invalid_basis!("aniso radial cache axis component storage is too large");
2712    }
2713    let mut phi_values = Array1::<f64>::zeros(nk);
2714    let mut q_values = Array1::<f64>::zeros(nk);
2715    let mut t_values = Array1::<f64>::zeros(nk);
2716    let mut axis_components = Array2::<f64>::zeros((nk, dim));
2717
2718    let psi_scale_share = radial_kind.raw_psi_isotropic_share();
2719
2720    let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
2721    let nc = n.div_ceil(cs);
2722    // Capture the *first* underlying radial-evaluation error rather than a
2723    // bare boolean: at an extreme trial hyperparameter the anisotropic
2724    // distance `r` can push the Duchon/Matérn radial kernel out of its
2725    // evaluable range, and the caller (the spatial-κ optimizer) needs the
2726    // real cause to decide whether the trial point is merely infeasible
2727    // (retreat) versus a genuine invariant violation (abort). Swallowing it
2728    // as "radial scalar evaluation failed" hid both the cause and the
2729    // recoverability.
2730    let first_err: std::sync::Mutex<Option<BasisError>> = std::sync::Mutex::new(None);
2731    // For large sweeps, replace per-pair exact radial evaluation with a
2732    // certified 1-D Chebyshev profile built once from a distance-only
2733    // pre-pass over the radius range (see `radial_profile`): at the 16-D
2734    // power-9 hybrid Duchon configuration a single exact triplet costs tens
2735    // of microseconds across its partial-fraction blocks, and this n·k
2736    // sweep was the dominant per-κ-trial cost of large-scale fits (#979).
2737    // Out-of-range radii and uncertified builds fall back to the exact
2738    // evaluator per pair.
2739    let profile = if nk >= RADIAL_PROFILE_MIN_PAIRS {
2740        let mut r_lo = f64::INFINITY;
2741        let mut r_hi = 0.0_f64;
2742        let mut drb = vec![0.0; dim];
2743        let mut cb = vec![0.0; dim];
2744        for i in 0..n {
2745            for a in 0..dim {
2746                drb[a] = data[[i, a]];
2747            }
2748            for j in 0..k {
2749                for a in 0..dim {
2750                    cb[a] = centers[[j, a]];
2751                }
2752                let (r, _) = aniso_distance_and_components(&drb, &cb, eta);
2753                if r > 0.0 {
2754                    r_lo = r_lo.min(r);
2755                    r_hi = r_hi.max(r);
2756                }
2757            }
2758        }
2759        if r_lo.is_finite() && r_hi > r_lo {
2760            radial_profile::RadialProfile::build(&radial_kind, r_lo, r_hi)
2761        } else {
2762            None
2763        }
2764    } else {
2765        None
2766    };
2767    {
2768        let pp = SendPtr(phi_values.as_mut_ptr());
2769        let qp = SendPtr(q_values.as_mut_ptr());
2770        let tp = SendPtr(t_values.as_mut_ptr());
2771        let ap = SendPtr(axis_components.as_mut_ptr());
2772        let ferr = &first_err;
2773        let profile_ref = profile.as_ref();
2774        (0..nc).into_par_iter().for_each(move |ci| {
2775            let start = ci * cs;
2776            let end = start.saturating_add(cs).min(n);
2777            let mut drb = vec![0.0; dim];
2778            let mut cb = vec![0.0; dim];
2779            for i in start..end {
2780                for a in 0..dim {
2781                    drb[a] = data[[i, a]];
2782                }
2783                for j in 0..k {
2784                    for a in 0..dim {
2785                        cb[a] = centers[[j, a]];
2786                    }
2787                    let (r, sv) = aniso_distance_and_components(&drb, &cb, eta);
2788                    let triplet = match profile_ref {
2789                        Some(profile) => profile.eval_or_exact(&radial_kind, r),
2790                        None => radial_kind.eval_design_triplet(r),
2791                    };
2792                    let (phi, q, t) = match triplet {
2793                        Ok(p) => p,
2794                        Err(e) => {
2795                            let mut slot = ferr.lock().unwrap_or_else(|p| p.into_inner());
2796                            if slot.is_none() {
2797                                *slot = Some(e);
2798                            }
2799                            return;
2800                        }
2801                    };
2802                    let flat = i * k + j;
2803                    // SAFETY: each Rayon chunk owns a disjoint i-row range,
2804                    // so flat=i*k+j stays in 0..nk for phi/q/t and
2805                    // flat*dim+a stays in 0..nk*dim for axis_components.
2806                    unsafe {
2807                        *pp.add(flat) = phi;
2808                        *qp.add(flat) = q;
2809                        *tp.add(flat) = t;
2810                        for a in 0..dim {
2811                            *ap.add(flat * dim + a) = sv[a];
2812                        }
2813                    }
2814                }
2815            }
2816        });
2817    }
2818    if let Some(cause) = first_err.into_inner().unwrap_or_else(|p| p.into_inner()) {
2819        return Err(BasisError::InvalidInput(format!(
2820            "radial scalar evaluation failed during aniso derivative construction \
2821             (eta={eta:?}): {cause}"
2822        )));
2823    }
2824
2825    let op = ImplicitDesignPsiDerivative::new(
2826        phi_values,
2827        q_values,
2828        t_values,
2829        axis_components,
2830        ident_transform,
2831        full_ident_transform,
2832        n,
2833        k,
2834        n_poly,
2835        dim,
2836    )
2837    .with_psi_scale_share(psi_scale_share);
2838
2839    // gam#1376 — the operator stays in the NATIVE per-axis ψ frame (no
2840    // `with_raw_eta_centering`): the κ-optimizer coordinate `psi_a` already maps
2841    // to the effective per-axis exponent `psi_a` of the kernel argument (the
2842    // `mean(psi)` it injects into the centered contrast is exactly cancelled by
2843    // the `ℓ = exp(−mean(psi))` it injects into the length scale), so the native
2844    // `∂φ/∂psi_a` produced by `materialize_first`/`materialize_second_*` (and by
2845    // the operator matvecs) is the correct raw-coordinate derivative. The
2846    // earlier centering broke the analytic↔FD match — see the comment above.
2847
2848    if operator_only {
2849        return Ok(AnisoBasisPsiDerivatives {
2850            design_first: Vec::new(),
2851            design_second_diag: Vec::new(),
2852            design_second_cross: Vec::new(),
2853            design_second_cross_pairs: Vec::new(),
2854            penalties_first: vec![Vec::new(); dim],
2855            penalties_second_diag: vec![Vec::new(); dim],
2856            penalties_cross_pairs: Vec::new(),
2857            penalties_cross_provider: None,
2858            implicit_operator: Some(op),
2859        });
2860    }
2861
2862    let design_first = (0..dim)
2863        .map(|a| op.materialize_first(a))
2864        .collect::<Result<Vec<_>, _>>()?;
2865    let design_second_diag = (0..dim)
2866        .map(|a| op.materialize_second_diag(a))
2867        .collect::<Result<Vec<_>, _>>()?;
2868
2869    Ok(AnisoBasisPsiDerivatives {
2870        design_first,
2871        design_second_diag,
2872        design_second_cross: Vec::new(),
2873        design_second_cross_pairs: Vec::new(),
2874        penalties_first: vec![Vec::new(); dim],
2875        penalties_second_diag: vec![Vec::new(); dim],
2876        penalties_cross_pairs: Vec::new(),
2877        penalties_cross_provider: None,
2878        implicit_operator: Some(op),
2879    })
2880}
2881
2882#[derive(Debug, Clone)]
2883pub(crate) struct ScalarDesignPsiDerivatives {
2884    pub(crate) design_first: Array2<f64>,
2885    pub(crate) design_second_diag: Array2<f64>,
2886    pub(crate) implicit_operator: Option<ImplicitDesignPsiDerivative>,
2887}
2888
2889pub(crate) fn build_scalar_design_psi_derivatives_shared(
2890    data: ArrayView2<'_, f64>,
2891    centers: ArrayView2<'_, f64>,
2892    fixed_eta: Option<&[f64]>,
2893    p_final: usize,
2894    ident_transform: Option<Array2<f64>>,
2895    full_ident_transform: Option<Array2<f64>>,
2896    n_poly: usize,
2897    radial_kind: RadialScalarKind,
2898    psi_scale_share: f64,
2899) -> Result<ScalarDesignPsiDerivatives, BasisError> {
2900    let n = data.nrows();
2901    let k = centers.nrows();
2902    let dim = data.ncols();
2903    if let Some(eta) = fixed_eta
2904        && eta.len() != dim
2905    {
2906        crate::bail_dim_basis!(
2907            "scalar design derivatives: eta.len()={} != data dimension {dim}",
2908            eta.len()
2909        );
2910    }
2911
2912    let policy = gam_runtime::resource::ResourcePolicy::default_library();
2913    let force_operator = radial_kind.is_duchon_family();
2914    let dense_derivatives_exceed_budget =
2915        should_use_implicit_operators_with_policy(n, p_final, 1, &policy);
2916    let operator_only = force_operator || dense_derivatives_exceed_budget;
2917    let cache_radial_components = should_cache_implicit_radial_components(n, k, 1, &policy);
2918    if operator_only && !cache_radial_components {
2919        let metric_eta = fixed_eta
2920            .map(|eta| eta.to_vec())
2921            .unwrap_or_else(|| vec![0.0; dim]);
2922        let op = ImplicitDesignPsiDerivative::new_streaming_scalar(
2923            shared_owned_data_matrix_from_view(data),
2924            shared_owned_centers_matrix_from_view(centers),
2925            metric_eta,
2926            radial_kind,
2927            ident_transform,
2928            full_ident_transform,
2929            n_poly,
2930        )
2931        .with_psi_scale_share(psi_scale_share);
2932        return Ok(ScalarDesignPsiDerivatives {
2933            design_first: Array2::<f64>::zeros((0, 0)),
2934            design_second_diag: Array2::<f64>::zeros((0, 0)),
2935            implicit_operator: Some(op),
2936        });
2937    }
2938
2939    let nk = n.checked_mul(k).ok_or_else(|| {
2940        BasisError::InvalidInput("scalar radial cache has too many data-center pairs".to_string())
2941    })?;
2942    let mut phi_values = Array1::<f64>::zeros(nk);
2943    let mut q_values = Array1::<f64>::zeros(nk);
2944    let mut t_values = Array1::<f64>::zeros(nk);
2945    let mut axis_components = Array2::<f64>::zeros((nk, 1));
2946
2947    let cs = IMPLICIT_MATVEC_CHUNK_SIZE;
2948    let nc = n.div_ceil(cs);
2949    let first_err: std::sync::Mutex<Option<BasisError>> = std::sync::Mutex::new(None);
2950    // Same certified radial-profile amortization as the per-axis sweep
2951    // above: one distance-only pre-pass for the radius range, one profile
2952    // build, Clenshaw per pair, exact fallback out of range (#979).
2953    let pair_r = |i: usize, j: usize, drb: &mut [f64], cb: &mut [f64]| -> f64 {
2954        if let Some(eta) = fixed_eta {
2955            for a in 0..dim {
2956                drb[a] = data[[i, a]];
2957                cb[a] = centers[[j, a]];
2958            }
2959            aniso_distance_and_components(drb, cb, eta).0
2960        } else {
2961            stable_euclidean_norm((0..dim).map(|a| data[[i, a]] - centers[[j, a]]))
2962        }
2963    };
2964    let profile = if nk >= RADIAL_PROFILE_MIN_PAIRS {
2965        let mut r_lo = f64::INFINITY;
2966        let mut r_hi = 0.0_f64;
2967        let mut drb = vec![0.0; dim];
2968        let mut cb = vec![0.0; dim];
2969        for i in 0..n {
2970            for j in 0..k {
2971                let r = pair_r(i, j, &mut drb, &mut cb);
2972                if r > 0.0 {
2973                    r_lo = r_lo.min(r);
2974                    r_hi = r_hi.max(r);
2975                }
2976            }
2977        }
2978        if r_lo.is_finite() && r_hi > r_lo {
2979            radial_profile::RadialProfile::build(&radial_kind, r_lo, r_hi)
2980        } else {
2981            None
2982        }
2983    } else {
2984        None
2985    };
2986    {
2987        let pp = SendPtr(phi_values.as_mut_ptr());
2988        let qp = SendPtr(q_values.as_mut_ptr());
2989        let tp = SendPtr(t_values.as_mut_ptr());
2990        let ap = SendPtr(axis_components.as_mut_ptr());
2991        let ferr = &first_err;
2992        let profile_ref = profile.as_ref();
2993        (0..nc).into_par_iter().for_each(move |ci| {
2994            let start = ci * cs;
2995            let end = start.saturating_add(cs).min(n);
2996            let mut data_row_buf = vec![0.0; dim];
2997            let mut center_buf = vec![0.0; dim];
2998            for i in start..end {
2999                for a in 0..dim {
3000                    data_row_buf[a] = data[[i, a]];
3001                }
3002                for j in 0..k {
3003                    let (r, scalar_component) = if let Some(eta) = fixed_eta {
3004                        for a in 0..dim {
3005                            center_buf[a] = centers[[j, a]];
3006                        }
3007                        let (r, components) =
3008                            aniso_distance_and_components(&data_row_buf, &center_buf, eta);
3009                        (r, components.into_iter().sum::<f64>())
3010                    } else {
3011                        let r =
3012                            stable_euclidean_norm((0..dim).map(|a| data[[i, a]] - centers[[j, a]]));
3013                        (r, r * r)
3014                    };
3015                    let triplet = match profile_ref {
3016                        Some(profile) => profile.eval_or_exact(&radial_kind, r),
3017                        None => radial_kind.eval_design_triplet(r),
3018                    };
3019                    let (phi, q, t) = match triplet {
3020                        Ok(p) => p,
3021                        Err(e) => {
3022                            let mut slot = ferr.lock().unwrap_or_else(|p| p.into_inner());
3023                            if slot.is_none() {
3024                                *slot = Some(e);
3025                            }
3026                            return;
3027                        }
3028                    };
3029                    let flat = i * k + j;
3030                    // SAFETY: each Rayon chunk owns a disjoint i-row range
3031                    // of the nk-long phi/q/t/axis buffers, so flat=i*k+j is
3032                    // in-bounds for every write and never aliases another worker.
3033                    unsafe {
3034                        *pp.add(flat) = phi;
3035                        *qp.add(flat) = q;
3036                        *tp.add(flat) = t;
3037                        *ap.add(flat) = scalar_component;
3038                    }
3039                }
3040            }
3041        });
3042    }
3043    if let Some(cause) = first_err.into_inner().unwrap_or_else(|p| p.into_inner()) {
3044        return Err(BasisError::InvalidInput(format!(
3045            "radial scalar evaluation failed during scalar derivative construction: {cause}"
3046        )));
3047    }
3048
3049    let op = ImplicitDesignPsiDerivative::new(
3050        phi_values,
3051        q_values,
3052        t_values,
3053        axis_components,
3054        ident_transform,
3055        full_ident_transform,
3056        n,
3057        k,
3058        n_poly,
3059        1,
3060    )
3061    .with_psi_scale_share(psi_scale_share);
3062
3063    if operator_only {
3064        return Ok(ScalarDesignPsiDerivatives {
3065            design_first: Array2::<f64>::zeros((0, 0)),
3066            design_second_diag: Array2::<f64>::zeros((0, 0)),
3067            implicit_operator: Some(op),
3068        });
3069    }
3070
3071    Ok(ScalarDesignPsiDerivatives {
3072        design_first: op.materialize_first(0)?,
3073        design_second_diag: op.materialize_second_diag(0)?,
3074        implicit_operator: Some(op),
3075    })
3076}