nereids_physics/surrogate.rs
1//! Forward-model surrogates for multi-isotope accelerated fits.
2//!
3//! Currently exposes [`SparseEmpiricalCubaturePlan`] — a Jacobian-anchored
4//! sparse empirical cubature on the joint σ-pushforward manifold. An
5//! algorithm-design study that benchmarked several candidate surrogates
6//! against the real VENUS operator selected this scheme as the k ≥ 2
7//! winner; this module is a Rust port of the study's winning reference
8//! implementation, and the compression table below records the study's
9//! measurements.
10//!
11//! # Mathematical basis
12//!
13//! Let `R` be the resolution operator on a fixed target grid, `σ_1(E'),
14//! …, σ_k(E')` the per-isotope cross-sections, and `x_ℓ = (σ_1(E'_ℓ), …,
15//! σ_k(E'_ℓ)) ∈ ℝ^k` the pushforward of a source point `E'_ℓ`. For each
16//! row `i`, exact evaluation is
17//!
18//! ```text
19//! T_i(n) = Σ_ℓ R_{iℓ} exp(-n · x_ℓ)
20//! ∂T_i/∂n_j = -Σ_ℓ R_{iℓ} x_{ℓ,j} exp(-n · x_ℓ)
21//! ```
22//!
23//! The row support contains ~82 ℓ's on the VENUS 3471-bin production
24//! grid. By [Carathéodory / Tchakaloff], any nonneg combination of
25//! feature vectors over this support is matched (in feature space) by an
26//! equivalent nonneg combination supported on at most `d + 1` atoms,
27//! where `d` is the feature dimension. Choosing features = forward
28//! evaluations at `S` training densities + Jacobian evaluations at one
29//! anchor density gives `d = S + k` features, so each row collapses to
30//! ≤ `S + k + 1` atoms while preserving positivity, row-stochasticity,
31//! and the exact Jacobian at the anchor.
32//!
33//! # Empirical compression (design-study measurements, real VENUS operator)
34//!
35//! | Scenario | k | avg atoms/row | max atoms/row | compression vs exact |
36//! |-----------------------------------|---|---------------|---------------|----------------------|
37//! | Hf (natural group) | 1 | 3.53 | 67 | 23.3× |
38//! | Hf + W | 2 | 5.65 | 7 | 14.5× |
39//! | U-235 + U-238 | 2 | 5.32 | 7 | 15.5× |
40//! | Gd + Eu + Sm | 3 | 8.59 | 9 | 9.6× |
41//! | Hf-174/176/177/178/179/180 indep. | 6 | 9.03 | 15 | 9.1× |
42//!
43//! # LP solver
44//!
45//! Row-wise Tchakaloff reduction is framed as a feasibility LP (minimize
46//! `0` subject to the equality constraints) and solved with `microlp`.
47//! The problem is small (≤ S + k + 1 rows × |support| columns, here
48//! typically ~ 10 × ~ 100) so a pure-Rust simplex is fast enough.
49
50use std::fmt;
51
52use microlp::{ComparisonOp, OptimizationDirection, Problem, SolveOutcome};
53
54use crate::resolution::ResolutionMatrix;
55
56/// Errors from [`SparseEmpiricalCubaturePlan`] construction.
57#[derive(Debug)]
58pub enum CubatureBuildError {
59 /// Flat `sigmas` storage has the wrong total element count.
60 ///
61 /// `sigmas` is stored row-major as `sigmas[j * n_rows + ℓ] =
62 /// σ_j(E'_ℓ)`, so the expected total length is `k * n_rows`.
63 SigmaGridMismatch {
64 /// Expected total element count (`k * n_rows`).
65 expected: usize,
66 /// Actual `sigmas.len()`.
67 actual: usize,
68 },
69 /// Zero isotopes supplied — the cubature has no meaning for k = 0.
70 ZeroIsotopes,
71 /// Zero training densities supplied — the LP construction requires
72 /// at least one forward feature per row.
73 ZeroTrainingDensities,
74 /// A training density vector has a length different from the
75 /// isotope count.
76 TrainingDensityLength {
77 /// Expected (k).
78 expected: usize,
79 /// Actual (`training_densities[i].len()`).
80 actual: usize,
81 /// Offending index.
82 index: usize,
83 },
84 /// The Jacobian anchor density has a length different from the
85 /// isotope count.
86 AnchorLength {
87 /// Expected (k).
88 expected: usize,
89 /// Actual.
90 actual: usize,
91 },
92 /// The row-wise LP failed to produce a feasible solution. Should
93 /// never fire on a well-formed problem because the uniform
94 /// (non-sparse) weight is always feasible; if it does, it signals
95 /// a numerical degeneracy (e.g., identical atoms in the row
96 /// support) worth investigating.
97 LpInfeasible {
98 /// Row of the resolution matrix where the LP failed.
99 row: usize,
100 },
101}
102
103impl fmt::Display for CubatureBuildError {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 Self::SigmaGridMismatch { expected, actual } => write!(
107 f,
108 "sigmas flat length ({actual}) must equal k * n_rows ({expected})",
109 ),
110 Self::ZeroIsotopes => write!(f, "cubature requires at least one isotope"),
111 Self::ZeroTrainingDensities => {
112 write!(f, "cubature requires at least one training density sample",)
113 }
114 Self::TrainingDensityLength {
115 expected,
116 actual,
117 index,
118 } => write!(
119 f,
120 "training_densities[{index}] has length {actual} (expected k = {expected})",
121 ),
122 Self::AnchorLength { expected, actual } => write!(
123 f,
124 "jacobian_anchor has length {actual} (expected k = {expected})",
125 ),
126 Self::LpInfeasible { row } => write!(
127 f,
128 "row-wise LP failed to find a feasible cubature for row {row} — \
129 likely numerical degeneracy in the row support",
130 ),
131 }
132 }
133}
134
135impl std::error::Error for CubatureBuildError {}
136
137/// Row-wise Tchakaloff cubature of the joint σ-pushforward measure on a
138/// fixed target grid.
139///
140/// Laid out in flat Struct-of-Arrays (SoA) form for cache-friendly online
141/// evaluation:
142///
143/// * `row_starts[i]..row_starts[i+1]` indexes into `weights`/`atoms` for
144/// row `i`.
145/// * `weights[q]` is the per-atom nonneg weight (sums to 1.0 within each
146/// row since the source measure is row-stochastic).
147/// * `atoms[q]` is a flat row-major block of length `k` storing the
148/// atom's joint σ coordinates.
149///
150/// Built once per `(grid, isotope_set, training_densities, anchor)`
151/// tuple and applied repeatedly during LM / KL iterations via
152/// [`Self::forward`] and [`Self::forward_and_jacobian`].
153#[derive(Debug, Clone)]
154pub struct SparseEmpiricalCubaturePlan {
155 /// Target energy grid the plan was built for (owned copy, same
156 /// pattern as [`crate::resolution::ResolutionPlan`] /
157 /// [`crate::resolution::ResolutionMatrix`]). Callers implementing
158 /// plan caches compare this against their current grid to decide
159 /// whether the plan is still valid.
160 target_energies: Vec<f64>,
161 /// Number of isotopes (per-atom dimensionality).
162 k: usize,
163 /// `row_starts[i]..row_starts[i+1]` — CSR-style row offsets.
164 /// Length `target_energies.len() + 1`.
165 row_starts: Vec<u32>,
166 /// Per-atom nonneg weights. Within each row, `Σ_q weights[q] = 1`.
167 weights: Vec<f64>,
168 /// Row-major flat storage of atom coordinates in ℝ^k. Length
169 /// `k * weights.len()`. Atom `q` occupies indices `k*q .. k*(q+1)`.
170 atoms: Vec<f64>,
171 /// Optional training-density upper bound — the per-isotope
172 /// `train_max` used to build the plan. When set, dispatch
173 /// layers can compare the current fit iterate against it and
174 /// fall back to the exact path when the iterate strays beyond
175 /// the box (with a tolerance multiplier to avoid thrashing).
176 /// `None` means "no box information available; dispatch cannot
177 /// safety-check against it". Set via
178 /// [`Self::with_density_box`].
179 density_box: Option<Vec<f64>>,
180}
181
182impl SparseEmpiricalCubaturePlan {
183 /// Canonical default training-density rule from the design-study
184 /// reference implementation: for an upper-bound density vector
185 /// `train_max ∈ ℝ^k`, return `S = 2 + k` training points
186 /// consisting of `0.25 * train_max`, `0.75 * train_max`, and the
187 /// k axis-aligned "unit" points `train_max[i] · e_i` (all other
188 /// components zero). Exposed as a helper so callers don't have
189 /// to hand-roll the rule.
190 ///
191 /// Duplicates are NOT removed. In practice the rule produces
192 /// `S = k + 2` distinct points for any `k ≥ 1` with all
193 /// `train_max[i] > 0`.
194 pub fn default_training_points(train_max: &[f64]) -> Vec<Vec<f64>> {
195 let k = train_max.len();
196 let mut points: Vec<Vec<f64>> = Vec::with_capacity(k + 2);
197 points.push(train_max.iter().map(|&x| 0.25 * x).collect());
198 points.push(train_max.iter().map(|&x| 0.75 * x).collect());
199 for (i, &max_i) in train_max.iter().enumerate() {
200 let mut p = vec![0.0_f64; k];
201 p[i] = max_i;
202 points.push(p);
203 }
204 points
205 }
206
207 /// Canonical default Jacobian anchor from the design-study
208 /// reference implementation: `0.5 * train_max`, the midpoint of
209 /// the density box.
210 pub fn default_jacobian_anchor(train_max: &[f64]) -> Vec<f64> {
211 train_max.iter().map(|&x| 0.5 * x).collect()
212 }
213
214 /// Build a Tchakaloff sparse-cubature plan row-by-row from an exact
215 /// [`ResolutionMatrix`] + isotope cross-section stack.
216 ///
217 /// # Arguments
218 ///
219 /// * `matrix` — exact sparse R (built via
220 /// [`crate::resolution::ResolutionPlan::compile_to_matrix`]).
221 /// * `sigmas` — per-isotope cross-sections on the matrix's target
222 /// grid, flat row-major: `sigmas[j * n_rows + ℓ]` = σ_j(E'_ℓ).
223 /// * `k` — number of isotopes (must match `sigmas.len() / n_rows`).
224 /// * `training_densities` — a slice of density vectors `n^(s) ∈
225 /// ℝ^k` covering the density box the fit is expected to explore.
226 /// The canonical default rule is `[0.25 * train_max, 0.75 *
227 /// train_max] ∪ {train_max_e_i : i=1..k}` which gives `S = 2 + k`
228 /// distinct training points.
229 /// * `jacobian_anchor` — a single density `n* ∈ ℝ^k` at which the
230 /// Jacobian features are evaluated. The canonical default is
231 /// `0.5 * train_max`.
232 ///
233 /// Per-row LP:
234 ///
235 /// ```text
236 /// find x ≥ 0 in ℝ^{|support|}
237 /// s.t. Σ_q x_q = 1
238 /// phi[s, q] = exp(-n^(s) · σ_support[q]) for s = 1..S
239 /// phi[ℓ, q] = σ_{ℓ, support[q]} · exp(-n* · σ_support[q])
240 /// for ℓ = 1..k
241 /// phi @ x = phi @ w_exact_support
242 /// ```
243 ///
244 /// where `w_exact_support = R[i, support] / Σ_q R[i, support[q]]`
245 /// is the **exact full-support row measure** (the existing
246 /// non-sparse weight distribution — NOT uniform; the entries
247 /// carry the kernel shape). It serves as the feasibility
248 /// fallback for the LP: the identity `x = w_exact_support`
249 /// always satisfies the equality constraints, so a feasible
250 /// solution exists. The returned basic feasible solution has
251 /// at most `S + k + 1` nonzero entries (Carathéodory).
252 pub fn build(
253 matrix: &ResolutionMatrix,
254 sigmas: &[f64],
255 k: usize,
256 training_densities: &[Vec<f64>],
257 jacobian_anchor: &[f64],
258 ) -> Result<Self, CubatureBuildError> {
259 if k == 0 {
260 return Err(CubatureBuildError::ZeroIsotopes);
261 }
262 if training_densities.is_empty() {
263 return Err(CubatureBuildError::ZeroTrainingDensities);
264 }
265 let n_rows = matrix.len();
266 if sigmas.len() != k * n_rows {
267 return Err(CubatureBuildError::SigmaGridMismatch {
268 expected: k * n_rows,
269 actual: sigmas.len(),
270 });
271 }
272 for (idx, td) in training_densities.iter().enumerate() {
273 if td.len() != k {
274 return Err(CubatureBuildError::TrainingDensityLength {
275 expected: k,
276 actual: td.len(),
277 index: idx,
278 });
279 }
280 }
281 if jacobian_anchor.len() != k {
282 return Err(CubatureBuildError::AnchorLength {
283 expected: k,
284 actual: jacobian_anchor.len(),
285 });
286 }
287
288 // Empty matrix — return an empty plan.
289 if n_rows == 0 {
290 return Ok(Self {
291 target_energies: matrix.target_energies().to_vec(),
292 k,
293 row_starts: vec![0],
294 weights: Vec::new(),
295 atoms: Vec::new(),
296 density_box: None,
297 });
298 }
299
300 let n_train = training_densities.len();
301 // Per-row LP has `n_train + k` equality rows for `phi @ x =
302 // target` plus 1 for `sum x = 1`.
303 let phi_rows = n_train + k;
304
305 let mut row_starts: Vec<u32> = Vec::with_capacity(n_rows + 1);
306 row_starts.push(0);
307 let mut weights: Vec<f64> = Vec::new();
308 let mut atoms: Vec<f64> = Vec::new();
309
310 // Reusable scratch across rows. Per-row support widths differ,
311 // but the max is bounded by `max(row_nnz) ≤ 132` on the real
312 // VENUS operator; `clear()` reuses the `Vec` capacity.
313 let mut support_sigma: Vec<f64> = Vec::new(); // k * |support|, row-major over atoms
314 let mut w_exact: Vec<f64> = Vec::new(); // |support|
315 let mut phi_fwd: Vec<f64> = Vec::new(); // n_train × |support|, row-major over rows
316 let mut phi_grad: Vec<f64> = Vec::new(); // k × |support|
317 let mut grad_base: Vec<f64> = Vec::new(); // |support| — exp(-anchor · σ_q) hoisted out of ell loop
318 let mut target: Vec<f64> = Vec::new(); // phi_rows
319 let mut phi_col_buf: Vec<(microlp::Variable, f64)> = Vec::new();
320
321 for i in 0..n_rows {
322 let start = matrix.row_starts()[i] as usize;
323 let end = matrix.row_starts()[i + 1] as usize;
324 let support_cols = &matrix.col_indices()[start..end];
325 let support_vals = &matrix.values()[start..end];
326 let support_len = support_cols.len();
327
328 // Passthrough / empty row → emit uniform weight directly.
329 // No LP needed. (A single row with a single entry at col
330 // i, value 1.0, stays as a single atom — its pushforward
331 // coordinates are just σ at that column.)
332 if support_len == 0 {
333 row_starts.push(weights.len() as u32);
334 continue;
335 }
336
337 // Shortcut: if the row support has only 1 column, the
338 // cubature is that single atom with weight 1. No LP and
339 // no feature matrix needed. Must check BEFORE building
340 // w_exact / phi to avoid the work the shortcut then
341 // discards.
342 if support_len == 1 {
343 let col = support_cols[0] as usize;
344 weights.push(1.0);
345 atoms.extend((0..k).map(|j| sigmas[j * n_rows + col]));
346 row_starts.push(weights.len() as u32);
347 continue;
348 }
349
350 // Non-trivial row (support_len ≥ 2). Build normalized
351 // exact-weight distribution + collect support-column σ
352 // vectors.
353 //
354 // **Zero-weight CSR cells MUST be filtered out** before
355 // they reach the LP. [`ResolutionPlan::compile_to_matrix`]
356 // deliberately retains `value == 0.0` entries for the
357 // `frac == +0.0` branch to preserve downstream NaN-safety
358 // when the matrix is re-applied to a spectrum containing
359 // NaN at `lo + 1`. But the cubature LP has a zero
360 // objective, so the simplex is free to assign positive
361 // mass to any zero-weight variable — the training
362 // constraints pass trivially (w_exact = 0 → target
363 // contribution = 0), yet held-out forward/Jacobian
364 // predictions can pick up mass at energies the exact
365 // resolution operator never samples. Filter them here
366 // so no zero-R column ever becomes an LP variable or a
367 // stored atom.
368 //
369 // Row sum guard: the source matrix is row-stochastic
370 // (Σ_q R_{iq} = 1 to machine precision), so dropping
371 // exactly-zero columns preserves `row_sum > 0`.
372 let row_sum: f64 = support_vals.iter().sum();
373 support_sigma.clear();
374 support_sigma.reserve(k * support_len);
375 w_exact.clear();
376 w_exact.reserve(support_len);
377 for (q, &col_u32) in support_cols.iter().enumerate() {
378 if support_vals[q] == 0.0 {
379 continue;
380 }
381 let col = col_u32 as usize;
382 for j in 0..k {
383 support_sigma.push(sigmas[j * n_rows + col]);
384 }
385 w_exact.push(support_vals[q] / row_sum);
386 }
387 // Effective support length after dropping zero-weight
388 // CSR cells. Subsequent LP / feature-matrix code uses
389 // this, not the original `support_len` that included
390 // zero-weight cells.
391 let support_len = w_exact.len();
392
393 // Re-check the degenerate cases on the filtered support.
394 // If all CSR cells happened to be zero, treat like an
395 // empty row. If exactly one survives, take the shortcut.
396 if support_len == 0 {
397 row_starts.push(weights.len() as u32);
398 continue;
399 }
400 if support_len == 1 {
401 weights.push(1.0);
402 atoms.extend_from_slice(&support_sigma[..k]);
403 row_starts.push(weights.len() as u32);
404 continue;
405 }
406
407 // Build per-row feature matrix phi (row-major over feature
408 // rows, then support columns).
409 phi_fwd.clear();
410 phi_fwd.reserve(n_train * support_len);
411 for td in training_densities.iter() {
412 for q in 0..support_len {
413 let mut dot = 0.0_f64;
414 for j in 0..k {
415 dot += td[j] * support_sigma[q * k + j];
416 }
417 phi_fwd.push((-dot).exp());
418 }
419 }
420 // Jacobian features `phi_grad[ℓ, q] = σ_{ℓ,q} · exp(-n* ·
421 // σ_q)`. The `exp(-n* · σ_q)` factor depends only on `q`,
422 // not `ℓ`, so hoist it into a row-local `grad_base[q]`
423 // buffer to avoid recomputing |support| × k exponentials
424 // (matches the design study's Python reference `phi_grad_base`
425 // layout).
426 phi_grad.clear();
427 phi_grad.reserve(k * support_len);
428 grad_base.clear();
429 grad_base.reserve(support_len);
430 for q in 0..support_len {
431 let mut dot = 0.0_f64;
432 for j in 0..k {
433 dot += jacobian_anchor[j] * support_sigma[q * k + j];
434 }
435 grad_base.push((-dot).exp());
436 }
437 for ell in 0..k {
438 for q in 0..support_len {
439 phi_grad.push(support_sigma[q * k + ell] * grad_base[q]);
440 }
441 }
442
443 // Target = phi @ w_exact, built streaming per feature row.
444 target.clear();
445 target.reserve(phi_rows);
446 for s in 0..n_train {
447 let mut t = 0.0_f64;
448 for q in 0..support_len {
449 t += phi_fwd[s * support_len + q] * w_exact[q];
450 }
451 target.push(t);
452 }
453 for ell in 0..k {
454 let mut t = 0.0_f64;
455 for q in 0..support_len {
456 t += phi_grad[ell * support_len + q] * w_exact[q];
457 }
458 target.push(t);
459 }
460
461 // Feasibility LP: minimize 0 subject to the equality
462 // constraints. Each column = one atom; coefficient on the
463 // objective = 0. `x_q ∈ [0, ∞)`.
464 let mut problem = Problem::new(OptimizationDirection::Minimize);
465 let vars: Vec<microlp::Variable> = (0..support_len)
466 .map(|_| problem.add_var(0.0, (0.0, f64::INFINITY)))
467 .collect();
468
469 // sum x_q = 1
470 phi_col_buf.clear();
471 for &v in &vars {
472 phi_col_buf.push((v, 1.0));
473 }
474 problem.add_constraint(&phi_col_buf, ComparisonOp::Eq, 1.0);
475
476 // phi @ x = target, one equality per feature row.
477 for s in 0..n_train {
478 phi_col_buf.clear();
479 for q in 0..support_len {
480 phi_col_buf.push((vars[q], phi_fwd[s * support_len + q]));
481 }
482 problem.add_constraint(&phi_col_buf, ComparisonOp::Eq, target[s]);
483 }
484 for ell in 0..k {
485 phi_col_buf.clear();
486 for q in 0..support_len {
487 phi_col_buf.push((vars[q], phi_grad[ell * support_len + q]));
488 }
489 problem.add_constraint(&phi_col_buf, ComparisonOp::Eq, target[n_train + ell]);
490 }
491
492 // Solve. If `microlp` fails (it may on numerically
493 // degenerate row supports — e.g., identical σ across the
494 // row, which is physically rare but possible), fall back
495 // to the exact full-support row measure `w_exact`. This
496 // preserves correctness at the cost of giving up
497 // compression on that row. The `LpInfeasible` error
498 // variant (returned below) only fires if BOTH the LP
499 // solution AND the `w_exact` fallback produce an empty
500 // active set after the `WEIGHT_EPSILON` filter — which
501 // is physically impossible on a valid row-stochastic
502 // `ResolutionMatrix` row (`Σ w_exact = 1` implies at
503 // least one entry exceeds `1 / support_len > 1e-12`).
504 let sparse_weights: Vec<f64> = match problem.solve() {
505 Ok(SolveOutcome::Solution(solution)) => {
506 vars.iter().map(|&v| solution.var_value(v)).collect()
507 }
508 // `Interrupted` carries no validated assignment (a time or
509 // node limit fired — none is configured here, but the variant
510 // must be handled); treat it like any solver failure and keep
511 // the exact row measure.
512 Ok(SolveOutcome::Interrupted(_)) | Err(_) => w_exact.clone(),
513 };
514
515 // Drop numerically-zero atoms and renormalize so the row
516 // still sums to exactly 1.0 after simplex roundoff.
517 const WEIGHT_EPSILON: f64 = 1e-12;
518 let mut active: Vec<(usize, f64)> = sparse_weights
519 .iter()
520 .enumerate()
521 .filter_map(|(q, &w)| (w > WEIGHT_EPSILON).then_some((q, w)))
522 .collect();
523 if active.is_empty() {
524 // Extreme fallback — should never happen because
525 // w_exact is already feasible with support_len > 0,
526 // but defend against a corrupt LP result.
527 active = w_exact
528 .iter()
529 .enumerate()
530 .filter_map(|(q, &w)| (w > WEIGHT_EPSILON).then_some((q, w)))
531 .collect();
532 if active.is_empty() {
533 return Err(CubatureBuildError::LpInfeasible { row: i });
534 }
535 }
536 let active_sum: f64 = active.iter().map(|&(_, w)| w).sum();
537 // Note: rows with repeated σ patterns (physically
538 // uncommon but possible) end up with multiple atoms at
539 // identical x. We emit them separately and rely on
540 // online forward evaluation to sum the weighted
541 // exponentials, which is algebraically identical to a
542 // pre-merged atom. Merging would be a micro-optimization
543 // worth revisiting only if profiling shows the duplicate
544 // work matters.
545
546 for (q, w) in active {
547 weights.push(w / active_sum);
548 for j in 0..k {
549 atoms.push(support_sigma[q * k + j]);
550 }
551 }
552 row_starts.push(weights.len() as u32);
553 }
554
555 Ok(Self {
556 target_energies: matrix.target_energies().to_vec(),
557 k,
558 row_starts,
559 weights,
560 atoms,
561 density_box: None,
562 })
563 }
564
565 /// Number of rows (target-grid size) covered by this plan.
566 pub fn len(&self) -> usize {
567 self.target_energies.len()
568 }
569
570 /// True when the plan covers no target energies.
571 pub fn is_empty(&self) -> bool {
572 self.target_energies.is_empty()
573 }
574
575 /// Number of isotopes (per-atom dimensionality).
576 pub fn k(&self) -> usize {
577 self.k
578 }
579
580 /// Total number of stored atoms across all rows.
581 pub fn n_atoms(&self) -> usize {
582 self.weights.len()
583 }
584
585 /// Target energy grid the plan was built for.
586 ///
587 /// Mirrors [`crate::resolution::ResolutionPlan::target_energies`]
588 /// / [`crate::resolution::ResolutionMatrix::target_energies`] —
589 /// callers implementing plan caches compare this against their
590 /// current grid to decide whether the plan is still valid.
591 pub fn target_energies(&self) -> &[f64] {
592 &self.target_energies
593 }
594
595 /// CSR row-start offsets. `row_starts()[i]..row_starts()[i+1]`
596 /// names the atom range for row `i`. Length `len() + 1`.
597 pub fn row_starts(&self) -> &[u32] {
598 &self.row_starts
599 }
600
601 /// Per-atom weights.
602 pub fn weights(&self) -> &[f64] {
603 &self.weights
604 }
605
606 /// Per-atom σ coordinates, flat row-major. Atom `q` at
607 /// `atoms()[k * q .. k * (q + 1)]`.
608 pub fn atoms(&self) -> &[f64] {
609 &self.atoms
610 }
611
612 /// Training-density upper bound recorded at build time, if any.
613 /// Dispatch layers use this to detect when the fit iterate
614 /// escapes the training region and safely fall back to the
615 /// exact path (cubature accuracy degrades quickly outside the
616 /// trained box). `None` when the caller chose not to record
617 /// one — in that case dispatch cannot safety-check.
618 pub fn density_box(&self) -> Option<&[f64]> {
619 self.density_box.as_deref()
620 }
621
622 /// Attach the training-density upper bound (`train_max`) used
623 /// during build, so dispatch can refuse to fire on iterates
624 /// that escape the trained region. Builder-style; returns
625 /// `self` for chaining. Callers in `spatial_map_typed`
626 /// populate this with the same `train_max` vector fed into
627 /// [`Self::default_training_points`] / [`Self::default_jacobian_anchor`].
628 ///
629 /// # Panics
630 ///
631 /// Panics if `train_max.len() != self.k()`.
632 #[must_use]
633 pub fn with_density_box(mut self, train_max: Vec<f64>) -> Self {
634 assert_eq!(
635 train_max.len(),
636 self.k,
637 "train_max length ({}) must equal k ({})",
638 train_max.len(),
639 self.k,
640 );
641 self.density_box = Some(train_max);
642 self
643 }
644
645 /// Evaluate the surrogate forward model `T_i(n)` at density vector
646 /// `n ∈ ℝ^k`.
647 ///
648 /// # Panics
649 ///
650 /// Panics if `n.len() != self.k()`.
651 pub fn forward(&self, n: &[f64]) -> Vec<f64> {
652 assert_eq!(
653 n.len(),
654 self.k,
655 "density vector length ({}) must match plan isotope count ({})",
656 n.len(),
657 self.k,
658 );
659 let mut out = vec![0.0_f64; self.target_energies.len()];
660 for (i, out_i) in out.iter_mut().enumerate() {
661 let s = self.row_starts[i] as usize;
662 let e = self.row_starts[i + 1] as usize;
663 let mut acc = 0.0_f64;
664 for q in s..e {
665 let atom = &self.atoms[q * self.k..(q + 1) * self.k];
666 let mut dot = 0.0_f64;
667 for j in 0..self.k {
668 dot += n[j] * atom[j];
669 }
670 acc += self.weights[q] * (-dot).exp();
671 }
672 *out_i = acc;
673 }
674 out
675 }
676
677 /// Evaluate forward + per-density Jacobian at density vector `n`.
678 /// Returns `(T, J)` where `T[i] = T_i(n)` and `J[i * k + ℓ] =
679 /// ∂T_i/∂n_ℓ`, both computed from the same atom scan so the online
680 /// cost is `(k + 1)` FLOPs per atom rather than `k + 1` separate
681 /// passes.
682 ///
683 /// # Panics
684 ///
685 /// Panics if `n.len() != self.k()`.
686 pub fn forward_and_jacobian(&self, n: &[f64]) -> (Vec<f64>, Vec<f64>) {
687 assert_eq!(
688 n.len(),
689 self.k,
690 "density vector length ({}) must match plan isotope count ({})",
691 n.len(),
692 self.k,
693 );
694 let mut forward = vec![0.0_f64; self.target_energies.len()];
695 let mut jac = vec![0.0_f64; self.target_energies.len() * self.k];
696 for i in 0..self.target_energies.len() {
697 let s = self.row_starts[i] as usize;
698 let e = self.row_starts[i + 1] as usize;
699 let mut t_i = 0.0_f64;
700 let jac_row = &mut jac[i * self.k..(i + 1) * self.k];
701 for q in s..e {
702 let atom = &self.atoms[q * self.k..(q + 1) * self.k];
703 let mut dot = 0.0_f64;
704 for j in 0..self.k {
705 dot += n[j] * atom[j];
706 }
707 let term = self.weights[q] * (-dot).exp();
708 t_i += term;
709 for (ell, jac_slot) in jac_row.iter_mut().enumerate() {
710 *jac_slot -= term * atom[ell];
711 }
712 }
713 forward[i] = t_i;
714 }
715 (forward, jac)
716 }
717}
718
719// ═══════════════════════════════════════════════════════════════════
720// Scalar (k = 1) surrogate — epic #472.
721// ═══════════════════════════════════════════════════════════════════
722//
723// The [`SparseEmpiricalCubaturePlan`] above is the k ≥ 2 production
724// winner, but its generic atom construction over-damps the grouped
725// Hf k = 1 KL scatter by ~27 % (design-study measurement). The
726// scalar path gets a dedicated surrogate. Both
727// study candidates (Lanczos σ-pushforward Gauss quadrature,
728// Chebyshev-in-density) were built side-by-side and benched on
729// the real VENUS 3471-bin production grid. Chebyshev won both the
730// accuracy (max_err ≤ 2e-15 vs ≤ 4e-15) **and** the wall-time
731// axis by a wide margin — the ordering is stable across
732// hardware, even though absolute µs-per-row numbers aren't.
733// **Chebyshev won**; Lanczos + Gauss-pushforward machinery was
734// deleted per the issue's "drop the loser" contract — no
735// same-name-different-function duplication. If future research
736// finds a better scalar surrogate, the public
737// [`ScalarSurrogatePlan`] alias below is the stable swap point.
738
739/// Errors from scalar surrogate plan construction.
740#[derive(Debug)]
741pub enum ScalarSurrogateBuildError {
742 /// `sigma` flat length disagrees with the matrix grid size.
743 SigmaGridMismatch {
744 /// Expected length (`n_rows`).
745 expected: usize,
746 /// Actual `sigma.len()`.
747 actual: usize,
748 },
749 /// A Chebyshev-node build was given `n_max ≤ 0` or `M < 2`.
750 InvalidChebyshevBox {
751 /// Offending upper bound.
752 n_max: f64,
753 /// Requested node count.
754 m: usize,
755 },
756 /// The Chebyshev interpolant cannot reach target accuracy on the
757 /// requested `[0, n_max]` box with `M` nodes — the box is too
758 /// wide for the σ profile. Chebyshev converges exponentially in
759 /// `M` for smooth `T(n) = exp(-n σ)`, but if `max(n_max · σ)` is
760 /// large the interpolant loses precision. Callers should either
761 /// shrink `n_max` (preferred — tighter fit-exploration bounds
762 /// fix this) or increase `M`.
763 InsufficientAccuracyOnBox {
764 /// Requested density box upper bound.
765 n_max: f64,
766 /// Chebyshev node count that failed.
767 m: usize,
768 /// Measured maximum relative error of the interpolant
769 /// against the exact `apply_r ∘ exp(-n σ)` on the box
770 /// (evaluated at midpoints between Chebyshev nodes).
771 max_rel_err: f64,
772 /// Required tolerance (currently `1e-6`).
773 tolerance: f64,
774 },
775}
776
777impl fmt::Display for ScalarSurrogateBuildError {
778 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
779 match self {
780 Self::SigmaGridMismatch { expected, actual } => write!(
781 f,
782 "scalar sigma length ({actual}) must equal n_rows ({expected})",
783 ),
784 Self::InvalidChebyshevBox { n_max, m } => write!(
785 f,
786 "Chebyshev plan requires n_max > 0 and M ≥ 2, got n_max = {n_max}, M = {m}",
787 ),
788 Self::InsufficientAccuracyOnBox {
789 n_max,
790 m,
791 max_rel_err,
792 tolerance,
793 } => write!(
794 f,
795 "Chebyshev plan ({m} nodes) on box [0, {n_max}] hit max rel err \
796 {max_rel_err:.3e} > tolerance {tolerance:.0e}; either shrink n_max \
797 (solver exploration range) or increase M",
798 ),
799 }
800 }
801}
802
803impl std::error::Error for ScalarSurrogateBuildError {}
804
805/// Chebyshev-in-density interpolant of `T_i(n)` for scalar (k = 1)
806/// forward models. For each row `i`, pre-samples `T_i(n_j)` at
807/// `M` Chebyshev-of-the-first-kind nodes in `[0, n_max]`, then
808/// stores the Chebyshev coefficients. Online evaluation is
809/// Clenshaw recurrence with `M` multiply-adds per row.
810///
811/// Unlike the Gauss quadrature, the Chebyshev representation is a
812/// **scalar interpolant** in density space — one pass evaluates
813/// the interpolant at `n`, and the derivative needs a separate
814/// derivative-coefficient series.
815#[derive(Debug, Clone)]
816pub struct ScalarChebyshevPlan {
817 /// Target energy grid the plan was built for.
818 target_energies: Vec<f64>,
819 /// Upper bound of the density box `[0, n_max]` the interpolant
820 /// is valid on.
821 n_max: f64,
822 /// Number of Chebyshev nodes (order + 1). Same for every row.
823 m: usize,
824 /// Row-major Chebyshev coefficients: `coeffs[i * m + k]` is the
825 /// `k`-th Chebyshev coefficient of row `i`.
826 coeffs: Vec<f64>,
827 /// Optional training-density upper bound (defaults to `n_max`
828 /// if the builder doesn't override).
829 density_box: Option<f64>,
830 /// Shared reference to the [`crate::resolution::ResolutionPlan`]
831 /// the plan was built from. Dispatch uses `Arc::ptr_eq` between
832 /// this and the model's currently attached resolution plan as
833 /// an O(1) identity check to refuse stale plans on the same
834 /// energy grid.
835 source_resolution_plan: std::sync::Arc<crate::resolution::ResolutionPlan>,
836 /// FNV-1a-64 fingerprint of the σ slice (`to_bits()` per
837 /// element) the plan was built from. Dispatch recomputes
838 /// from the model's current σ and compares — catches stale
839 /// plans where the grid is unchanged but σ differs.
840 sigma_fingerprint: u64,
841}
842
843/// FNV-1a-64 hash of an `f64` slice by bit pattern — used for
844/// scalar-surrogate dispatch's σ-identity check.
845pub fn fingerprint_f64_slice(xs: &[f64]) -> u64 {
846 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
847 const FNV_PRIME: u64 = 0x100000001b3;
848 let mut h = FNV_OFFSET;
849 for &v in xs {
850 h ^= v.to_bits();
851 h = h.wrapping_mul(FNV_PRIME);
852 }
853 h
854}
855
856impl ScalarChebyshevPlan {
857 /// Build an `M`-node Chebyshev-in-density plan from a shared
858 /// [`crate::resolution::ResolutionPlan`] + scalar σ + density
859 /// box `[0, n_max]`.
860 ///
861 /// The `source_resolution_plan` `Arc` is **stored on the plan**
862 /// so the dispatch-time eligibility check can use
863 /// `Arc::ptr_eq` to refuse stale plans on the same grid.
864 /// A matching σ fingerprint
865 /// is also computed and stored for the same reason: same-grid
866 /// σ-mismatch would otherwise trigger silently-wrong
867 /// transmissions.
868 ///
869 /// Internally calls `source_resolution_plan.compile_to_matrix()`
870 /// once, then `crate::resolution::apply_r` `M` times (one per
871 /// Chebyshev node) to get exact row evaluations, then runs a
872 /// per-row discrete cosine transform to extract Chebyshev
873 /// coefficients.
874 ///
875 /// Cost: one matrix compile + `M × N × avg_nnz_per_row` FMAs
876 /// for the exact sampling pass, plus `M^2` per row for the DCT.
877 pub fn build(
878 source_resolution_plan: std::sync::Arc<crate::resolution::ResolutionPlan>,
879 sigma: &[f64],
880 n_max: f64,
881 m: usize,
882 ) -> Result<Self, ScalarSurrogateBuildError> {
883 let matrix = source_resolution_plan.compile_to_matrix();
884 let n_rows = matrix.len();
885 if sigma.len() != n_rows {
886 return Err(ScalarSurrogateBuildError::SigmaGridMismatch {
887 expected: n_rows,
888 actual: sigma.len(),
889 });
890 }
891 if !n_max.is_finite() || n_max <= 0.0 || m < 2 {
892 return Err(ScalarSurrogateBuildError::InvalidChebyshevBox { n_max, m });
893 }
894
895 // Chebyshev nodes of the first kind on [-1, 1]:
896 // x_j = cos(π (j + 0.5) / M) for j = 0..M-1
897 // Mapped to [0, n_max]:
898 // n_j = (n_max / 2) (x_j + 1)
899 let nodes_x: Vec<f64> = (0..m)
900 .map(|j| {
901 let pj = (j as f64 + 0.5) * std::f64::consts::PI / m as f64;
902 pj.cos()
903 })
904 .collect();
905 let nodes_n: Vec<f64> = nodes_x.iter().map(|&x| 0.5 * n_max * (x + 1.0)).collect();
906
907 // Evaluate T_i(n_j) exactly for each j. `values[j * n_rows
908 // + i]` = T_i(n_j).
909 let mut samples = vec![0.0_f64; m * n_rows];
910 for (j, &nj) in nodes_n.iter().enumerate() {
911 let t_un: Vec<f64> = (0..n_rows).map(|i| (-nj * sigma[i]).exp()).collect();
912 let t_res = crate::resolution::apply_r(&matrix, &t_un);
913 for (i, &v) in t_res.iter().enumerate() {
914 samples[j * n_rows + i] = v;
915 }
916 }
917
918 // DCT-II to extract Chebyshev coefficients per row.
919 // c_k = (2 / M) Σ_j T_i(n_j) T_k(x_j) for k ≥ 1
920 // c_0 = (1 / M) Σ_j T_i(n_j)
921 // where T_k(cos θ) = cos(k θ), θ_j = π (j + 0.5) / M.
922 let mut coeffs = vec![0.0_f64; n_rows * m];
923 for i in 0..n_rows {
924 for k in 0..m {
925 let mut sum = 0.0_f64;
926 for j in 0..m {
927 let theta_j = (j as f64 + 0.5) * std::f64::consts::PI / m as f64;
928 sum += samples[j * n_rows + i] * (k as f64 * theta_j).cos();
929 }
930 let scale = if k == 0 { 1.0 } else { 2.0 } / m as f64;
931 coeffs[i * m + k] = scale * sum;
932 }
933 }
934
935 // Build-time accuracy self-check.
936 //
937 // Chebyshev interpolants are exact at their nodes by
938 // construction; the test points that reveal how wide the
939 // box can safely be are the **midpoints** between
940 // Chebyshev nodes (where the standard Chebyshev error
941 // bound attains its supremum on the box). We evaluate
942 // the just-built interpolant at those midpoints, compare
943 // to the exact `apply_r ∘ exp(-n σ)`, and refuse to
944 // return a plan that blows the accuracy budget.
945 //
946 // The threshold (`1e-6` max rel err) matches the "close
947 // to exact" bar in the scalar-surrogate docstrings. For
948 // typical VENUS fits (τ_peak ≲ 1, box = 2 × initial
949 // density) the interpolant achieves ≤ 1e-15 — this
950 // guard fires only when a caller passes a pathologically
951 // wide box.
952 let sigma_fingerprint = fingerprint_f64_slice(sigma);
953 let plan = Self {
954 target_energies: matrix.target_energies().to_vec(),
955 n_max,
956 m,
957 coeffs,
958 density_box: Some(n_max),
959 source_resolution_plan: std::sync::Arc::clone(&source_resolution_plan),
960 sigma_fingerprint,
961 };
962 const TOLERANCE: f64 = 1e-6;
963 let mut max_rel_err = 0.0_f64;
964 for j in 0..m.saturating_sub(1) {
965 // Midpoint between Chebyshev node j and j+1, in density space.
966 let n_mid = 0.5 * (nodes_n[j] + nodes_n[j + 1]);
967 let t_interp = plan.forward_scalar(n_mid);
968 let t_un: Vec<f64> = (0..n_rows).map(|i| (-n_mid * sigma[i]).exp()).collect();
969 let t_exact = crate::resolution::apply_r(&matrix, &t_un);
970 // Plain relative error with a 1e-15 denominator floor
971 // (matches `max_hybrid_err` conventions elsewhere in
972 // the crate). The previous
973 // `abs.min(rel)` could dramatically under-report when
974 // `|a|, |b|` are both small, hiding catastrophic
975 // divergence where the interpolant drifts to O(1)
976 // while the exact value tends to 0.
977 for (a, b) in t_interp.iter().zip(t_exact.iter()) {
978 let abs = (a - b).abs();
979 let rel = abs / a.abs().max(b.abs()).max(1e-15);
980 max_rel_err = max_rel_err.max(rel);
981 }
982 }
983 if !max_rel_err.is_finite() || max_rel_err > TOLERANCE {
984 return Err(ScalarSurrogateBuildError::InsufficientAccuracyOnBox {
985 n_max,
986 m,
987 max_rel_err,
988 tolerance: TOLERANCE,
989 });
990 }
991
992 Ok(plan)
993 }
994
995 pub fn len(&self) -> usize {
996 self.target_energies.len()
997 }
998 pub fn is_empty(&self) -> bool {
999 self.target_energies.is_empty()
1000 }
1001 pub fn target_energies(&self) -> &[f64] {
1002 &self.target_energies
1003 }
1004 pub fn n_max(&self) -> f64 {
1005 self.n_max
1006 }
1007 pub fn m(&self) -> usize {
1008 self.m
1009 }
1010 pub fn density_box(&self) -> Option<f64> {
1011 self.density_box
1012 }
1013 /// Accessor for the shared
1014 /// [`crate::resolution::ResolutionPlan`] the plan was built from.
1015 /// Dispatch uses `Arc::ptr_eq` between this and the model's
1016 /// currently attached `resolution_plan` as the O(1) identity
1017 /// check that refuses stale plans on the same grid.
1018 pub fn source_resolution_plan(&self) -> &std::sync::Arc<crate::resolution::ResolutionPlan> {
1019 &self.source_resolution_plan
1020 }
1021 /// FNV-1a-64 fingerprint of the σ slice (by `to_bits()`) the
1022 /// plan was built from. Dispatch recomputes from the model's
1023 /// current σ and compares to catch same-grid σ-mismatch.
1024 pub fn sigma_fingerprint(&self) -> u64 {
1025 self.sigma_fingerprint
1026 }
1027
1028 /// Evaluate the Chebyshev interpolant at density `n`. Density
1029 /// outside `[0, n_max]` extrapolates (caller responsibility —
1030 /// dispatch should reject via the density-box check).
1031 pub fn forward_scalar(&self, n: f64) -> Vec<f64> {
1032 let n_rows = self.target_energies.len();
1033 let mut out = vec![0.0_f64; n_rows];
1034 if self.m == 0 {
1035 return out;
1036 }
1037 // Map n → x ∈ [-1, 1].
1038 let x = 2.0 * n / self.n_max - 1.0;
1039 // Clenshaw recurrence: evaluate Σ_k c_k T_k(x).
1040 // b_{M+1} = b_{M+2} = 0; b_k = 2 x b_{k+1} - b_{k+2} + c_k;
1041 // result = c_0 + x b_1 - b_2.
1042 for (i, out_i) in out.iter_mut().enumerate() {
1043 let row_start = i * self.m;
1044 let mut b_next = 0.0_f64;
1045 let mut b_next_next = 0.0_f64;
1046 for k in (1..self.m).rev() {
1047 let b_k = 2.0 * x * b_next - b_next_next + self.coeffs[row_start + k];
1048 b_next_next = b_next;
1049 b_next = b_k;
1050 }
1051 *out_i = self.coeffs[row_start] + x * b_next - b_next_next;
1052 }
1053 out
1054 }
1055
1056 /// Evaluate forward + derivative in one pass. The derivative
1057 /// of a Chebyshev series can be evaluated via a modified
1058 /// Clenshaw recurrence that internally tracks the derivative
1059 /// coefficients — or we use the standard identity
1060 /// `T_k'(x) = k · U_{k-1}(x)` (Chebyshev-of-the-second-kind
1061 /// recurrence). Here we run two parallel Clenshaw sweeps: one
1062 /// for `T(x)` and one for `d/dx T(x)`, then scale by
1063 /// `dx/dn = 2 / n_max`.
1064 pub fn forward_and_derivative_scalar(&self, n: f64) -> (Vec<f64>, Vec<f64>) {
1065 let n_rows = self.target_energies.len();
1066 let mut forward = vec![0.0_f64; n_rows];
1067 let mut deriv = vec![0.0_f64; n_rows];
1068 if self.m == 0 {
1069 return (forward, deriv);
1070 }
1071 let x = 2.0 * n / self.n_max - 1.0;
1072 let dx_dn = 2.0 / self.n_max;
1073
1074 // Derivative coefficients d_k such that Σ d_k T_k(x) =
1075 // d/dx Σ c_k T_k(x). Standard recurrence:
1076 // d_{M-1} = 0
1077 // d_{M-2} = 2 (M-1) c_{M-1}
1078 // d_k = d_{k+2} + 2 (k+1) c_{k+1} for k = M-3..0
1079 // (then d_0 needs to be halved if we want a simple Clenshaw,
1080 // but it's cleaner to use the "recurrence with halved d_0"
1081 // convention; we apply the same Clenshaw as forward.)
1082 let m = self.m;
1083 let mut d_coeffs = vec![0.0_f64; m];
1084
1085 for (i, (out_t, out_d)) in forward.iter_mut().zip(deriv.iter_mut()).enumerate() {
1086 let row_start = i * m;
1087 // Compute d_coeffs for this row.
1088 d_coeffs.fill(0.0);
1089 if m >= 2 {
1090 // d_{M-1} = 0 (already zero)
1091 // d_{M-2} = 2 (M-1) c_{M-1} for M ≥ 2
1092 for k in (0..m - 1).rev() {
1093 let prev = if k + 2 < m { d_coeffs[k + 2] } else { 0.0 };
1094 d_coeffs[k] = prev + 2.0 * (k as f64 + 1.0) * self.coeffs[row_start + k + 1];
1095 }
1096 d_coeffs[0] *= 0.5; // Clenshaw convention: halve d_0.
1097 }
1098
1099 // Clenshaw on forward coefficients.
1100 let mut b_next = 0.0_f64;
1101 let mut b_next_next = 0.0_f64;
1102 for k in (1..m).rev() {
1103 let b_k = 2.0 * x * b_next - b_next_next + self.coeffs[row_start + k];
1104 b_next_next = b_next;
1105 b_next = b_k;
1106 }
1107 *out_t = self.coeffs[row_start] + x * b_next - b_next_next;
1108
1109 // Clenshaw on derivative coefficients (dx/dx side).
1110 let mut b_next = 0.0_f64;
1111 let mut b_next_next = 0.0_f64;
1112 for k in (1..m).rev() {
1113 let b_k = 2.0 * x * b_next - b_next_next + d_coeffs[k];
1114 b_next_next = b_next;
1115 b_next = b_k;
1116 }
1117 let deriv_dx = d_coeffs[0] + x * b_next - b_next_next;
1118 *out_d = deriv_dx * dx_dn;
1119 }
1120 (forward, deriv)
1121 }
1122}
1123
1124/// Scalar (k = 1) surrogate used by the downstream dispatch
1125/// layers (see `TransmissionFitModel` / `PrecomputedTransmissionModel`).
1126///
1127/// This was an enum of `Gauss` vs `Chebyshev` during the
1128/// bench-off period; Chebyshev won the real-VENUS bench on both
1129/// accuracy (≤ 2e-15 vs ≤ 4e-15) and wall-time axes, and Lanczos
1130/// Gauss was deleted per the issue's "drop the loser" contract.
1131/// The type alias is kept as a public stable name so callers and
1132/// downstream dispatch code aren't coupled to the winning impl's
1133/// concrete type — if a future research sprint finds a better
1134/// scalar surrogate, only the alias moves.
1135pub type ScalarSurrogatePlan = ScalarChebyshevPlan;
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::resolution::ResolutionPlan;
1141
1142 // ---------- Synthetic plan helpers (CI-hermetic) ----------
1143
1144 /// Build a synthetic (energies, sigmas, ResolutionMatrix) triple
1145 /// with a uniform triangular-kernel resolution operator and a
1146 /// hand-designed multi-isotope σ pattern. Avoids loading any
1147 /// fixture — these tests run on every `cargo test`.
1148 fn synthetic_setup(
1149 n_grid: usize,
1150 half_kernel: usize,
1151 k: usize,
1152 ) -> (
1153 Vec<f64>,
1154 Vec<f64>,
1155 crate::resolution::ResolutionMatrix,
1156 std::sync::Arc<crate::resolution::ResolutionPlan>,
1157 ) {
1158 assert!(n_grid > 2 * half_kernel);
1159 let energies: Vec<f64> = (0..n_grid).map(|i| 10.0 + i as f64).collect();
1160 // Build a ResolutionMatrix from a hand-constructed plan with
1161 // triangular-kernel rows — the same `make_synthetic_overlap_plan`
1162 // approach used in `resolution.rs` tests, inlined here to avoid
1163 // cross-module test visibility.
1164 let mut starts: Vec<u32> = Vec::with_capacity(n_grid + 1);
1165 starts.push(0);
1166 let mut lo_idx: Vec<u32> = Vec::new();
1167 let mut frac_arr: Vec<f64> = Vec::new();
1168 let mut weight_arr: Vec<f64> = Vec::new();
1169 let mut norm: Vec<f64> = Vec::with_capacity(n_grid);
1170 for i in 0..n_grid {
1171 let lo_min = i.saturating_sub(half_kernel);
1172 let lo_max = (i + half_kernel).min(n_grid - 2);
1173 let mut row_norm = 0.0_f64;
1174 for lo in lo_min..=lo_max {
1175 let d = (lo as i64 - i as i64).abs() as f64;
1176 let w = 1.0 - d / (half_kernel as f64 + 1.0);
1177 lo_idx.push(lo as u32);
1178 frac_arr.push(0.5);
1179 weight_arr.push(w);
1180 row_norm += w;
1181 }
1182 norm.push(row_norm);
1183 starts.push(lo_idx.len() as u32);
1184 }
1185 // Use the raw constructor via compile_to_matrix on a
1186 // manually-assembled plan. ResolutionPlan's fields are
1187 // crate-private, so we build it via the canonical plan
1188 // constructor (`TabulatedResolution::plan`) would require a
1189 // kernel — so we instead invoke the test-visible constructor
1190 // pattern the resolution module already uses internally.
1191 //
1192 // For the surrogate tests we only need the compiled matrix,
1193 // not the plan; we therefore build the ResolutionMatrix
1194 // directly (mirroring compile_to_matrix's output format)
1195 // without going through ResolutionPlan. This is done by
1196 // constructing the plan via the public `plan()` route from
1197 // a trivial TabulatedResolution proxy: a single-energy,
1198 // delta-kernel resolution that produces identity rows; then
1199 // overriding via a synthetic plan fixture would require
1200 // crate-private access.
1201 //
1202 // Simplest path: use a minimal `ResolutionPlan` surrogate by
1203 // directly building a `ResolutionMatrix`-equivalent CSR via
1204 // the `ResolutionPlan::compile_to_matrix` pathway. Since
1205 // that method consumes only the public fields above, we
1206 // expose a test-only helper `from_raw_parts` on ResolutionPlan.
1207 // (Added in this module as `SyntheticPlanBuilder` below.)
1208 let plan =
1209 SyntheticPlanBuilder::new(energies.clone(), starts, lo_idx, frac_arr, weight_arr, norm)
1210 .build();
1211 let matrix = plan.compile_to_matrix();
1212
1213 // Synthetic σ: k independent Gaussian resonances per isotope at
1214 // distinct energies, bounded in a physically plausible range.
1215 let mut sigmas = vec![0.0_f64; k * n_grid];
1216 for j in 0..k {
1217 let e_center = 10.0 + (j as f64 + 1.0) * (n_grid as f64) / (k as f64 + 1.0);
1218 let width = 3.0;
1219 for ell in 0..n_grid {
1220 let e = 10.0 + ell as f64;
1221 let g = (-((e - e_center).powi(2)) / (width * width)).exp();
1222 sigmas[j * n_grid + ell] = 100.0 * g + 5.0;
1223 }
1224 }
1225 let plan_arc = std::sync::Arc::new(plan);
1226 (energies, sigmas, matrix, plan_arc)
1227 }
1228
1229 /// Helper that exposes a way to build a `ResolutionPlan` from raw
1230 /// parts — needed because the fields are private to
1231 /// `resolution.rs`. This test-only wrapper uses the same round-
1232 /// trip trick the resolution tests use: build via the public
1233 /// `TabulatedResolution::plan` surface on a trivial grid. For the
1234 /// purpose of surrogate tests we don't care that the raw plan
1235 /// weights differ from what a real kernel would produce — what
1236 /// matters is that `compile_to_matrix` produces a valid CSR.
1237 struct SyntheticPlanBuilder {
1238 energies: Vec<f64>,
1239 starts: Vec<u32>,
1240 lo_idx: Vec<u32>,
1241 frac: Vec<f64>,
1242 weight: Vec<f64>,
1243 norm: Vec<f64>,
1244 }
1245
1246 impl SyntheticPlanBuilder {
1247 fn new(
1248 energies: Vec<f64>,
1249 starts: Vec<u32>,
1250 lo_idx: Vec<u32>,
1251 frac: Vec<f64>,
1252 weight: Vec<f64>,
1253 norm: Vec<f64>,
1254 ) -> Self {
1255 Self {
1256 energies,
1257 starts,
1258 lo_idx,
1259 frac,
1260 weight,
1261 norm,
1262 }
1263 }
1264
1265 /// Build a `ResolutionPlan` by going through the crate-public
1266 /// test-only constructor exposed on the resolution module.
1267 fn build(self) -> ResolutionPlan {
1268 crate::resolution::test_support::plan_from_raw_parts(
1269 self.energies,
1270 self.starts,
1271 self.lo_idx,
1272 self.frac,
1273 self.weight,
1274 self.norm,
1275 )
1276 }
1277 }
1278
1279 // ---------- Tests ----------
1280
1281 #[test]
1282 fn cubature_rejects_zero_isotopes() {
1283 let (_e, _s, matrix, _plan) = synthetic_setup(20, 3, 2);
1284 let err = SparseEmpiricalCubaturePlan::build(&matrix, &[], 0, &[vec![0.0]], &[0.0])
1285 .expect_err("k = 0 must reject");
1286 assert!(matches!(err, CubatureBuildError::ZeroIsotopes));
1287 }
1288
1289 #[test]
1290 fn cubature_rejects_mismatched_sigmas() {
1291 let (_e, _s, matrix, _plan) = synthetic_setup(20, 3, 2);
1292 let err = SparseEmpiricalCubaturePlan::build(
1293 &matrix,
1294 &[0.0; 7], // wrong length
1295 2,
1296 &[vec![1e-4, 1e-4]],
1297 &[1e-4, 1e-4],
1298 )
1299 .expect_err("sigma grid mismatch must reject");
1300 assert!(matches!(err, CubatureBuildError::SigmaGridMismatch { .. }));
1301 }
1302
1303 #[test]
1304 fn cubature_empty_matrix_empty_plan() {
1305 // Reuse the synthetic fabric but with n_grid = 0 — the helper
1306 // can't produce that directly (assertion), so build an empty
1307 // matrix via a zero-row plan.
1308 let plan = crate::resolution::test_support::plan_from_raw_parts(
1309 Vec::new(),
1310 vec![0_u32],
1311 Vec::new(),
1312 Vec::new(),
1313 Vec::new(),
1314 Vec::new(),
1315 );
1316 let matrix = plan.compile_to_matrix();
1317 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &[], 3, &[vec![0.0; 3]], &[0.0; 3])
1318 .expect("empty matrix must build empty cubature");
1319 assert_eq!(cub.len(), 0);
1320 assert!(cub.is_empty());
1321 assert_eq!(cub.n_atoms(), 0);
1322 assert!(cub.target_energies().is_empty());
1323 }
1324
1325 #[test]
1326 fn cubature_target_energies_mirror_matrix_grid() {
1327 let (energies, sigmas, matrix, _plan) = synthetic_setup(20, 3, 2);
1328 let train_max = [1e-4_f64, 1e-4];
1329 let training = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1330 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1331 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &sigmas, 2, &training, &anchor)
1332 .expect("build");
1333 // target_energies must byte-match the matrix's stored grid so
1334 // callers can use it as a cache key (same pattern as
1335 // ResolutionPlan / ResolutionMatrix).
1336 assert_eq!(cub.target_energies(), matrix.target_energies());
1337 assert_eq!(cub.target_energies(), energies.as_slice());
1338 }
1339
1340 #[test]
1341 fn cubature_default_training_points_shape() {
1342 let train_max = [1e-4_f64, 2e-4, 5e-5];
1343 let pts = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1344 // S = k + 2 = 5 points for k = 3.
1345 assert_eq!(pts.len(), 5);
1346 for p in &pts {
1347 assert_eq!(p.len(), 3);
1348 }
1349 // First two points are quarter / three-quarter of train_max.
1350 for (i, &m) in train_max.iter().enumerate() {
1351 assert!((pts[0][i] - 0.25 * m).abs() < 1e-15);
1352 assert!((pts[1][i] - 0.75 * m).abs() < 1e-15);
1353 }
1354 // Remaining k points are axis-aligned.
1355 for (i, &max_i) in train_max.iter().enumerate() {
1356 for (j, &value) in pts[2 + i].iter().enumerate() {
1357 let expected = if i == j { max_i } else { 0.0 };
1358 assert!((value - expected).abs() < 1e-15);
1359 }
1360 }
1361 // Anchor is the midpoint.
1362 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1363 for (i, &m) in train_max.iter().enumerate() {
1364 assert!((anchor[i] - 0.5 * m).abs() < 1e-15);
1365 }
1366 }
1367
1368 /// Zero-weight CSR cells retained by
1369 /// [`crate::resolution::ResolutionPlan::compile_to_matrix`] for
1370 /// NaN-safety (the `frac == +0.0` branch) MUST NOT become
1371 /// cubature atoms, even though the LP's zero objective would let
1372 /// the simplex put arbitrary mass on them. This test guards
1373 /// against regression.
1374 #[test]
1375 fn cubature_rejects_zero_weight_csr_cells_as_atoms() {
1376 // Hand-construct a 5-cell synthetic plan where every
1377 // regular-bracket entry has `frac = +0.0`, producing CSR
1378 // rows with an explicit `(lo + 1, 0.0)` zero-weight column.
1379 let energies: Vec<f64> = (0..5).map(|i| 10.0 + i as f64).collect();
1380 let mut starts: Vec<u32> = vec![0];
1381 let mut lo_idx: Vec<u32> = Vec::new();
1382 let mut frac: Vec<f64> = Vec::new();
1383 let mut weight: Vec<f64> = Vec::new();
1384 let mut norm: Vec<f64> = Vec::new();
1385 for i in 0..5 {
1386 // Row i: one regular-bracket entry at lo = i.min(3) with
1387 // frac = +0.0. This produces CSR columns {i.min(3),
1388 // i.min(3) + 1} with values {1.0, 0.0} respectively.
1389 let lo = i.min(3);
1390 lo_idx.push(lo as u32);
1391 frac.push(0.0); // +0.0, not the -0.0 sentinel
1392 weight.push(1.0);
1393 norm.push(1.0);
1394 starts.push(lo_idx.len() as u32);
1395 }
1396 let plan = crate::resolution::test_support::plan_from_raw_parts(
1397 energies, starts, lo_idx, frac, weight, norm,
1398 );
1399 let matrix = plan.compile_to_matrix();
1400
1401 // Confirm the matrix actually has zero-weight CSR cells.
1402 let total_nnz = matrix.nnz();
1403 let zero_weight_cells = matrix.values().iter().filter(|&&v| v == 0.0).count();
1404 assert!(
1405 zero_weight_cells > 0,
1406 "test fixture must include zero-weight CSR cells — got {total_nnz} nnz, {zero_weight_cells} zero",
1407 );
1408
1409 // Build a cubature. The resulting atoms must correspond ONLY
1410 // to CSR cells with non-zero weight.
1411 let sigmas = vec![0.5_f64, 1.0, 1.5, 2.0, 2.5];
1412 let train_max = [1e-4_f64];
1413 let training = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1414 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1415 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &sigmas, 1, &training, &anchor)
1416 .expect("build must succeed on zero-weight-cell fixture");
1417
1418 // Collect the σ values retained as atoms; each must correspond
1419 // to a support column with non-zero CSR value. With k = 1,
1420 // the atom sigma is either 0.5, 1.0, 1.5, 2.0, or 2.5 —
1421 // whichever column was non-zero in the source row.
1422 for (i, window) in cub.row_starts().windows(2).enumerate() {
1423 let (s, e) = (window[0] as usize, window[1] as usize);
1424 for q in s..e {
1425 let atom_sigma = cub.atoms()[q];
1426 // The corresponding CSR cell at the nearest source
1427 // column must have non-zero weight.
1428 let row_start = matrix.row_starts()[i] as usize;
1429 let row_end = matrix.row_starts()[i + 1] as usize;
1430 let row_cols = &matrix.col_indices()[row_start..row_end];
1431 let row_vals = &matrix.values()[row_start..row_end];
1432 let source_nonzero = row_cols
1433 .iter()
1434 .zip(row_vals)
1435 .find(|&(&col, _)| (sigmas[col as usize] - atom_sigma).abs() < 1e-15)
1436 .map(|(_, &v)| v);
1437 assert!(
1438 source_nonzero.is_some() && source_nonzero.unwrap() > 0.0,
1439 "row {i} atom sigma {atom_sigma} has no non-zero source in CSR row",
1440 );
1441 }
1442 }
1443 }
1444
1445 #[test]
1446 fn cubature_build_error_display() {
1447 // Cover each error variant's Display message so a future
1448 // refactor that breaks the formatting fails loudly.
1449 let e = CubatureBuildError::ZeroIsotopes;
1450 assert!(format!("{e}").contains("at least one isotope"));
1451
1452 let e = CubatureBuildError::ZeroTrainingDensities;
1453 assert!(format!("{e}").contains("at least one training density"));
1454
1455 let e = CubatureBuildError::SigmaGridMismatch {
1456 expected: 100,
1457 actual: 50,
1458 };
1459 let s = format!("{e}");
1460 assert!(s.contains("sigmas") && s.contains("100") && s.contains("50"));
1461
1462 let e = CubatureBuildError::TrainingDensityLength {
1463 expected: 3,
1464 actual: 2,
1465 index: 7,
1466 };
1467 let s = format!("{e}");
1468 assert!(s.contains("training_densities[7]") && s.contains("length 2"));
1469
1470 let e = CubatureBuildError::AnchorLength {
1471 expected: 3,
1472 actual: 5,
1473 };
1474 let s = format!("{e}");
1475 assert!(s.contains("jacobian_anchor") && s.contains("length 5"));
1476
1477 let e = CubatureBuildError::LpInfeasible { row: 42 };
1478 assert!(format!("{e}").contains("row 42"));
1479 }
1480
1481 /// Forward equivalence at the training densities: the cubature's
1482 /// feasibility LP pins `phi_fwd @ x = phi_fwd @ w_exact`, so
1483 /// `cubature.forward(n^(s))` equals `sum_q R_{iq} exp(-n^(s)
1484 /// · σ_q)` (the exact surrogate output) at every training density
1485 /// `n^(s)` — row by row.
1486 #[test]
1487 fn cubature_forward_matches_exact_at_training_densities() {
1488 let (_e, sigmas, matrix, _plan) = synthetic_setup(40, 4, 2);
1489 let train_max = [2e-4_f64, 1.5e-4];
1490 let training = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1491 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1492 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &sigmas, 2, &training, &anchor)
1493 .expect("build");
1494
1495 for (s, n) in training.iter().enumerate() {
1496 let t_cub = cub.forward(n);
1497 let t_exact = exact_forward(&matrix, &sigmas, 2, n);
1498 let max_err = max_hybrid_err(&t_cub, &t_exact);
1499 assert!(
1500 max_err < 1e-9,
1501 "training[{s}] n={n:?} max hybrid err = {max_err:.3e} (expected < 1e-9)",
1502 );
1503 }
1504 }
1505
1506 /// Forward accuracy at a held-out density inside the training
1507 /// convex hull: the cubature's bias should be bounded (Jensen-like
1508 /// term on the missing feature directions) but still within the
1509 /// ≤1e-3 max abs error band the design study measured on real VENUS.
1510 #[test]
1511 fn cubature_forward_held_out_bounded_error() {
1512 let (_e, sigmas, matrix, _plan) = synthetic_setup(40, 4, 2);
1513 let train_max = [2e-4_f64, 1.5e-4];
1514 let training = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1515 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1516 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &sigmas, 2, &training, &anchor)
1517 .expect("build");
1518
1519 // Moderate density at 50 % of the box, both isotopes active.
1520 let n_test = vec![0.5 * train_max[0], 0.5 * train_max[1]];
1521 let t_cub = cub.forward(&n_test);
1522 let t_exact = exact_forward(&matrix, &sigmas, 2, &n_test);
1523 let max_abs = t_cub
1524 .iter()
1525 .zip(t_exact.iter())
1526 .map(|(a, b)| (a - b).abs())
1527 .fold(0.0_f64, f64::max);
1528 assert!(
1529 max_abs < 1e-2,
1530 "held-out max abs err = {max_abs:.3e} (expected < 1e-2)",
1531 );
1532 }
1533
1534 /// Jacobian at the anchor density: the cubature's LP pins
1535 /// `phi_grad @ x = phi_grad @ w_exact`, so the Jacobian columns at
1536 /// `n*` should match the exact Jacobian `-R[-σ_ℓ exp(-n* · σ)]` to
1537 /// LP tolerance.
1538 #[test]
1539 fn cubature_jacobian_matches_exact_at_anchor() {
1540 let (_e, sigmas, matrix, _plan) = synthetic_setup(30, 4, 3);
1541 let train_max = [2e-4_f64, 1.5e-4, 1e-4];
1542 let training = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1543 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1544 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &sigmas, 3, &training, &anchor)
1545 .expect("build");
1546
1547 let (_t_cub, j_cub) = cub.forward_and_jacobian(&anchor);
1548 let j_exact = exact_jacobian(&matrix, &sigmas, 3, &anchor);
1549 let max_err = max_hybrid_err(&j_cub, &j_exact);
1550 // Looser than the forward-at-training-densities bound (1e-9)
1551 // because Jacobian features `σ_ℓ · exp(-n · σ)` have magnitudes
1552 // O(50) (σ in barns) vs forward features' O(1). The simplex
1553 // solver's equality residuals accumulate ~1e-8 abs error which
1554 // is LP precision, not a cubature correctness issue — the study's
1555 // Python reference implementation hits the same band.
1556 assert!(
1557 max_err < 1e-7,
1558 "Jacobian at anchor max hybrid err = {max_err:.3e} (expected < 1e-7)",
1559 );
1560 }
1561
1562 /// Row weights sum to 1 after renormalization.
1563 #[test]
1564 fn cubature_rows_are_probability_measures() {
1565 let (_e, sigmas, matrix, _plan) = synthetic_setup(30, 4, 2);
1566 let train_max = [2e-4_f64, 1.5e-4];
1567 let training = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1568 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1569 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &sigmas, 2, &training, &anchor)
1570 .expect("build");
1571 for i in 0..cub.len() {
1572 let s = cub.row_starts()[i] as usize;
1573 let e = cub.row_starts()[i + 1] as usize;
1574 let row_sum: f64 = cub.weights()[s..e].iter().sum();
1575 assert!(
1576 (row_sum - 1.0).abs() < 1e-12,
1577 "row {i} sum = {row_sum} (expected 1.0 within 1e-12)",
1578 );
1579 }
1580 }
1581
1582 /// k = 6 curse-of-dim stress: confirm the build succeeds, atoms
1583 /// stay bounded (~S+k+1 per row), and held-out forward error stays
1584 /// modest. Mirrors the design study's k = 6 independent-Hf scenario in
1585 /// structural shape.
1586 #[test]
1587 fn cubature_k6_builds_and_evaluates() {
1588 let (_e, sigmas, matrix, _plan) = synthetic_setup(30, 4, 6);
1589 let train_max: Vec<f64> = (0..6).map(|j| 1e-4 * (1.0 + 0.2 * j as f64)).collect();
1590 // S training points = 2 midpoints + k axis-aligned points = 8.
1591 let training = SparseEmpiricalCubaturePlan::default_training_points(&train_max);
1592 let anchor = SparseEmpiricalCubaturePlan::default_jacobian_anchor(&train_max);
1593 let cub = SparseEmpiricalCubaturePlan::build(&matrix, &sigmas, 6, &training, &anchor)
1594 .expect("k=6 build");
1595
1596 // Atom counts: the Carathéodory bound is S + k + 1 = 15.
1597 // The LP may produce fewer (columns genuinely redundant). Allow
1598 // a small slack above the theoretical bound for numerical edge
1599 // cases.
1600 let max_atoms = cub
1601 .row_starts()
1602 .windows(2)
1603 .map(|w| (w[1] - w[0]) as usize)
1604 .max()
1605 .unwrap_or(0);
1606 assert!(
1607 max_atoms <= 18,
1608 "k=6 max atoms/row = {max_atoms} (expected ≤ 18 = S+k+1+slack)",
1609 );
1610
1611 // Forward at held-out density inside the box.
1612 let n_test: Vec<f64> = train_max.iter().map(|&x| 0.4 * x).collect();
1613 let t_cub = cub.forward(&n_test);
1614 let t_exact = exact_forward(&matrix, &sigmas, 6, &n_test);
1615 let max_abs = t_cub
1616 .iter()
1617 .zip(t_exact.iter())
1618 .map(|(a, b)| (a - b).abs())
1619 .fold(0.0_f64, f64::max);
1620 assert!(
1621 max_abs < 1e-2,
1622 "k=6 held-out max abs err = {max_abs:.3e} (expected < 1e-2)",
1623 );
1624 }
1625
1626 // ---------- helpers ----------
1627
1628 fn exact_forward(
1629 matrix: &crate::resolution::ResolutionMatrix,
1630 sigmas: &[f64],
1631 k: usize,
1632 n: &[f64],
1633 ) -> Vec<f64> {
1634 let n_rows = matrix.len();
1635 // T_un[ℓ] = exp(-Σ_j n_j σ_j(ℓ)).
1636 let mut t_un = vec![0.0_f64; n_rows];
1637 for (ell, t) in t_un.iter_mut().enumerate() {
1638 let mut dot = 0.0_f64;
1639 for j in 0..k {
1640 dot += n[j] * sigmas[j * n_rows + ell];
1641 }
1642 *t = (-dot).exp();
1643 }
1644 crate::resolution::apply_r(matrix, &t_un)
1645 }
1646
1647 fn exact_jacobian(
1648 matrix: &crate::resolution::ResolutionMatrix,
1649 sigmas: &[f64],
1650 k: usize,
1651 n: &[f64],
1652 ) -> Vec<f64> {
1653 let n_rows = matrix.len();
1654 let mut jac = vec![0.0_f64; n_rows * k];
1655 // ∂T_i/∂n_ℓ = -Σ_q R_{iq} σ_ℓ(q) exp(-n · σ_q).
1656 let mut t_un = vec![0.0_f64; n_rows];
1657 for (q, t) in t_un.iter_mut().enumerate() {
1658 let mut dot = 0.0_f64;
1659 for j in 0..k {
1660 dot += n[j] * sigmas[j * n_rows + q];
1661 }
1662 *t = (-dot).exp();
1663 }
1664 for ell in 0..k {
1665 let mut inner = vec![0.0_f64; n_rows];
1666 for q in 0..n_rows {
1667 inner[q] = -sigmas[ell * n_rows + q] * t_un[q];
1668 }
1669 let col = crate::resolution::apply_r(matrix, &inner);
1670 for (i, &v) in col.iter().enumerate() {
1671 jac[i * k + ell] = v;
1672 }
1673 }
1674 jac
1675 }
1676
1677 fn max_hybrid_err(a: &[f64], b: &[f64]) -> f64 {
1678 a.iter()
1679 .zip(b)
1680 .map(|(x, y)| {
1681 let denom = x.abs().max(y.abs()).max(1e-12);
1682 (x - y).abs() / denom
1683 })
1684 .fold(0.0_f64, f64::max)
1685 }
1686
1687 // ---------------------------------------------------------------
1688 // VENUS-like cubature regression
1689 // (`cubature_real_venus_k1_forward_equivalence`) moved to
1690 // `crates/nereids-physics/tests/venus_usr_surrogate.rs` — see
1691 // issues #497 and #557. It parses a synthetic SAMMY USR-format
1692 // kernel via `common::synthetic_venus_usr_tab()`.
1693 // ---------------------------------------------------------------
1694
1695 // ── Scalar (k = 1) surrogate tests ─────────────────────────────
1696
1697 /// Build a 1-isotope synthetic σ + matrix pair, shared by both
1698 /// scalar surrogate tests.
1699 fn scalar_setup(
1700 n_grid: usize,
1701 half_kernel: usize,
1702 ) -> (
1703 Vec<f64>,
1704 crate::resolution::ResolutionMatrix,
1705 std::sync::Arc<crate::resolution::ResolutionPlan>,
1706 ) {
1707 let (_e, sigmas, matrix, plan) = synthetic_setup(n_grid, half_kernel, 1);
1708 (sigmas, matrix, plan) // sigmas for k=1 is flat length n_grid
1709 }
1710
1711 #[test]
1712 fn scalar_chebyshev_matches_exact_at_multiple_densities() {
1713 let (sigmas_flat, matrix, res_plan) = scalar_setup(40, 4);
1714 let sigma = &sigmas_flat;
1715 let n_max = 2e-4_f64;
1716 let plan =
1717 ScalarChebyshevPlan::build(res_plan, sigma, n_max, 16).expect("build chebyshev plan");
1718 for n in [1e-5_f64, 1e-4, 1.6e-4] {
1719 let t_plan = plan.forward_scalar(n);
1720 let t_un: Vec<f64> = sigma.iter().map(|&s| (-n * s).exp()).collect();
1721 let t_exact = crate::resolution::apply_r(&matrix, &t_un);
1722 let max_err = max_hybrid_err(&t_plan, &t_exact);
1723 // Chebyshev accuracy depends on M; for M = 16 on a
1724 // bounded T ∈ [0, 1] signal, expect ≤ 1e-8.
1725 assert!(
1726 max_err < 1e-8,
1727 "Chebyshev vs exact at n = {n:.1e}: max hybrid err = {max_err:.3e}",
1728 );
1729 }
1730 }
1731
1732 #[test]
1733 fn scalar_chebyshev_derivative_matches_finite_difference() {
1734 let (sigmas_flat, _matrix, res_plan) = scalar_setup(30, 4);
1735 let sigma = &sigmas_flat;
1736 let n_max = 2e-4_f64;
1737 let plan = ScalarChebyshevPlan::build(res_plan, sigma, n_max, 16).expect("build");
1738 let n = 1.6e-4_f64;
1739 let h = 1e-8_f64;
1740 let (_t, dt_an) = plan.forward_and_derivative_scalar(n);
1741 let t_plus = plan.forward_scalar(n + h);
1742 let t_minus = plan.forward_scalar(n - h);
1743 for i in 0..plan.len() {
1744 let dt_fd = (t_plus[i] - t_minus[i]) / (2.0 * h);
1745 let denom = dt_an[i].abs().max(dt_fd.abs()).max(1e-12);
1746 let rel = (dt_an[i] - dt_fd).abs() / denom;
1747 assert!(
1748 rel < 1e-4,
1749 "row {i}: analytic {} vs FD {} rel = {:.3e}",
1750 dt_an[i],
1751 dt_fd,
1752 rel,
1753 );
1754 }
1755 }
1756
1757 #[test]
1758 fn scalar_chebyshev_rejects_invalid_box() {
1759 let (sigmas_flat, _matrix, res_plan) = scalar_setup(20, 3);
1760 let err =
1761 ScalarChebyshevPlan::build(std::sync::Arc::clone(&res_plan), &sigmas_flat, 0.0, 16)
1762 .expect_err("n_max = 0 must reject");
1763 assert!(matches!(
1764 err,
1765 ScalarSurrogateBuildError::InvalidChebyshevBox { .. }
1766 ));
1767 let err = ScalarChebyshevPlan::build(res_plan, &sigmas_flat, 1e-4, 1)
1768 .expect_err("M = 1 must reject");
1769 assert!(matches!(
1770 err,
1771 ScalarSurrogateBuildError::InvalidChebyshevBox { .. }
1772 ));
1773 }
1774
1775 #[test]
1776 fn scalar_chebyshev_rejects_overwide_box() {
1777 // The build-time self-check refuses
1778 // boxes where 16-node Chebyshev can't resolve the
1779 // exp(-n · σ) surface. A pathologically wide box on the
1780 // synthetic σ used by scalar_setup exceeds the 1e-6
1781 // tolerance and must be rejected.
1782 let (sigma, _matrix, res_plan) = scalar_setup(40, 4);
1783 // The scalar_setup σ has max ≈ 105 on a Gaussian peak.
1784 // At n_max = 2.0, τ_peak ≈ 210 → exp(-210) becomes
1785 // extremely small (~7e-92, still representable in f64 but
1786 // way below the dynamic range where smooth interpolation
1787 // converges). The Chebyshev polynomial can't track this
1788 // with M = 16 nodes. Must reject at build time rather
1789 // than quietly return a plan that produces huge forward
1790 // errors on dispatch.
1791 let err = ScalarChebyshevPlan::build(res_plan, &sigma, 2.0, 16)
1792 .expect_err("overwide box must reject");
1793 match err {
1794 ScalarSurrogateBuildError::InsufficientAccuracyOnBox {
1795 n_max,
1796 m,
1797 max_rel_err,
1798 tolerance,
1799 } => {
1800 assert_eq!(n_max, 2.0);
1801 assert_eq!(m, 16);
1802 assert!(
1803 max_rel_err > tolerance,
1804 "expected max_rel_err {max_rel_err:.3e} > tolerance {tolerance:.0e}",
1805 );
1806 }
1807 other => panic!("expected InsufficientAccuracyOnBox, got {other:?}"),
1808 }
1809 }
1810
1811 #[test]
1812 fn scalar_plan_rejects_sigma_size_mismatch() {
1813 let (_sigmas_flat, _matrix, res_plan) = scalar_setup(20, 3);
1814 let wrong = vec![0.0_f64; 15];
1815 let err = ScalarChebyshevPlan::build(res_plan, &wrong, 1e-4, 16)
1816 .expect_err("Chebyshev sigma mismatch must reject");
1817 assert!(matches!(
1818 err,
1819 ScalarSurrogateBuildError::SigmaGridMismatch { .. }
1820 ));
1821 }
1822
1823 // ---------------------------------------------------------------
1824 // VENUS-like scalar Chebyshev regression
1825 // (`scalar_chebyshev_real_venus_k1_regression`) moved to
1826 // `crates/nereids-physics/tests/venus_usr_surrogate.rs` — see
1827 // issues #497 and #557. It parses a synthetic SAMMY USR-format
1828 // kernel via `common::synthetic_venus_usr_tab()`.
1829 // ---------------------------------------------------------------
1830}