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