gam_solve/estimate/outer_eval_capture.rs
1//! Structured capture of outer-objective evidence for integration tests.
2//!
3//! The raw-evaluation window serves flexible-link measurements (#1876). The
4//! finite-difference record serves end-to-end gradient gates (#2460): when
5//! explicitly enabled, the generic outer runner compares the analytic gradient
6//! at its first bounded seed with a finite difference of that same objective.
7//! Tests consume typed arrays rather than scraping formatted production logs.
8//!
9//! Both channels are disabled by default. The raw window is process-global
10//! because its flexible-link measurements intentionally span helper calls. The
11//! finite-difference request is thread-local: a parallel integration test can
12//! neither consume nor overwrite another test's one-shot audit.
13
14use ndarray::{Array1, Array2};
15use std::cell::RefCell;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Mutex, OnceLock};
18
19/// One captured outer evaluation: the outer coordinate `theta = (ρ ‖ link)`, the
20/// scalar cost, and the analytic outer gradient in the same layout.
21#[derive(Clone, Debug)]
22pub struct OuterEvalRecord {
23 pub theta: Array1<f64>,
24 pub cost: f64,
25 pub gradient: Array1<f64>,
26}
27
28/// Analytic-vs-finite-difference evidence for the ψ block at one real outer
29/// seed.
30///
31/// `theta` retains the complete outer seed and `rho_dim` locates the ψ block in
32/// that seed. Every gradient and scalar-stencil array contains exactly
33/// `psi_dim` entries in ψ-local order. Smoothing-parameter ρ coordinates are
34/// deliberately excluded: the κ/geometry gates that request this record do not
35/// grade them, and each unnecessary finite-difference coordinate costs two
36/// complete inner profiles.
37#[derive(Clone, Debug)]
38pub struct OuterGradientFdRecord {
39 pub theta: Array1<f64>,
40 pub rho_dim: usize,
41 pub psi_dim: usize,
42 pub cost: f64,
43 pub analytic_psi_gradient: Array1<f64>,
44 pub finite_difference_psi_gradient: Array1<f64>,
45 pub psi_steps: Array1<f64>,
46 /// Ridders' estimate of each ψ finite difference's OWN error, and the
47 /// truncation order of the accepted extrapolant (`2` is a raw stencil, `4`
48 /// one Richardson stage, …).
49 ///
50 /// Present because a finite difference is an estimator: without it a
51 /// consumer cannot tell an analytic-gradient defect from its own oracle's
52 /// truncation, and has to grade at whatever tolerance the worst step
53 /// happens to need — which is how these gates ended up at `5e-2` (#2461).
54 /// `f64::INFINITY` marks a coordinate the ladder could not resolve; such a
55 /// component says nothing about the analytic gradient.
56 pub psi_fd_uncertainty: Array1<f64>,
57 pub psi_fd_orders: Vec<usize>,
58
59 /// Max-abs of the `#1033b` psi-Gram anchor correction applied to the
60 /// criterion's VALUE at this seed, as `(gram_delta, rhs_delta)`.
61 ///
62 /// `joint_hyper` pins the n-free tensor to the exactly streamed statistics
63 /// by adding a constant offset measured at one reference psi, then installs
64 /// the derivative of the UNCORRECTED tensor. A constant removes nothing from
65 /// a derivative, so a non-zero value here is the tensor's own value error at
66 /// this seed and its SLOPE error is loose in the gradient lane (#2464).
67 ///
68 /// `None` means the correction never ran on this seed -- NOT that it ran and
69 /// was zero. The distinction is the whole point of the field: a probe that
70 /// reports `0.0` for "never fired" is unfalsifiable, and reading an absent
71 /// emission as a measured zero is exactly how this quantity was first
72 /// mis-measured.
73 pub psi_gram_anchor_deltas: Option<(f64, f64)>,
74 /// The per-atom breakdown of the same comparison, when the objective's
75 /// criterion is assembled from atoms at all.
76 pub decomposition: OuterGradientFdDecomposition,
77 /// The SAME comparison for the ρ (log-smoothing) block, when the caller
78 /// armed [`enable_outer_gradient_fd_capture_over_theta`].
79 ///
80 /// `None` is the default and means the ρ coordinates were not differenced —
81 /// NOT that they agreed. The two blocks are separate fields rather than one
82 /// θ-indexed array because grading ρ is a deliberate, expensive opt-in: each
83 /// coordinate costs a full Ridders ladder of inner profiles, and the shipped
84 /// κ/geometry gates that consume the ψ block do not grade ρ.
85 pub rho: Option<OuterGradientFdRhoBlock>,
86 /// The curvature the `logdet_h` atom is taken on, differenced as a MATRIX
87 /// against the analytic drift the same evaluation published (#2765).
88 ///
89 /// `None` when the backend cannot hand out a dense `H`. See
90 /// [`OuterCurvatureDriftAudit`] for why a scalar-only audit cannot decide
91 /// what this decides.
92 pub curvature: Option<OuterCurvatureDriftAudit>,
93}
94
95/// The criterion's own curvature and its per-θ-coordinate analytic drift, as
96/// MATRICES, at one evaluation point (#2765).
97///
98/// The scalar audit above compares `½ tr(K·Ḣ_i)` against a finite difference of
99/// `½ log|H|`. When those disagree, three objects could be at fault and the
100/// scalar cannot separate them: the curvature `H` itself, the drift `Ḣ_i`, or
101/// the trace kernel `K` the cost's log-determinant pairs with. Differencing `H`
102/// as a matrix and comparing it to `Ḣ_i` entry by entry answers the middle
103/// question outright, and answers it in a form that names WHICH block of the
104/// joint coefficient space is wrong rather than reporting one contracted number.
105#[derive(Clone, Debug)]
106pub struct OuterCurvatureSnapshot {
107 /// The dense curvature whose log-determinant the `logdet_h` atom reports.
108 pub hessian: Array2<f64>,
109 /// The orthonormal tangent basis `Z` of `null(A_act)` when the inner solve
110 /// returned on an active inequality face and the criterion is therefore the
111 /// TANGENT-projected `½log|ZᵀHZ|` (#2765).
112 ///
113 /// Recorded because it is the coordinate system the whole comparison lives
114 /// in: a drift stated in `p`-space says nothing about a determinant taken on
115 /// an `m`-dimensional face, and a face that MOVES between two displaced θ is
116 /// not a differentiable criterion at all — a distinction the contracted
117 /// scalar cannot express and the previous audit silently averaged over.
118 pub tangent_basis: Option<Array2<f64>>,
119 /// `log|H|` as the criterion consumes it, i.e. the operator's own
120 /// log-determinant plus any uniform-rescale correction. Recorded so a gap
121 /// against `½ log det(hessian)` — a value/kernel disagreement rather than a
122 /// derivative one — is visible instead of assumed absent.
123 pub logdet: f64,
124 /// Total analytic `Ḣ_i` per θ coordinate, ρ block first then ψ, densified.
125 pub drifts: Vec<Array2<f64>>,
126}
127
128/// Per-θ-coordinate evidence that the analytic drift IS the derivative of the
129/// curvature the criterion's log-determinant is taken on (#2765).
130#[derive(Clone, Debug)]
131pub struct OuterCurvatureDriftAudit {
132 /// `‖Ḣ_i^analytic − (H(θ+h) − H(θ−h))/2h‖_max` per θ coordinate.
133 pub drift_max_abs_error: Array1<f64>,
134 /// The same, relative to `‖Ḣ_i‖_max` of the two.
135 pub drift_relative_error: Array1<f64>,
136 /// `(row, col)` of the entry carrying `drift_max_abs_error`.
137 pub drift_worst_entry: Vec<(usize, usize)>,
138 /// `‖Z₊Z₊ᵀ − Z₋Z₋ᵀ‖_max` per θ coordinate: how far the ACTIVE FACE the
139 /// criterion's determinant is taken on moves between the two displaced
140 /// evaluations. A non-zero entry means the finite difference straddles two
141 /// different criteria, so the analytic derivative is not wrong there — the
142 /// question is not well posed there.
143 pub face_drift_max_abs: Array1<f64>,
144 /// Tangent dimension at the base point, and at each coordinate's `θ ± h`.
145 pub tangent_dim: Option<usize>,
146 pub displaced_tangent_dim: Vec<(Option<usize>, Option<usize>)>,
147 /// The two objects the comparison is between, retained per θ coordinate:
148 /// the analytic `ZᵀḢ_iZ` and the measured `d(ZᵀHZ)/dθ_i`. A max-abs number
149 /// says a drift is wrong; these say HOW — whether the error is a multiple
150 /// of the face curvature, a rank-one leak, or one block's own.
151 pub analytic_face_drift: Vec<Array2<f64>>,
152 pub measured_face_drift: Vec<Array2<f64>>,
153 /// `ZᵀHZ` itself, the curvature whose determinant the atom reports.
154 pub face_curvature: Array2<f64>,
155 /// `‖Ḣ_i^analytic‖_max`, so a relative error can be read against a scale.
156 pub analytic_drift_max_abs: Array1<f64>,
157 /// `½ log det(H)` recomputed from the captured dense matrix, against the
158 /// `logdet` the criterion actually consumed. A gap here means the operator's
159 /// log-determinant is not the plain one of this matrix (spectral
160 /// regularization, a rank mask, a uniform rescale), which changes what the
161 /// trace kernel has to be.
162 pub dense_half_logdet: f64,
163 pub criterion_half_logdet: f64,
164}
165
166/// Analytic-vs-finite-difference evidence for the ρ (log-smoothing) block at the
167/// same seed, in ρ-local order (#2765).
168///
169/// The ψ block above and this one are graded by the SAME Ridders ladder against
170/// the SAME criterion, which is the point: the two blocks share the criterion's
171/// moving-Hessian machinery (the trace kernel `K` and the mode-response drift
172/// `D_β H[v]`) but have structurally different frozen drifts — `λ_k S_k`, a
173/// known exact matrix, for ρ, and the family's own `∂_ψ H|_β` for ψ. So a
174/// defect that shows in BOTH blocks lives in the shared machinery and a defect
175/// that shows in only one lives in that block's own drift. Without this the
176/// bisection could not be made, and a ψ-only audit could only report that
177/// *something* in the chain was wrong.
178///
179/// The analytic parts come from the ρ-block audit channel
180/// ([`RhoGradientParts`]), which the capture arms for itself; the
181/// finite-difference parts come from the same criterion-component stencils the
182/// ψ block uses.
183#[derive(Clone, Debug)]
184pub struct OuterGradientFdRhoBlock {
185 pub analytic_gradient: Array1<f64>,
186 pub finite_difference_gradient: Array1<f64>,
187 pub steps: Array1<f64>,
188 pub fd_uncertainty: Array1<f64>,
189 pub fd_orders: Vec<usize>,
190 /// The analytic entry as the ρ-block audit reports it, before the prior
191 /// gradient and the canonical-face KKT projection the assembly applies
192 /// afterwards. Equal to `analytic_gradient` on every model that carries
193 /// neither; recorded separately so a gap between them is visible rather
194 /// than charged to the derivative.
195 pub analytic_audit_total: Array1<f64>,
196 pub analytic_fixed_beta: Array1<f64>,
197 pub analytic_logdet_h: Array1<f64>,
198 /// `analytic_logdet_h` split at the drift: the half that does not read the
199 /// coefficient mode response, `½ tr(K · λ_k S_k)`, and the half that does,
200 /// `½ tr(K · D_β H[v_k])`. The first is PSD-by-construction, so a negative
201 /// entry is a defect that needs no oracle at all.
202 pub analytic_frozen_logdet_h: Array1<f64>,
203 pub analytic_mode_response_logdet_h: Array1<f64>,
204 pub analytic_logdet_s: Array1<f64>,
205 /// `audit_total − (fixed_beta + logdet_h + logdet_s)`: the IFT/KKT fold.
206 pub analytic_kkt: Array1<f64>,
207 pub finite_difference_fixed_beta: Array1<f64>,
208 pub finite_difference_logdet_h: Array1<f64>,
209 pub finite_difference_logdet_s: Array1<f64>,
210 pub finite_difference_kkt: Array1<f64>,
211}
212
213/// Whether the audited criterion decomposes into REML atoms, and the evidence
214/// either way (#2460).
215///
216/// The comparison above — one analytic ψ gradient against one Ridders-certified
217/// finite difference of the same objective — is available from any outer
218/// objective that declares a ψ block, because it needs only `eval_cost` and
219/// `eval_with_order`. The breakdown below is not: it exists where the criterion
220/// is `fixed-β likelihood + ½log|H| − ½log|S|₊ + KKT residual` and the evaluator
221/// publishes those atoms as it assembles them.
222///
223/// Routes that evaluate a criterion directly — the constant-curvature fair
224/// profile computes its value and derivative in closed form and never enters a
225/// REML assembly — have no atoms to publish and no selected coefficient mode to
226/// difference. Making the breakdown a PRECONDITION of the measurement is what
227/// left those routes with no audit at all, which is the wrong way round: a
228/// hand-derived derivative on a bespoke profile is the one that most wants
229/// checking.
230#[derive(Clone, Debug)]
231pub enum OuterGradientFdDecomposition {
232 /// The evaluator published every atom, and each is differenced at the step
233 /// the Ridders ladder accepted for the total.
234 Decomposed(Box<OuterGradientFdAtoms>),
235 /// The evaluator published no atoms, no scalar criterion components and no
236 /// selected coefficient mode. `reason` names the objective so a consumer
237 /// reports which route it got rather than an empty array.
238 ///
239 /// A PARTIAL publication is never reported here — it is a defect in an
240 /// evaluator that means to decompose, and the capture still fails loudly.
241 NotDecomposed { reason: String },
242}
243
244impl OuterGradientFdDecomposition {
245 /// The atoms, or `None` where the criterion does not decompose.
246 pub fn atoms(&self) -> Option<&OuterGradientFdAtoms> {
247 match self {
248 Self::Decomposed(atoms) => Some(atoms),
249 Self::NotDecomposed { .. } => None,
250 }
251 }
252}
253
254/// Per-atom analytic-vs-finite-difference evidence, in ψ-local order.
255///
256/// This is what localizes a total mismatch to a term: the survival marginal-slope
257/// gate reads it to separate an agreeing fixed-β atom from a disagreeing
258/// moving-Hessian chain, which is a different bug report from "the gradient is
259/// wrong".
260#[derive(Clone, Debug)]
261pub struct OuterGradientFdAtoms {
262 pub fixed_beta_psi_gradient: Array1<f64>,
263 pub logdet_h_psi_gradient: Array1<f64>,
264 pub frozen_logdet_h_psi_gradient: Array1<f64>,
265 pub mode_response_logdet_h_psi_gradient: Array1<f64>,
266 pub analytic_mode_response_norm: Array1<f64>,
267 pub finite_difference_mode_response_norm: Array1<f64>,
268 pub mode_response_relative_error: Array1<f64>,
269 pub mode_response_max_abs_error: Array1<f64>,
270 pub logdet_s_psi_gradient: Array1<f64>,
271 pub kkt_psi_gradient: Array1<f64>,
272 pub finite_difference_fixed_beta_psi_gradient: Array1<f64>,
273 pub finite_difference_logdet_h_psi_gradient: Array1<f64>,
274 pub finite_difference_logdet_s_psi_gradient: Array1<f64>,
275 pub finite_difference_kkt_psi_gradient: Array1<f64>,
276}
277
278/// Maximum evaluations retained per capture window (opening iterates only).
279const MAX_CAPTURED: usize = 8;
280
281static ENABLED: AtomicBool = AtomicBool::new(false);
282
283struct OuterGradientFdCapture {
284 min_psi_dim: usize,
285 grade_rho: bool,
286 record: Option<OuterGradientFdRecord>,
287 components: Vec<(f64, f64, f64, f64, f64, f64)>,
288 criterion_components: Option<(f64, [f64; 4])>,
289 psi_gram_anchor_deltas: Option<(f64, f64)>,
290 selected_mode: Option<(Array1<f64>, Option<Array2<f64>>)>,
291 curvature: Option<OuterCurvatureSnapshot>,
292 tangent_basis: Option<Array2<f64>>,
293}
294
295thread_local! {
296 static FD_CAPTURE: RefCell<Option<OuterGradientFdCapture>> = const { RefCell::new(None) };
297}
298
299fn buffer() -> &'static Mutex<Vec<OuterEvalRecord>> {
300 static BUFFER: OnceLock<Mutex<Vec<OuterEvalRecord>>> = OnceLock::new();
301 BUFFER.get_or_init(|| Mutex::new(Vec::new()))
302}
303
304/// Request one structured audit over the WHOLE θ vector — the ψ block and the
305/// ρ (log-smoothing) block — at the next outer seed with enough ψ axes.
306///
307/// Opt-in rather than the default because grading ρ costs a full Ridders ladder
308/// of inner profiles per coordinate, which is the same price the ψ block pays
309/// and which the shipped κ/geometry gates have no use for. What it buys is the
310/// bisection described on [`OuterGradientFdRhoBlock`]: the two blocks share the
311/// criterion's moving-Hessian machinery and differ in their frozen drift, so a
312/// disagreement present in one and absent in the other localizes the defect
313/// without any new instrumentation inside the evaluator.
314pub fn enable_outer_gradient_fd_capture_over_theta(min_psi_dim: usize) {
315 arm_outer_gradient_fd_capture(min_psi_dim, true);
316}
317
318fn arm_outer_gradient_fd_capture(min_psi_dim: usize, grade_rho: bool) {
319 FD_CAPTURE.with(|capture| {
320 *capture.borrow_mut() = Some(OuterGradientFdCapture {
321 min_psi_dim,
322 grade_rho,
323 record: None,
324 components: Vec::new(),
325 criterion_components: None,
326 psi_gram_anchor_deltas: None,
327 selected_mode: None,
328 curvature: None,
329 tangent_basis: None,
330 });
331 });
332}
333
334/// Whether the armed audit was asked to difference the ρ block too.
335pub(crate) fn outer_gradient_fd_capture_grades_rho() -> bool {
336 FD_CAPTURE.with(|capture| {
337 capture
338 .borrow()
339 .as_ref()
340 .is_some_and(|state| state.record.is_none() && state.grade_rho)
341 })
342}
343
344pub(crate) fn begin_outer_gradient_component_capture() {
345 FD_CAPTURE.with(|capture| {
346 if let Some(state) = capture.borrow_mut().as_mut() {
347 state.components.clear();
348 }
349 });
350}
351
352pub(crate) fn outer_gradient_component_capture_enabled() -> bool {
353 FD_CAPTURE.with(|capture| {
354 capture
355 .borrow()
356 .as_ref()
357 .is_some_and(|state| state.record.is_none())
358 })
359}
360
361pub(crate) fn record_outer_gradient_component(
362 fixed_beta: f64,
363 logdet_h: f64,
364 frozen_logdet_h: f64,
365 mode_response_logdet_h: f64,
366 logdet_s: f64,
367 kkt: f64,
368) {
369 FD_CAPTURE.with(|capture| {
370 if let Some(state) = capture.borrow_mut().as_mut()
371 && state.record.is_none()
372 {
373 state.components.push((
374 fixed_beta,
375 logdet_h,
376 frozen_logdet_h,
377 mode_response_logdet_h,
378 logdet_s,
379 kkt,
380 ));
381 }
382 });
383}
384
385pub(crate) fn take_outer_gradient_components() -> Vec<(f64, f64, f64, f64, f64, f64)> {
386 FD_CAPTURE.with(|capture| {
387 capture
388 .borrow_mut()
389 .as_mut()
390 .map_or_else(Vec::new, |state| std::mem::take(&mut state.components))
391 })
392}
393
394pub(crate) fn begin_outer_criterion_component_capture() {
395 FD_CAPTURE.with(|capture| {
396 if let Some(state) = capture.borrow_mut().as_mut() {
397 state.criterion_components = None;
398 state.psi_gram_anchor_deltas = None;
399 state.selected_mode = None;
400 state.curvature = None;
401 state.tangent_basis = None;
402 }
403 });
404}
405
406pub(crate) fn peek_outer_tangent_basis() -> Option<Array2<f64>> {
407 FD_CAPTURE.with(|capture| {
408 capture
409 .borrow()
410 .as_ref()
411 .and_then(|state| state.tangent_basis.clone())
412 })
413}
414
415/// Retain the criterion's dense curvature and its analytic drifts for an armed
416/// audit (#2765).
417///
418/// Called from the REML/LAML assembly, which is the only place that holds both
419/// the operator the log-determinant is taken on and the per-coordinate drift
420/// that claims to be its derivative. No-op unless a finite-difference audit
421/// armed this thread, so an ordinary fit pays one thread-local read.
422pub fn record_outer_curvature_snapshot(snapshot: OuterCurvatureSnapshot) {
423 FD_CAPTURE.with(|capture| {
424 if let Some(state) = capture.borrow_mut().as_mut()
425 && state.record.is_none()
426 {
427 state.curvature = Some(snapshot);
428 }
429 });
430}
431
432pub(crate) fn take_outer_curvature_snapshot() -> Option<OuterCurvatureSnapshot> {
433 FD_CAPTURE.with(|capture| {
434 capture
435 .borrow_mut()
436 .as_mut()
437 .and_then(|state| state.curvature.take())
438 })
439}
440
441/// Retain the final selected scalar-criterion decomposition for an armed
442/// outer-gradient audit.
443///
444/// This is public only so sibling workspace evaluators can report through the
445/// same typed sink after their own nonconvex mode selection. It is a no-op
446/// unless `enable_outer_gradient_fd_capture` armed the calling thread.
447pub fn record_outer_criterion_components(cost: f64, components: [f64; 4]) {
448 FD_CAPTURE.with(|capture| {
449 if let Some(state) = capture.borrow_mut().as_mut()
450 && state.record.is_none()
451 {
452 state.criterion_components = Some((cost, components));
453 }
454 });
455}
456
457/// Report the psi-Gram anchor correction's magnitude for an armed audit.
458///
459/// Public for the same reason as [`record_outer_criterion_components`]: the
460/// correction is applied in the evaluator, not in the outer runner that builds
461/// the record. No-op unless `enable_outer_gradient_fd_capture` armed this
462/// thread. Called on every application, so the LAST application before the
463/// record is finalized is the one reported -- the seed the audit grades.
464pub fn record_psi_gram_anchor_deltas(gram_delta_max_abs: f64, rhs_delta_max_abs: f64) {
465 FD_CAPTURE.with(|capture| {
466 if let Some(state) = capture.borrow_mut().as_mut()
467 && state.record.is_none()
468 {
469 state.psi_gram_anchor_deltas = Some((gram_delta_max_abs, rhs_delta_max_abs));
470 }
471 });
472}
473
474pub(crate) fn take_psi_gram_anchor_deltas() -> Option<(f64, f64)> {
475 FD_CAPTURE.with(|capture| {
476 capture
477 .borrow_mut()
478 .as_mut()
479 .and_then(|state| state.psi_gram_anchor_deltas.take())
480 })
481}
482
483pub(crate) fn take_outer_criterion_components() -> Option<(f64, [f64; 4])> {
484 FD_CAPTURE.with(|capture| {
485 capture
486 .borrow_mut()
487 .as_mut()
488 .and_then(|state| state.criterion_components.take())
489 })
490}
491
492/// Retain the selected coefficient mode and its analytic extended-coordinate
493/// response columns for an armed finite-difference audit.
494///
495/// Sibling workspace evaluators call this only after nonconvex candidate
496/// selection, beside [`record_outer_criterion_components`]. Value-only
497/// evaluations pass no response columns but still retain their selected
498/// coefficients for the scalar stencil.
499pub fn record_outer_selected_mode(
500 beta: Array1<f64>,
501 ext_mode_response_cols: Option<Array2<f64>>,
502) {
503 FD_CAPTURE.with(|capture| {
504 if let Some(state) = capture.borrow_mut().as_mut()
505 && state.record.is_none()
506 {
507 state.selected_mode = Some((beta, ext_mode_response_cols));
508 }
509 });
510}
511
512/// Whether a finite-difference audit is armed on this thread at all.
513///
514/// Emitters consult this before building the evidence they would hand to
515/// [`record_outer_selected_mode`], so an unarmed fit pays a thread-local read
516/// rather than a coefficient-vector clone on every outer evaluation.
517pub fn outer_gradient_audit_capture_armed() -> bool {
518 FD_CAPTURE.with(|capture| {
519 capture
520 .borrow()
521 .as_ref()
522 .is_some_and(|state| state.record.is_none())
523 })
524}
525
526pub(crate) fn take_outer_selected_mode() -> Option<(Array1<f64>, Option<Array2<f64>>)> {
527 FD_CAPTURE.with(|capture| {
528 capture
529 .borrow_mut()
530 .as_mut()
531 .and_then(|state| state.selected_mode.take())
532 })
533}
534
535/// Stop the audit window and take its single record.
536pub fn take_outer_gradient_fd_capture() -> Option<OuterGradientFdRecord> {
537 FD_CAPTURE.with(|capture| capture.borrow_mut().take().and_then(|state| state.record))
538}
539
540pub(crate) fn outer_gradient_fd_capture_enabled(psi_dim: usize) -> bool {
541 FD_CAPTURE.with(|capture| {
542 capture
543 .borrow()
544 .as_ref()
545 .is_some_and(|state| state.record.is_none() && psi_dim >= state.min_psi_dim)
546 })
547}
548
549pub(crate) fn record_outer_gradient_fd(record: OuterGradientFdRecord) {
550 FD_CAPTURE.with(|capture| {
551 if let Some(state) = capture.borrow_mut().as_mut()
552 && state.record.is_none()
553 && record.psi_dim >= state.min_psi_dim
554 {
555 state.record = Some(record);
556 }
557 });
558}
559
560// ═══════════════════════════════════════════════════════════════════════════
561// ρ-block outer audit (#2454)
562// ═══════════════════════════════════════════════════════════════════════════
563//
564// The ψ block has carried a typed analytic-vs-FD record since #2460; the ρ
565// block had none, so every large-λ smoothing-gradient investigation had to
566// scrape `log::trace!` lines or bolt an environment-gated instrument onto the
567// evaluator. This channel closes that asymmetry: it emits, per outer
568// evaluation, the SAME four-way additive decomposition the criterion VALUE
569// carries (`RemlCriterionComponents`) but for each ρ coordinate's analytic
570// gradient — so a caller can finite-difference each criterion component and
571// grade the gradient part that owns it, instead of grading only their sum.
572//
573// Thread-local and disabled by default, matching the ψ channel: a parallel
574// integration test can neither consume nor overwrite another test's audit.
575
576/// One ρ coordinate's analytic gradient, split into the additive parts that
577/// match the criterion-value components of `RemlCriterionComponents`.
578///
579/// `fixed_beta + logdet_h + logdet_s` is the envelope gradient entry as
580/// assembled; `total` additionally carries any IFT/KKT correction folded in
581/// afterwards, so `total − (fixed_beta + logdet_h + logdet_s)` is the `kkt`
582/// part. `lambda` and `block_quadratic` are the two raw inputs the
583/// `fixed_beta` part is built from (`½·λ_k·q_k`, scaled by the dispersion
584/// channel), retained because a defect that is proportional to `λ_k` is only
585/// diagnosable against the `λ_k` it was multiplied by.
586#[derive(Clone, Copy, Debug)]
587pub struct RhoGradientParts {
588 pub index: usize,
589 pub lambda: f64,
590 pub block_quadratic: f64,
591 /// `rank(S_k)` as the outer penalty coordinate represents it (rows of its
592 /// root), and the ambient dimension it acts on.
593 pub rank: usize,
594 pub dim: usize,
595 pub fixed_beta: f64,
596 pub logdet_h: f64,
597 /// `logdet_h` split at the drift: `½ tr(K · λ_k S_k)`, the half that does
598 /// not read the coefficient mode response. Both `K` and `S_k` are PSD, so a
599 /// NEGATIVE value here is a defect with no oracle required.
600 pub frozen_logdet_h: f64,
601 /// The other half, `½ tr(K · D_β H[v_k])`.
602 pub mode_response_logdet_h: f64,
603 pub logdet_s: f64,
604 pub total: f64,
605}
606
607/// The two floating-point spellings of the penalty energy `β̂ᵀS(λ)β̂` that the
608/// criterion and its ρ-gradient respectively read, plus the profiled-Gaussian
609/// scalars that connect them to `fixed_beta`.
610///
611/// `stable` is the inner solve's stable-basis emission (what the criterion
612/// VALUE uses); `block_sum` is `Σ_k λ_k q_k` rebuilt from the outer penalty
613/// coordinates (what `½λ_k q_k` — the gradient's `fixed_beta` channel — is a
614/// per-block projection of). They are the same mathematical quantity, so any
615/// disagreement is a floating-point one; the ρ-derivative multiplies it by
616/// `λ_k`, which is why it must be measured rather than assumed small.
617///
618/// Recorded on BOTH dispersion arms (#2644). The channel reconstruction
619/// `dp_cgrad · (½λ_k q_k) / phi` is what the three scalars are for, and it is
620/// kept true on both: the profiled-Gaussian arm supplies the smooth
621/// deviance-floor chain factor and the profiled scale, while fixed dispersion
622/// — where the channel is bare `½λ_k q_k` — supplies `dp_cgrad = phi = 1.0`.
623/// `dp_raw`/`dp_floored` are the penalized deviance; on the fixed arm no
624/// criterion term reads them and they are equal.
625#[derive(Clone, Copy, Debug)]
626pub struct PenaltyEnergyAudit {
627 pub stable: f64,
628 pub block_sum: f64,
629 pub dp_raw: f64,
630 pub dp_floored: f64,
631 pub dp_cgrad: f64,
632 pub phi: f64,
633}
634
635/// The same penalty energy spelled from the ORIGINAL-frame canonical penalty
636/// roots and from the TRANSFORMED-frame (post-`Qs`) ones, both evaluated at the
637/// coefficient vector the outer evaluator will actually use.
638///
639/// Recorded at the assembly site, where both root sets and the inner solve's
640/// own `stable_penalty_term` are simultaneously in scope.
641#[derive(Clone, Debug)]
642pub struct PenaltyFrameAudit {
643 pub stable_penalty_term: f64,
644 pub original_frame_blocks: Vec<f64>,
645 pub transformed_frame_blocks: Vec<f64>,
646 /// `‖Qs − I‖_max`; zero exactly when the reparameterization is the identity.
647 pub qs_deviation_from_identity: f64,
648 /// Which coefficient frame the inner solve reports `beta` in.
649 pub coordinate_frame: &'static str,
650 /// `βᵀ S_transformed β` from the reparameterization's rebuilt (rank-truncated)
651 /// penalty, at `β` as handed to the outer evaluator and at `Qsᵀβ`.
652 pub s_transformed_quadratic: f64,
653 pub s_transformed_quadratic_rotated: f64,
654 /// `‖E_transformed β‖²` and `‖E_transformed Qsᵀβ‖²`.
655 pub e_transformed_quadratic: f64,
656 pub e_transformed_quadratic_rotated: f64,
657 /// `p`, the reconstruction's row count (`structural_rank`), and the
658 /// dimension of the λ-invariant DECLARED-NULL subspace the split excludes.
659 pub p: usize,
660 pub e_rows: usize,
661 pub null_dim: usize,
662 /// `‖U_⊥ᵀ β_t‖²` — how much of β̂ lives in the declared-null subspace.
663 pub beta_null_energy: f64,
664 /// Per-block `(Πβ_t)ᵀ S_k^t (Πβ_t)` with `Π = I − U_⊥U_⊥ᵀ`: the block
665 /// quadratic restricted to the subspace the criterion actually penalizes.
666 pub projected_frame_blocks: Vec<f64>,
667 /// The rank the criterion's own `−½ log|S(λ)|₊` term ranges over, and its
668 /// value. This is the OTHER half of the same-penalty question (#2454): the
669 /// `fixed_beta` channel and `H` both carry the split-projected `S̃`, whose
670 /// rank is `e_rows`, while `log|S|₊` is taken on `Σ_k λ_k S_k` and can
671 /// therefore charge MORE directions than `½log|H|` will ever inflate. The
672 /// asymptotic slope of the criterion in ρ is `½(rank(S̃) − penalty_rank)`,
673 /// so any gap between these two integers is a linear-in-ρ ramp with no
674 /// interior optimum.
675 pub penalty_logdet_rank: usize,
676 pub penalty_logdet_value: f64,
677}
678
679/// One outer evaluation's #784 block-local quadrature record: the
680/// spliced value `Δ_b`, the block the splice selected, and the four gradient
681/// channels PER ρ COORDINATE exactly as the assembly formed them (#2623).
682///
683/// Every field is in the corrector's own `Δ_b`-side convention, i.e. the sign the
684/// producer emits, NOT the cost-side sign. `delta_b` is `+Δ_b` (the criterion
685/// carries `−Δ_b`) and `explicit_a` is the raw quadrature gradient. Recording
686/// the raw values is the whole point: the sign question this decides is which
687/// side of `d(cost)/dρ = −d(Δ_b)/dρ` each channel already lives on, and a record
688/// that pre-applied a sign would assume the answer.
689///
690/// `spliced` is the entry the assembly actually adds to the cost gradient, so
691/// `spliced` vs `−(explicit_a + trace_bc + mode_d)` is the disagreement itself,
692/// readable without re-deriving it.
693#[derive(Clone, Debug)]
694pub struct QuadratureMarginalAudit {
695 /// `Δ_b` as the corrector reports it: added to the block marginal
696 /// log-likelihood, SUBTRACTED from the criterion.
697 pub delta_b: f64,
698 /// Absolute fine/coarse quadrature-rule difference on `delta_b`.
699 pub quadrature_error: f64,
700 /// Number of nodes in the fine rule.
701 pub node_count: usize,
702 /// The activation evidence: `max|γ_r|` over curvature directions and the
703 /// threshold `τ(n_eff)` it had to exceed.
704 pub max_abs_skewness: f64,
705 pub skewness_threshold: f64,
706 /// Which `H` eigenvector indices form the integrated block, ascending.
707 ///
708 /// An FD stencil must compare this ACROSS its points. The block is selected
709 /// by a threshold on a per-direction diagnostic, so a stencil that changes
710 /// block membership is differencing two different functions and its
711 /// quotient is not a derivative of either.
712 pub block_cols: Vec<usize>,
713 /// Channel (a), `∂Δ_b/∂ρ_j` — the corrector's explicit penalty-score channel,
714 /// raw.
715 pub explicit_a: Vec<f64>,
716 /// Channels (b)+(c) together, `tr(Ḣ_j · (Q_b + Q_c))`.
717 pub trace_bc: Vec<f64>,
718 /// Channel (d), `g_dᵀ · dβ̂/dρ_j`.
719 pub mode_d: Vec<f64>,
720 /// The gradient entry the assembly writes into the cost gradient.
721 pub spliced: Vec<f64>,
722}
723
724/// One outer evaluation's ρ-block audit: the criterion value decomposition and
725/// the per-coordinate analytic gradient decomposition that pairs with it.
726#[derive(Clone, Debug, Default)]
727pub struct RhoOuterAudit {
728 /// `(cost, [fixed_beta, logdet_h, logdet_s, kkt])` for the criterion VALUE.
729 pub criterion: Option<(f64, [f64; 4])>,
730 /// Per-ρ-coordinate analytic gradient parts, in coordinate order.
731 pub parts: Vec<RhoGradientParts>,
732 /// The penalty-energy spellings behind the `fixed_beta` channel.
733 pub penalty_energy: Option<PenaltyEnergyAudit>,
734 /// The original-frame vs transformed-frame penalty roots at the assembly
735 /// site.
736 pub penalty_frame: Option<PenaltyFrameAudit>,
737 /// Whether the #784 block-local quadrature ENGAGED on this
738 /// evaluation (#2623).
739 ///
740 /// False means the splice DECLINED, so gradient channels (b), (c) and (d)
741 /// were never formed. A finite-difference comparison of those channels is
742 /// then vacuous rather than passing: it is the shape where a guard is
743 /// satisfied by an absence. Any FD row that means to exercise them must
744 /// ASSERT this true before comparing, or it silently degenerates into the
745 /// well-behaved regime where the splice never runs.
746 pub quadrature_marginal_engaged: bool,
747 /// The engaged splice's value, block and per-coordinate channel split, or
748 /// `None` when it declined (#2623).
749 ///
750 /// Present exactly when `quadrature_marginal_engaged` is true. Kept beside the
751 /// flag rather than behind a separate accessor so a reader cannot assert
752 /// engagement without having the channels in hand, nor read the channels
753 /// without having checked engagement.
754 pub quadrature_marginal: Option<QuadratureMarginalAudit>,
755}
756
757thread_local! {
758 static RHO_AUDIT: RefCell<Option<RhoOuterAudit>> = const { RefCell::new(None) };
759}
760
761/// Arm the ρ-block audit on this thread, discarding any previous window.
762///
763/// Every subsequent outer evaluation on this thread overwrites the window, so
764/// the caller reads the audit for the LAST evaluation it triggered — which is
765/// the contract a probe wants when it evaluates at one θ at a time.
766pub fn enable_rho_outer_audit() {
767 RHO_AUDIT.with(|audit| *audit.borrow_mut() = Some(RhoOuterAudit::default()));
768}
769
770/// Disarm the ρ-block audit and take the last evaluation's window.
771pub fn take_rho_outer_audit() -> Option<RhoOuterAudit> {
772 RHO_AUDIT.with(|audit| audit.borrow_mut().take())
773}
774
775/// Re-arm the ρ-block audit with a window taken earlier.
776///
777/// Exists so a nested consumer — the outer-gradient FD capture, which arms this
778/// channel for itself to read the analytic ρ atoms — can hand a caller's window
779/// back untouched instead of silently disarming an audit it did not open.
780pub fn restore_rho_outer_audit(window: RhoOuterAudit) {
781 RHO_AUDIT.with(|audit| *audit.borrow_mut() = Some(window));
782}
783
784pub(crate) fn rho_outer_audit_enabled() -> bool {
785 RHO_AUDIT.with(|audit| audit.borrow().is_some())
786}
787
788/// Start a fresh window for one outer evaluation (no-op when disarmed).
789///
790/// Clears the criterion and per-coordinate gradient slots, which the evaluator
791/// refills on this evaluation. The penalty-frame slot is deliberately NOT
792/// cleared: it is written at the assembly site, which runs BEFORE the evaluator
793/// for the same evaluation, so clearing it here would discard the record the
794/// caller asked for.
795pub(crate) fn begin_rho_outer_audit_eval() {
796 RHO_AUDIT.with(|audit| {
797 if let Some(state) = audit.borrow_mut().as_mut() {
798 state.criterion = None;
799 state.parts = Vec::new();
800 // Engagement is decided INSIDE the evaluation, so it is cleared
801 // here and set again if the splice runs. Latching it across
802 // evaluations would let one engaged eval vouch for a later
803 // declined one (#2623).
804 state.quadrature_marginal_engaged = false;
805 state.quadrature_marginal = None;
806 }
807 });
808}
809
810/// Record that the #784 quadrature splice engaged on this
811/// evaluation, together with the channels it formed (#2623). No-op when the
812/// audit is disarmed.
813pub(crate) fn record_quadrature_marginal(record: QuadratureMarginalAudit) {
814 RHO_AUDIT.with(|audit| {
815 if let Some(state) = audit.borrow_mut().as_mut() {
816 state.quadrature_marginal_engaged = true;
817 state.quadrature_marginal = Some(record);
818 }
819 });
820}
821
822/// The record written by the last engaged splice on this thread, if the audit is
823/// armed and one has been written since the window began.
824///
825/// The correction is computed once per inner solution and cached on the eval
826/// bundle, while the audit window is cleared at the START of every assemble call
827/// sharing that bundle — and one ρ drives two or three of them (value,
828/// value+gradient, value+gradient+Hessian). So the assemble that computes the
829/// splice records it and the next one clears the record and then hits the cache,
830/// which would report a genuinely engaged evaluation as declined. The cache
831/// carries this record forward and re-publishes it, which is what this reader is
832/// for (#2623).
833pub(crate) fn last_quadrature_marginal_record() -> Option<QuadratureMarginalAudit> {
834 RHO_AUDIT.with(|audit| {
835 audit
836 .borrow()
837 .as_ref()
838 .and_then(|state| state.quadrature_marginal.clone())
839 })
840}
841
842pub(crate) fn record_rho_outer_criterion(cost: f64, components: [f64; 4]) {
843 RHO_AUDIT.with(|audit| {
844 if let Some(state) = audit.borrow_mut().as_mut() {
845 state.criterion = Some((cost, components));
846 }
847 });
848}
849
850pub(crate) fn record_rho_penalty_frame(frame: PenaltyFrameAudit) {
851 RHO_AUDIT.with(|audit| {
852 if let Some(state) = audit.borrow_mut().as_mut() {
853 state.penalty_frame = Some(frame);
854 }
855 });
856}
857
858pub(crate) fn record_rho_penalty_energy(energy: PenaltyEnergyAudit) {
859 RHO_AUDIT.with(|audit| {
860 if let Some(state) = audit.borrow_mut().as_mut() {
861 state.penalty_energy = Some(energy);
862 }
863 });
864}
865
866pub(crate) fn record_rho_gradient_parts(parts: Vec<RhoGradientParts>) {
867 RHO_AUDIT.with(|audit| {
868 if let Some(state) = audit.borrow_mut().as_mut() {
869 state.parts = parts;
870 }
871 });
872}
873
874/// Record one outer evaluation when capture is enabled (no-op otherwise). Only
875/// the first [`MAX_CAPTURED`] evaluations of a window are retained.
876pub(crate) fn record_outer_eval(theta: &Array1<f64>, cost: f64, gradient: &Array1<f64>) {
877 if !ENABLED.load(Ordering::Relaxed) {
878 return;
879 }
880 let mut b = buffer().lock().expect("outer-eval capture buffer");
881 if b.len() < MAX_CAPTURED {
882 b.push(OuterEvalRecord {
883 theta: theta.clone(),
884 cost,
885 gradient: gradient.clone(),
886 });
887 }
888}