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