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