Skip to main content

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}