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