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