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