Skip to main content

gam_problem/
lib.rs

1//! Shared REML/LAML contract types.
2//!
3//! These are the family-facing interfaces for REML outer assembly. They live
4//! below `solver` so families can construct operator-backed derivative payloads
5//! without importing `solver::estimate::reml::reml_outer_engine`.
6
7use std::any::Any;
8use std::collections::HashMap;
9use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
10use std::sync::{Arc, Condvar, Mutex};
11
12use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut1, ArrayViewMut2};
13use rayon::iter::{IntoParallelIterator, ParallelIterator};
14
15#[macro_use]
16mod macros;
17
18pub mod basis_error;
19pub mod block_count_error;
20pub mod block_role;
21pub mod block_spec;
22pub mod coefficient_prior_mean;
23mod constraint_set;
24pub mod custom_family_blockwise;
25pub mod custom_family_error;
26pub mod diagnostics;
27pub mod dispersion;
28pub mod dispersion_cov;
29pub mod estimation_error;
30pub mod execution_path;
31pub mod family_options;
32pub mod finite_validation;
33pub mod fisher_rao;
34pub mod gauge;
35pub mod identifiability_audit;
36pub mod joint_penalty;
37mod linear_constraints;
38pub mod log_strength;
39pub mod monotone_root_error;
40pub mod outer_subsample;
41pub mod penalty_coordinate;
42pub mod penalty_matrix;
43mod pseudo_logdet;
44pub mod psi_design_contract;
45pub mod psi_terms;
46pub mod riemannian_retraction;
47// `ρ`-posterior certificate/escalation DATA types contract-downed (#1521) so
48// gam-solve can store/return them without a back-edge into gam-inference; the
49// computation stays UP in the monolith `inference::rho_posterior`.
50pub mod rho_posterior;
51pub mod roundoff;
52pub mod row_measure;
53pub mod row_metric;
54pub mod schedule;
55// JSON-representation contracts for numeric payloads: `serde_extended_real`
56// gives `+∞` an encoding (JSON has none), and `serde_finite` refuses every
57// OTHER non-finite float structurally, naming its path (#2601).
58pub mod serde_extended_real;
59pub mod serde_finite;
60// #1521 contract-downs: pure-data carriers + caller-supplied sampler/verdict
61// traits so gam-solve can call up-tier work (NUTS sampling, topology verdicts)
62// without a back-edge into gam-inference/gam-sae; computation stays UP.
63pub mod laplace_sampler_contract;
64mod seeding;
65pub mod solver_contract;
66// Fixtures that build `ParameterBlockSpec`s live with the type they build, so
67// downstream crates' test builds reach them through a leaf dependency instead of
68// the model-layer `gam-test-support` crate.
69pub mod test_support;
70pub mod topology_certificates;
71pub mod types;
72
73pub use riemannian_retraction::LatentRetractionRegistry;
74pub use row_measure::RowSubsampleMask;
75
76pub use basis_error::BasisError;
77pub use block_count_error::BlockCountMismatch;
78pub use block_role::BlockRole;
79pub use block_spec::{
80    AdditiveBlockJacobian, BlockEffectiveJacobian, BlockGeometryDirectionalDerivative,
81    BlockWorkingSet, CoefficientCoordinate, FamilyChannelHessian, FamilyLinearizationState,
82    GaugeComposedJacobian, ParameterBlockSpec, ParameterBlockState, RowScaledJacobian,
83};
84pub use coefficient_prior_mean::{CoefficientPriorMean, PriorMeanError};
85pub use constraint_set::{
86    ConstraintRowId, ConstraintSet, ContractFeasibleStep, ContractFeasibleStepError,
87    KhatriRaoConeConstraints, PRIMAL_FEASIBILITY_TOL, PlacedConstraintBlock,
88    feasibility_quantities_are_finite,
89};
90pub use custom_family_blockwise::{
91    CUSTOM_FAMILY_RIDGE_FLOOR, ExactNewtonOuterCurvature, validate_blockspec_consistency,
92};
93pub use custom_family_error::{
94    CustomFamilyError, InnerConvergenceTerminalState, JointNewtonTerminalReason,
95    relative_stationarity,
96};
97pub use dispersion::{Dispersion, DispersionError};
98pub use dispersion_cov::{
99    CovarianceStandardErrorError, PhiScaledCovariance, UnscaledPrecision, se_from_covariance,
100};
101pub use estimation_error::{
102    EstimationError, FixedLambdaCheckpoint, FixedLambdaResidualKind, FixedLambdaSolverStage,
103    FixedLambdaStallReason, FixedLambdaStationarityEvidence, StationarityRung,
104    StationarityStandard,
105};
106pub use estimation_error::FitStationarityEvidence;
107pub use execution_path::ExecutionPath;
108pub use family_options::{ExactNewtonOuterObjective, ExactOuterDerivativeOrder};
109pub use finite_validation::{
110    bail_if_cached_beta_non_finite, ensure_finite_scalar, ensure_finite_scalar_estimation,
111    validate_all_finite, validate_all_finite_estimation, validate_all_finite_trial_point,
112};
113pub use serde_finite::{NonFiniteFloat, ensure_serialized_floats_are_finite};
114pub use fisher_rao::{
115    FisherRaoDefiniteness, normalize_fisher_rao_blocks, normalize_fisher_rao_blocks_pd,
116};
117pub use roundoff::{roundoff_growth_factor, weighted_residual_is_at_roundoff_floor};
118use gam_linalg::dense;
119pub use gam_linalg::faer_ndarray::{in_nested_parallel_region, with_nested_parallel};
120pub use gauge::Gauge;
121pub use identifiability_audit::{
122    AliasedPair, BlockIdentity, DroppedColumn, IdentifiabilityAudit, JointRankCertificate,
123    MapUniquenessError,
124};
125pub use joint_penalty::{JointPenaltyBundle, JointPenaltyError, JointPenaltySpec};
126pub use linear_constraints::LinearInequalityConstraints;
127pub use log_strength::{
128    IndexedLogStrengthDomainError, LOG_STRENGTH_MAX, LOG_STRENGTH_MIN, LogStrengthDomainError,
129    PhysicalStrengthDomainError, checked_exp_log_strength, checked_exp_log_strengths,
130    checked_log_strength, validate_log_strength, validate_log_strengths,
131};
132pub use monotone_root_error::MonotoneRootError;
133pub use penalty_coordinate::{PenaltyCoordinate, project_block_root_out_of_null_directions};
134pub use penalty_matrix::PenaltyMatrix;
135pub use pseudo_logdet::PseudoLogdetMode;
136pub use psi_design_contract::{
137    CustomFamilyBlockPsiDerivative, CustomFamilyHyperAxis, CustomFamilyHyperLayout,
138    CustomFamilyPsiDerivativeOperator, JointHessianSourcePreference,
139    MaterializablePsiDerivativeOperator, MaterializationIntent, SharedCustomFamilyHyperLayout,
140};
141pub use psi_terms::{
142    ExactNewtonJointPsiSecondOrderContracted, ExactNewtonJointPsiSecondOrderTerms,
143    ExactNewtonJointPsiTerms, ExactNewtonJointPsiWorkspace,
144};
145pub use row_metric::{
146    FisherFactorKind, MetricProvenance, RowMetric, WeightField, pack_probe_factors,
147};
148pub use schedule::{GumbelTemperatureSchedule, ScheduleKind};
149pub use seeding::{OrderedRhoBounds, SeedConfig, SeedRiskProfile};
150pub use solver_contract::{
151    DeclaredHessianForm, Derivative, EfsEval, FixedPointCertificateEval,
152    FixedPointCoordinateCertificate, HessianMaterialization, HessianOperator, HessianValue,
153    ObjectiveEvalError, OuterEval, OuterStrategyError,
154};
155pub use types::*;
156
157#[cold]
158fn reml_contract_panic(message: impl Into<String>) -> ! {
159    std::panic::panic_any(message.into())
160}
161
162/// Evaluation mode for the unified evaluator.
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub enum EvalMode {
165    /// Compute cost only (e.g., for line search).
166    ValueOnly,
167    /// Compute cost and gradient (the common case).
168    ValueAndGradient,
169    /// Compute cost, gradient, and outer Hessian.
170    ValueGradientHessian,
171}
172
173/// Trait for operators that can compute a hyper-derivative matrix-vector product
174/// without necessarily materializing the full matrix.
175struct NonDowncastableHyperOperator;
176
177static NON_DOWNCASTABLE_HYPER_OPERATOR: NonDowncastableHyperOperator = NonDowncastableHyperOperator;
178
179pub trait HyperOperator: Send + Sync {
180    /// Operator dimension `p` such that `B · v` consumes a `p`-vector and
181    /// produces a `p`-vector.
182    fn dim(&self) -> usize;
183
184    /// Compute B · v (matrix-vector product). v and result are p-vectors.
185    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64>;
186
187    /// Expose the concrete type for solver-local downcast helpers when the
188    /// implementor has a `'static` concrete type. Borrowing adapters may keep
189    /// the default, which simply cannot downcast.
190    fn as_any(&self) -> &(dyn Any + 'static) {
191        &NON_DOWNCASTABLE_HYPER_OPERATOR
192    }
193
194    /// Compute B · v from a vector view.
195    fn mul_vec_view(&self, v: ArrayView1<'_, f64>) -> Array1<f64> {
196        self.mul_vec(&v.to_owned())
197    }
198
199    /// Compute B · v into caller-owned storage.
200    fn mul_vec_into(&self, v: ArrayView1<'_, f64>, mut out: ArrayViewMut1<'_, f64>) {
201        out.assign(&self.mul_vec_view(v));
202    }
203
204    /// Compute B · F where F is (p × k). Default dispatches per-column in
205    /// parallel unless already inside a rayon worker.
206    fn mul_mat(&self, factor: &Array2<f64>) -> Array2<f64> {
207        let p = factor.nrows();
208        let k = factor.ncols();
209        let mut out = Array2::<f64>::zeros((p, k));
210        if rayon::current_thread_index().is_some() {
211            for col in 0..k {
212                let bv = out.column_mut(col);
213                self.mul_vec_into(factor.column(col), bv);
214            }
215            return out;
216        }
217        let cols: Vec<Array1<f64>> = (0..k)
218            .into_par_iter()
219            .map(|col| {
220                let mut bv = Array1::<f64>::zeros(p);
221                self.mul_vec_into(factor.column(col), bv.view_mut());
222                bv
223            })
224            .collect();
225        for (col, bv) in cols.into_iter().enumerate() {
226            out.column_mut(col).assign(&bv);
227        }
228        out
229    }
230
231    /// Compute `trace(F^T B F)` for a `(p x k)` factor matrix `F`.
232    fn trace_projected_factor(&self, factor: &Array2<f64>) -> f64 {
233        let op_factor = self.mul_mat(factor);
234        factor
235            .iter()
236            .zip(op_factor.iter())
237            .map(|(&f, &bf)| f * bf)
238            .sum()
239    }
240
241    /// Optional stable identity for this operator's action `B`. When `Some`,
242    /// the default cached trace / projected-matrix paths memoize the `B · F`
243    /// product in the shared [`ProjectedFactorCache`] under a
244    /// `(design_id, factor)` key, so repeated projections of the same factor
245    /// against the same operator within one outer iteration build `B · F`
246    /// once. `None` (the default) disables that reuse: an operator with no
247    /// design factor stable across calls cannot key the cache without risking
248    /// a stale `B · F`, so it recomputes every time.
249    fn projection_design_id(&self) -> Option<usize> {
250        None
251    }
252
253    fn trace_projected_factor_cached(
254        &self,
255        factor: &Array2<f64>,
256        factor_cache: &ProjectedFactorCache,
257    ) -> f64 {
258        // The default implementation has no use for the caller-owned cache;
259        // verify the cache object carries a positive-size allocation before
260        // delegating to the exact path.
261        assert!(std::mem::size_of_val(factor_cache) > 0);
262        match self.projection_design_id() {
263            Some(design_id) => {
264                let key = ProjectedFactorKey::from_factor_view(design_id, factor.view());
265                let projected = factor_cache.get_or_insert_with(key, || self.mul_mat(factor));
266                factor
267                    .iter()
268                    .zip(projected.iter())
269                    .map(|(&f, &bf)| f * bf)
270                    .sum()
271            }
272            None => self.trace_projected_factor(factor),
273        }
274    }
275
276    /// Compute the exact projected matrix `F^T B F`.
277    fn projected_matrix(&self, factor: &Array2<f64>) -> Array2<f64> {
278        let op_factor = self.mul_mat(factor);
279        gam_linalg::faer_ndarray::fast_atb(factor, &op_factor)
280    }
281
282    /// Compute the exact projected matrix `F^T B F`, reusing caller-owned
283    /// projection caches when the operator has a shared row/design factor.
284    fn projected_matrix_cached(
285        &self,
286        factor: &Array2<f64>,
287        factor_cache: &ProjectedFactorCache,
288    ) -> Array2<f64> {
289        assert!(std::mem::size_of_val(factor_cache) > 0);
290        match self.projection_design_id() {
291            Some(design_id) => {
292                let key = ProjectedFactorKey::from_factor_view(design_id, factor.view());
293                let projected = factor_cache.get_or_insert_with(key, || self.mul_mat(factor));
294                gam_linalg::faer_ndarray::fast_atb(factor, projected.as_ref())
295            }
296            None => self.projected_matrix(factor),
297        }
298    }
299
300    /// Fill columns `[start, start + out.ncols())` of `B` into `out`.
301    fn mul_basis_columns_into(&self, start: usize, mut out: ArrayViewMut2<'_, f64>) {
302        let cols = out.ncols();
303        let dim = out.nrows();
304        assert!(start + cols <= dim);
305        let mut basis = Array1::<f64>::zeros(dim);
306        for local_col in 0..cols {
307            let global_col = start + local_col;
308            basis[global_col] = 1.0;
309            self.mul_vec_into(basis.view(), out.column_mut(local_col));
310            basis[global_col] = 0.0;
311        }
312    }
313
314    /// Accumulate `scale * B · v` into caller-owned storage.
315    fn scaled_add_mul_vec(
316        &self,
317        v: ArrayView1<'_, f64>,
318        scale: f64,
319        mut out: ArrayViewMut1<'_, f64>,
320    ) {
321        if scale == 0.0 {
322            return;
323        }
324        let mut work = Array1::<f64>::zeros(out.len());
325        self.mul_vec_into(v, work.view_mut());
326        out.scaled_add(scale, &work);
327    }
328
329    /// Compute v^T · B · u (bilinear form).
330    fn bilinear(&self, v: &Array1<f64>, u: &Array1<f64>) -> f64 {
331        let mut bv = Array1::<f64>::zeros(v.len());
332        self.mul_vec_into(v.view(), bv.view_mut());
333        u.dot(&bv)
334    }
335
336    /// Compute v^T · B · u without requiring owned vector inputs.
337    fn bilinear_view(&self, v: ArrayView1<'_, f64>, u: ArrayView1<'_, f64>) -> f64 {
338        let mut bv = Array1::<f64>::zeros(v.len());
339        self.mul_vec_into(v, bv.view_mut());
340        u.dot(&bv)
341    }
342
343    /// Whether `bilinear_view` is implemented as a direct scalar contraction.
344    fn has_fast_bilinear_view(&self) -> bool {
345        false
346    }
347
348    /// Full dense materialization.
349    fn to_dense(&self) -> Array2<f64> {
350        let p = self.dim();
351        let mut out = Array2::<f64>::zeros((p, p));
352        let mut basis = Array1::<f64>::zeros(p);
353        for j in 0..p {
354            basis[j] = 1.0;
355            self.mul_vec_into(basis.view(), out.column_mut(j));
356            basis[j] = 0.0;
357        }
358        out
359    }
360
361    /// Whether this operator uses implicit (non-materialized) storage.
362    fn is_implicit(&self) -> bool;
363
364    /// If this operator is block-local, returns the block range and local matrix.
365    fn block_local_data(&self) -> Option<(&Array2<f64>, usize, usize)> {
366        None
367    }
368}
369
370#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
371pub struct ProjectedFactorKey {
372    pub(crate) design_id: usize,
373    pub(crate) factor_ptr: usize,
374    pub(crate) rows: usize,
375    pub(crate) cols: usize,
376    pub(crate) row_stride: isize,
377    pub(crate) col_stride: isize,
378    pub(crate) value_hash: u64,
379    pub(crate) value_hash2: u64,
380}
381
382impl ProjectedFactorKey {
383    pub fn from_factor_view(design_id: usize, factor: ArrayView2<'_, f64>) -> Self {
384        let strides = factor.strides();
385        let (value_hash, value_hash2) = projected_factor_value_fingerprint(factor);
386        Self {
387            design_id,
388            factor_ptr: factor.as_ptr() as usize,
389            rows: factor.nrows(),
390            cols: factor.ncols(),
391            row_stride: strides[0],
392            col_stride: strides[1],
393            value_hash,
394            value_hash2,
395        }
396    }
397
398    /// Construct a synthetic, unique-by-`seed` key without going through
399    /// [`Self::from_factor_view`]. Used by cache tests that need to inject
400    /// fingerprints directly (and deterministically) rather than relying on
401    /// ndarray pointer aliasing, which the real constructor keys on.
402    pub fn synthetic(seed: u64) -> Self {
403        Self {
404            design_id: 1,
405            factor_ptr: seed as usize,
406            rows: 1,
407            cols: 1,
408            row_stride: 1,
409            col_stride: 1,
410            value_hash: seed,
411            value_hash2: seed.wrapping_mul(31),
412        }
413    }
414}
415
416pub(crate) fn projected_factor_value_fingerprint(factor: ArrayView2<'_, f64>) -> (u64, u64) {
417    let mut h1 = 0xcbf2_9ce4_8422_2325_u64;
418    let mut h2 = 0x9e37_79b1_85eb_ca87_u64;
419    for (idx, value) in factor.iter().enumerate() {
420        let bits = value.to_bits();
421        let mixed = bits.wrapping_add((idx as u64).wrapping_mul(0x517c_c1b7_2722_0a95));
422        h1 ^= mixed;
423        h1 = h1.wrapping_mul(0x0000_0100_0000_01b3);
424        h2 ^= bits.rotate_left((idx & 63) as u32);
425        h2 = h2.wrapping_mul(0x94d0_49bb_1331_11eb).rotate_left(27);
426    }
427    (h1, h2)
428}
429
430/// Memoizer for projected factor products keyed on a `(design, factor)` fingerprint.
431pub struct ProjectedFactorCache {
432    pub(crate) inner: Mutex<ProjectedFactorCacheInner>,
433}
434
435pub(crate) struct ProjectedFactorCacheInner {
436    pub(crate) entries: HashMap<ProjectedFactorKey, ProjectedFactorEntry>,
437    pub(crate) in_progress: HashMap<ProjectedFactorKey, Arc<ProjectedFactorInProgress>>,
438    pub(crate) next_seq: u64,
439    pub(crate) total_bytes: usize,
440    pub(crate) budget_bytes: usize,
441}
442
443pub(crate) struct ProjectedFactorInProgress {
444    pub(crate) state: Mutex<Option<ProjectedFactorInProgressState>>,
445    pub(crate) ready: Condvar,
446    pub(crate) waiter_count: std::sync::atomic::AtomicUsize,
447    pub(crate) subscriber_arrived: (Mutex<()>, Condvar),
448}
449
450pub(crate) enum ProjectedFactorInProgressState {
451    Ready(Arc<Array2<f64>>),
452    Failed,
453}
454
455pub(crate) struct ProjectedFactorEntry {
456    pub(crate) value: Arc<Array2<f64>>,
457    pub(crate) bytes: usize,
458    pub(crate) last_used: u64,
459}
460
461impl Default for ProjectedFactorCache {
462    fn default() -> Self {
463        Self::with_budget(Self::DEFAULT_BUDGET_BYTES)
464    }
465}
466
467impl ProjectedFactorCache {
468    pub const DEFAULT_BUDGET_BYTES: usize = 2 * 1024 * 1024 * 1024;
469
470    pub fn with_budget(budget_bytes: usize) -> Self {
471        Self {
472            inner: Mutex::new(ProjectedFactorCacheInner {
473                entries: HashMap::new(),
474                in_progress: HashMap::new(),
475                next_seq: 0,
476                total_bytes: 0,
477                budget_bytes,
478            }),
479        }
480    }
481
482    pub fn get_or_insert_with(
483        &self,
484        key: ProjectedFactorKey,
485        compute: impl FnOnce() -> Array2<f64>,
486    ) -> Arc<Array2<f64>> {
487        enum CacheLookup {
488            Hit(Arc<Array2<f64>>),
489            Wait(Arc<ProjectedFactorInProgress>),
490            Compute(Arc<ProjectedFactorInProgress>),
491        }
492
493        let lookup = {
494            let mut inner = self
495                .inner
496                .lock()
497                .expect("projected factor cache lock poisoned");
498            inner.next_seq += 1;
499            let now = inner.next_seq;
500            if let Some(entry) = inner.entries.get_mut(&key) {
501                entry.last_used = now;
502                CacheLookup::Hit(entry.value.clone())
503            } else if let Some(waiter) = inner.in_progress.get(&key) {
504                CacheLookup::Wait(waiter.clone())
505            } else {
506                let marker = Arc::new(ProjectedFactorInProgress {
507                    state: Mutex::new(None),
508                    ready: Condvar::new(),
509                    waiter_count: std::sync::atomic::AtomicUsize::new(0),
510                    subscriber_arrived: (Mutex::new(()), Condvar::new()),
511                });
512                inner.in_progress.insert(key, marker.clone());
513                CacheLookup::Compute(marker)
514            }
515        };
516
517        match lookup {
518            CacheLookup::Hit(value) => value,
519            CacheLookup::Wait(marker) => {
520                marker
521                    .waiter_count
522                    .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
523                let (lock, cv) = &marker.subscriber_arrived;
524                drop(
525                    lock.lock()
526                        .expect("subscriber-arrived notification lock poisoned"),
527                );
528                cv.notify_all();
529                let mut guard = marker
530                    .state
531                    .lock()
532                    .expect("projected factor in-progress lock poisoned");
533                let result = loop {
534                    match guard.as_ref() {
535                        Some(ProjectedFactorInProgressState::Ready(value)) => {
536                            break value.clone();
537                        }
538                        Some(ProjectedFactorInProgressState::Failed) => {
539                            marker
540                                .waiter_count
541                                .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
542                            reml_contract_panic("projected factor cache producer panicked")
543                        }
544                        None => {
545                            guard = marker
546                                .ready
547                                .wait(guard)
548                                .expect("projected factor in-progress wait poisoned");
549                        }
550                    }
551                };
552                marker
553                    .waiter_count
554                    .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
555                result
556            }
557            CacheLookup::Compute(marker) => {
558                let computed = match catch_unwind(AssertUnwindSafe(|| Arc::new(compute()))) {
559                    Ok(value) => value,
560                    Err(payload) => {
561                        let mut inner = self
562                            .inner
563                            .lock()
564                            .expect("projected factor cache lock poisoned");
565                        inner.in_progress.remove(&key);
566                        drop(inner);
567
568                        let mut guard = marker
569                            .state
570                            .lock()
571                            .expect("projected factor in-progress lock poisoned");
572                        *guard = Some(ProjectedFactorInProgressState::Failed);
573                        marker.ready.notify_all();
574                        resume_unwind(payload);
575                    }
576                };
577                let bytes = computed.len().saturating_mul(std::mem::size_of::<f64>());
578                let mut inner = self
579                    .inner
580                    .lock()
581                    .expect("projected factor cache lock poisoned");
582                inner.next_seq += 1;
583                let now = inner.next_seq;
584
585                if inner.budget_bytes > 0 && bytes <= inner.budget_bytes {
586                    while inner.total_bytes.saturating_add(bytes) > inner.budget_bytes
587                        && !inner.entries.is_empty()
588                    {
589                        let Some(oldest_key) = inner
590                            .entries
591                            .iter()
592                            .min_by_key(|(_, e)| e.last_used)
593                            .map(|(k, _)| *k)
594                        else {
595                            break;
596                        };
597                        if let Some(removed) = inner.entries.remove(&oldest_key) {
598                            inner.total_bytes = inner.total_bytes.saturating_sub(removed.bytes);
599                        }
600                    }
601                }
602
603                let value = if let Some(entry) = inner.entries.get_mut(&key) {
604                    entry.last_used = now;
605                    entry.value.clone()
606                } else {
607                    inner.entries.insert(
608                        key,
609                        ProjectedFactorEntry {
610                            value: computed.clone(),
611                            bytes,
612                            last_used: now,
613                        },
614                    );
615                    inner.total_bytes = inner.total_bytes.saturating_add(bytes);
616                    computed
617                };
618                inner.in_progress.remove(&key);
619                drop(inner);
620
621                let mut guard = marker
622                    .state
623                    .lock()
624                    .expect("projected factor in-progress lock poisoned");
625                *guard = Some(ProjectedFactorInProgressState::Ready(value.clone()));
626                marker.ready.notify_all();
627                value
628            }
629        }
630    }
631
632    pub fn len(&self) -> usize {
633        self.inner
634            .lock()
635            .map(|inner| inner.entries.len())
636            .unwrap_or(0)
637    }
638
639    pub fn total_bytes(&self) -> usize {
640        self.inner
641            .lock()
642            .map(|inner| inner.total_bytes)
643            .unwrap_or(0)
644    }
645
646    pub fn is_empty(&self) -> bool {
647        self.len() == 0
648    }
649
650    /// Test/diagnostic affordance: block until a consumer has subscribed to the
651    /// in-progress slot for `key` (i.e. is waiting on the producer), or until
652    /// `timeout` elapses. Returns `true` if a subscriber arrived, `false` if the
653    /// key has no in-progress slot or the wait timed out.
654    ///
655    /// This lives on the cache because it reaches into the per-key subscriber
656    /// condvar and waiter counter, which are private synchronization internals;
657    /// exposing it as a method keeps those fields encapsulated while still
658    /// letting downstream tests deterministically order producer/consumer
659    /// interleavings.
660    pub fn wait_for_subscriber(
661        &self,
662        key: ProjectedFactorKey,
663        timeout: std::time::Duration,
664    ) -> bool {
665        let marker = {
666            let inner = self
667                .inner
668                .lock()
669                .expect("projected factor cache lock poisoned");
670            let Some(m) = inner.in_progress.get(&key) else {
671                return false;
672            };
673            Arc::clone(m)
674        };
675        if marker
676            .waiter_count
677            .load(std::sync::atomic::Ordering::Acquire)
678            > 0
679        {
680            return true;
681        }
682        let (lock, cv) = &marker.subscriber_arrived;
683        let mut guard = lock
684            .lock()
685            .expect("subscriber-arrived notification lock poisoned");
686        let deadline = std::time::Instant::now() + timeout;
687        loop {
688            if marker
689                .waiter_count
690                .load(std::sync::atomic::Ordering::Acquire)
691                > 0
692            {
693                return true;
694            }
695            let now = std::time::Instant::now();
696            if now >= deadline {
697                return false;
698            }
699            let (next_guard, result) = cv
700                .wait_timeout(guard, deadline - now)
701                .expect("subscriber-arrived wait poisoned");
702            guard = next_guard;
703            if result.timed_out()
704                && marker
705                    .waiter_count
706                    .load(std::sync::atomic::Ordering::Acquire)
707                    == 0
708            {
709                return false;
710            }
711        }
712    }
713}
714
715#[derive(Clone)]
716pub struct DenseMatrixHyperOperator {
717    pub matrix: Array2<f64>,
718}
719
720impl HyperOperator for DenseMatrixHyperOperator {
721    fn dim(&self) -> usize {
722        self.matrix.nrows()
723    }
724
725    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
726        self.matrix.dot(v)
727    }
728
729    fn as_any(&self) -> &(dyn Any + 'static) {
730        self
731    }
732
733    fn mul_vec_view(&self, v: ArrayView1<'_, f64>) -> Array1<f64> {
734        self.matrix.dot(&v)
735    }
736
737    fn mul_vec_into(&self, v: ArrayView1<'_, f64>, mut out: ArrayViewMut1<'_, f64>) {
738        assert_eq!(self.matrix.ncols(), v.len());
739        assert_eq!(self.matrix.nrows(), out.len());
740        for (row, out_value) in self.matrix.rows().into_iter().zip(out.iter_mut()) {
741            *out_value = row.dot(&v);
742        }
743    }
744
745    fn mul_basis_columns_into(&self, start: usize, mut out: ArrayViewMut2<'_, f64>) {
746        let end = start + out.ncols();
747        assert!(end <= self.matrix.ncols());
748        out.assign(&self.matrix.slice(ndarray::s![.., start..end]));
749    }
750
751    fn scaled_add_mul_vec(
752        &self,
753        v: ArrayView1<'_, f64>,
754        scale: f64,
755        mut out: ArrayViewMut1<'_, f64>,
756    ) {
757        assert_eq!(self.matrix.ncols(), v.len());
758        assert_eq!(self.matrix.nrows(), out.len());
759        if scale == 0.0 {
760            return;
761        }
762        for (row, out_value) in self.matrix.rows().into_iter().zip(out.iter_mut()) {
763            *out_value += scale * row.dot(&v);
764        }
765    }
766
767    fn bilinear(&self, v: &Array1<f64>, u: &Array1<f64>) -> f64 {
768        dense::bilinear(&self.matrix, v.view(), u.view())
769    }
770
771    fn bilinear_view(&self, v: ArrayView1<'_, f64>, u: ArrayView1<'_, f64>) -> f64 {
772        dense::bilinear(&self.matrix, v, u)
773    }
774
775    fn to_dense(&self) -> Array2<f64> {
776        self.matrix.clone()
777    }
778
779    fn is_implicit(&self) -> bool {
780        false
781    }
782}
783
784#[derive(Clone)]
785pub struct BlockLocalDrift {
786    pub local: Array2<f64>,
787    pub start: usize,
788    pub end: usize,
789    pub total_dim: usize,
790}
791
792impl HyperOperator for BlockLocalDrift {
793    fn dim(&self) -> usize {
794        self.total_dim
795    }
796
797    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
798        assert_eq!(v.len(), self.total_dim);
799        let mut out = Array1::zeros(self.total_dim);
800        self.mul_vec_into(v.view(), out.view_mut());
801        out
802    }
803
804    fn as_any(&self) -> &(dyn Any + 'static) {
805        self
806    }
807
808    fn mul_vec_into(&self, v: ArrayView1<'_, f64>, mut out: ArrayViewMut1<'_, f64>) {
809        assert_eq!(v.len(), self.total_dim);
810        assert_eq!(out.len(), self.total_dim);
811        out.fill(0.0);
812        let v_block = v.slice(ndarray::s![self.start..self.end]);
813        let mut out_block = out.slice_mut(ndarray::s![self.start..self.end]);
814        dense::matvec_into(&self.local, v_block, out_block.view_mut());
815    }
816
817    fn scaled_add_mul_vec(
818        &self,
819        v: ArrayView1<'_, f64>,
820        scale: f64,
821        mut out: ArrayViewMut1<'_, f64>,
822    ) {
823        assert_eq!(v.len(), self.total_dim);
824        assert_eq!(out.len(), self.total_dim);
825        if scale == 0.0 {
826            return;
827        }
828        let v_block = v.slice(ndarray::s![self.start..self.end]);
829        let out_block = out.slice_mut(ndarray::s![self.start..self.end]);
830        dense::matvec_scaled_add_into(&self.local, v_block, scale, out_block);
831    }
832
833    fn bilinear(&self, v: &Array1<f64>, u: &Array1<f64>) -> f64 {
834        self.bilinear_view(v.view(), u.view())
835    }
836
837    fn bilinear_view(&self, v: ArrayView1<'_, f64>, u: ArrayView1<'_, f64>) -> f64 {
838        assert_eq!(v.len(), self.total_dim);
839        assert_eq!(u.len(), self.total_dim);
840        let v_block = v.slice(ndarray::s![self.start..self.end]);
841        let u_block = u.slice(ndarray::s![self.start..self.end]);
842        dense::bilinear(&self.local, v_block, u_block)
843    }
844
845    fn to_dense(&self) -> Array2<f64> {
846        let p = self.total_dim;
847        let mut out = Array2::zeros((p, p));
848        out.slice_mut(ndarray::s![self.start..self.end, self.start..self.end])
849            .assign(&self.local);
850        out
851    }
852
853    fn is_implicit(&self) -> bool {
854        false
855    }
856
857    fn block_local_data(&self) -> Option<(&Array2<f64>, usize, usize)> {
858        Some((&self.local, self.start, self.end))
859    }
860}
861
862#[derive(Clone)]
863pub struct HyperCoordDrift {
864    pub dense: Option<Array2<f64>>,
865    pub block_local: Option<BlockLocalDrift>,
866    pub operator: Option<Arc<dyn HyperOperator>>,
867}
868
869impl HyperCoordDrift {
870    pub fn none() -> Self {
871        Self {
872            dense: None,
873            block_local: None,
874            operator: None,
875        }
876    }
877
878    pub fn from_dense(dense: Array2<f64>) -> Self {
879        Self {
880            dense: Some(dense),
881            block_local: None,
882            operator: None,
883        }
884    }
885
886    pub fn from_operator(operator: Arc<dyn HyperOperator>) -> Self {
887        Self {
888            dense: None,
889            block_local: None,
890            operator: Some(operator),
891        }
892    }
893
894    pub fn from_parts(
895        dense: Option<Array2<f64>>,
896        operator: Option<Arc<dyn HyperOperator>>,
897    ) -> Self {
898        let dense = dense.filter(|mat| !(operator.is_some() && mat.is_empty()));
899        Self {
900            dense,
901            block_local: None,
902            operator,
903        }
904    }
905
906    pub fn from_block_local_and_operator(
907        local: Array2<f64>,
908        start: usize,
909        end: usize,
910        total_dim: usize,
911        operator: Option<Arc<dyn HyperOperator>>,
912    ) -> Self {
913        Self {
914            dense: None,
915            block_local: Some(BlockLocalDrift {
916                local,
917                start,
918                end,
919                total_dim,
920            }),
921            operator,
922        }
923    }
924
925    pub fn has_operator(&self) -> bool {
926        self.operator.is_some()
927    }
928
929    pub fn uses_operator_fast_path(&self) -> bool {
930        self.operator.is_some() || self.block_local.is_some()
931    }
932
933    pub fn operator_ref(&self) -> Option<&dyn HyperOperator> {
934        self.operator.as_ref().map(Arc::as_ref)
935    }
936
937    pub fn materialize(&self) -> Array2<f64> {
938        let p = self.infer_dim();
939        if p == 0 {
940            return Array2::zeros((0, 0));
941        }
942        let mut out = self.dense.clone().unwrap_or_else(|| Array2::zeros((p, p)));
943        if let Some(bl) = &self.block_local {
944            out.slice_mut(ndarray::s![bl.start..bl.end, bl.start..bl.end])
945                .scaled_add(1.0, &bl.local);
946        }
947        if let Some(op) = &self.operator {
948            out += &op.to_dense();
949        }
950        out
951    }
952
953    pub fn apply(&self, v: &Array1<f64>) -> Array1<f64> {
954        let mut out = Array1::zeros(v.len());
955        self.scaled_add_apply(v.view(), 1.0, &mut out);
956        out
957    }
958
959    pub fn scaled_add_apply(&self, v: ArrayView1<'_, f64>, scale: f64, out: &mut Array1<f64>) {
960        assert_eq!(v.len(), out.len());
961        if scale == 0.0 {
962            return;
963        }
964        if let Some(dense) = &self.dense {
965            dense::matvec_scaled_add_into(dense, v, scale, out.view_mut());
966        }
967        if let Some(bl) = &self.block_local {
968            let v_block = v.slice(ndarray::s![bl.start..bl.end]);
969            let out_block = out.slice_mut(ndarray::s![bl.start..bl.end]);
970            dense::matvec_scaled_add_into(&bl.local, v_block, scale, out_block);
971        }
972        if let Some(op) = &self.operator {
973            op.scaled_add_mul_vec(v, scale, out.view_mut());
974        }
975    }
976
977    pub(crate) fn infer_dim(&self) -> usize {
978        if let Some(d) = &self.dense {
979            return d.nrows();
980        }
981        if let Some(op) = &self.operator {
982            return op.dim();
983        }
984        if let Some(bl) = &self.block_local {
985            return bl.total_dim;
986        }
987        0
988    }
989}
990
991#[derive(Clone)]
992pub struct HyperCoord {
993    pub a: f64,
994    pub g: Array1<f64>,
995    pub drift: HyperCoordDrift,
996    pub ld_s: f64,
997    pub b_depends_on_beta: bool,
998    pub is_penalty_like: bool,
999    pub firth_g: Option<Array1<f64>>,
1000    pub tk_eta_fixed: Option<Array1<f64>>,
1001    pub tk_x_fixed: Option<Array2<f64>>,
1002}
1003
1004#[derive(Clone)]
1005pub struct HyperCoordPair {
1006    pub a: f64,
1007    pub g: Array1<f64>,
1008    pub b_mat: Array2<f64>,
1009    pub b_operator: Option<Arc<dyn HyperOperator>>,
1010    pub ld_s: f64,
1011}
1012
1013/// Fallible result of computing one second-order fixed-β coordinate pair.
1014///
1015/// Pair assembly may call an immutable family workspace whose shape and
1016/// numerical validation are deliberately error-capable. Keeping that failure
1017/// in the callback contract lets dense and operator Hessian consumers stop
1018/// with the original evidence instead of converting it into a panic.
1019pub type HyperCoordPairResult = Result<HyperCoordPair, String>;
1020
1021/// Shared-ownership callback computing a second-order fixed-β
1022/// [`HyperCoordPairResult`] for a coordinate pair `(i, j)`.
1023///
1024/// `Arc` (not `Box`) so the same callback can be cloned into a derived
1025/// `InnerSolution` — notably the tangent-projected solution built under active
1026/// inequality constraints, which must carry the very same pair callbacks
1027/// through to `ValueGradientHessian` outer-Hessian assembly. The pair objects
1028/// are p-space; every consumer contracts them through the (possibly
1029/// tangent-wrapped) Hessian operator, which applies the `ZᵀMZ` / `Z H_T⁻¹ Zᵀ`
1030/// projection internally, so a clone-through is mathematically exact.
1031pub type HyperCoordPairFn = Arc<dyn Fn(usize, usize) -> HyperCoordPairResult + Send + Sync>;
1032
1033impl HyperCoordPair {
1034    pub fn zero() -> Self {
1035        Self {
1036            a: 0.0,
1037            g: Array1::zeros(0),
1038            b_mat: Array2::zeros((0, 0)),
1039            b_operator: None,
1040            ld_s: 0.0,
1041        }
1042    }
1043}
1044
1045#[derive(Clone)]
1046pub enum DriftDerivResult {
1047    Dense(Array2<f64>),
1048    Operator(Arc<dyn HyperOperator>),
1049}
1050
1051impl std::fmt::Debug for DriftDerivResult {
1052    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1053        match self {
1054            Self::Dense(matrix) => f
1055                .debug_tuple("Dense")
1056                .field(&format_args!("{}x{}", matrix.nrows(), matrix.ncols()))
1057                .finish(),
1058            Self::Operator(_) => f
1059                .debug_tuple("Operator")
1060                .field(&"<hyper-operator>")
1061                .finish(),
1062        }
1063    }
1064}
1065
1066impl DriftDerivResult {
1067    pub fn into_operator(self) -> Arc<dyn HyperOperator> {
1068        match self {
1069            Self::Dense(matrix) => Arc::new(DenseMatrixHyperOperator { matrix }),
1070            Self::Operator(operator) => operator,
1071        }
1072    }
1073
1074    pub fn apply(&self, v: &Array1<f64>) -> Array1<f64> {
1075        match self {
1076            Self::Dense(matrix) => matrix.dot(v),
1077            Self::Operator(operator) => operator.mul_vec(v),
1078        }
1079    }
1080}
1081
1082pub type FixedDriftDerivFn =
1083    Box<dyn Fn(usize, &Array1<f64>) -> Result<Option<DriftDerivResult>, String> + Send + Sync>;
1084
1085/// Shared-ownership form of [`FixedDriftDerivFn`] used for `InnerSolution`
1086/// storage, so the same `M_i[u] = D_β B_i[u]` callback can be cloned into a
1087/// derived (tangent-projected) solution. Construction sites still hand back a
1088/// `Box` ([`FixedDriftDerivFn`]); storage re-tags it via `Arc::from` (free).
1089/// The drift `M` is a p-space matrix that every consumer contracts through the
1090/// (tangent-wrapped) Hessian operator's `trace_logdet_*`, so the clone-through
1091/// is exact under projection.
1092pub type SharedFixedDriftDerivFn =
1093    Arc<dyn Fn(usize, &Array1<f64>) -> Result<Option<DriftDerivResult>, String> + Send + Sync>;
1094
1095pub struct ContractedPsiSecondOrder {
1096    pub objective: Array1<f64>,
1097    pub score: Array2<f64>,
1098    pub hessian: Vec<DriftDerivResult>,
1099    pub ld_s: Array1<f64>,
1100}
1101
1102pub type ContractedPsiSecondOrderFn =
1103    Arc<dyn Fn(&[f64]) -> Result<Option<ContractedPsiSecondOrder>, String> + Send + Sync>;