Skip to main content

gam_math/
score_opt.rs

1//! Certified global optimization of one-dimensional scores on a bounded
2//! domain, together with the affine-pencil spectral profile shared by the
3//! Gaussian REML smoothing-parameter searches.
4//!
5//! Point samples alone cannot prove that a smooth function has no narrow
6//! stationary pair between them.  The search therefore requires two pieces of
7//! information from its caller:
8//!
9//! * an exact point evaluation `(value, first derivative, second derivative)`;
10//! * an OUTER enclosure of both derivatives over every requested interval.
11//!
12//! An interval is discarded only when its first-derivative enclosure excludes
13//! zero.  A stationary point is refined only after the second-derivative
14//! enclosure excludes zero, proving that the first derivative is monotone and
15//! hence that a straddling interval contains exactly one root.  Every other
16//! interval is subdivided.  If floating-point spacing or the caller-requested
17//! resolution is reached before either fact is proved, the result is a typed
18//! [`ScoreSearchError::Unresolved`] rather than a best-effort optimum.
19//!
20//! [`AffineRemlProfile`] supplies both the point jets and rigorous interval
21//! formulas for scores whose penalized Hessian has simultaneously diagonal
22//! affine modes `h_i(lambda) = g_i + lambda s_i`.  This covers an ordinary
23//! Demmler--Reinsch eigensystem (`g_i = 1`) and a reference-Hessian pencil
24//! (`g_i = 1 - lambda_0 mu_i`, `s_i = mu_i`) without any matrix dependency in
25//! this crate.
26
27use std::fmt;
28
29/// Closed real interval `[lo, hi]`.
30///
31/// Search callbacks may use infinite endpoints for conservative bounds, but
32/// neither endpoint may be NaN and `lo <= hi` must hold.  The search validates
33/// every enclosure returned by a callback.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct ClosedInterval {
36    pub lo: f64,
37    pub hi: f64,
38}
39
40impl ClosedInterval {
41    #[inline]
42    pub const fn new(lo: f64, hi: f64) -> Self {
43        Self { lo, hi }
44    }
45
46    /// Construct an interval and round both supplied bounds one representable
47    /// value outward.  This is the public bridge for callers that derive a
48    /// real-valued bound with ordinary nearest-rounded scalar arithmetic.
49    #[inline]
50    pub fn outward(lo: f64, hi: f64) -> Self {
51        Self {
52            lo: next_down(lo),
53            hi: next_up(hi),
54        }
55    }
56
57    #[inline]
58    pub const fn point(value: f64) -> Self {
59        Self {
60            lo: value,
61            hi: value,
62        }
63    }
64
65    #[inline]
66    pub const fn entire() -> Self {
67        Self {
68            lo: f64::NEG_INFINITY,
69            hi: f64::INFINITY,
70        }
71    }
72
73    #[inline]
74    pub fn contains(self, value: f64) -> bool {
75        self.lo <= value && value <= self.hi
76    }
77
78    #[inline]
79    pub fn contains_zero(self) -> bool {
80        self.contains(0.0)
81    }
82
83    #[inline]
84    fn is_valid(self) -> bool {
85        !self.lo.is_nan() && !self.hi.is_nan() && self.lo <= self.hi
86    }
87
88    #[inline]
89    fn hull(self, other: Self) -> Self {
90        Self {
91            lo: self.lo.min(other.lo),
92            hi: self.hi.max(other.hi),
93        }
94    }
95
96    #[inline]
97    fn add(self, other: Self) -> Self {
98        Self {
99            lo: next_down(self.lo + other.lo),
100            hi: next_up(self.hi + other.hi),
101        }
102    }
103
104    #[inline]
105    fn sub(self, other: Self) -> Self {
106        Self {
107            lo: next_down(self.lo - other.hi),
108            hi: next_up(self.hi - other.lo),
109        }
110    }
111
112    #[inline]
113    fn neg(self) -> Self {
114        Self {
115            lo: next_down(-self.hi),
116            hi: next_up(-self.lo),
117        }
118    }
119
120    fn mul(self, other: Self) -> Self {
121        let products = [
122            self.lo * other.lo,
123            self.lo * other.hi,
124            self.hi * other.lo,
125            self.hi * other.hi,
126        ];
127        let mut lo = f64::INFINITY;
128        let mut hi = f64::NEG_INFINITY;
129        for value in products {
130            lo = lo.min(value);
131            hi = hi.max(value);
132        }
133        Self {
134            lo: next_down(lo),
135            hi: next_up(hi),
136        }
137    }
138
139    #[inline]
140    fn scale(self, value: f64) -> Self {
141        self.mul(Self::point(value))
142    }
143
144    fn square(self) -> Self {
145        if self.lo >= 0.0 {
146            Self {
147                lo: next_down(self.lo * self.lo).max(0.0),
148                hi: next_up(self.hi * self.hi),
149            }
150        } else if self.hi <= 0.0 {
151            Self {
152                lo: next_down(self.hi * self.hi).max(0.0),
153                hi: next_up(self.lo * self.lo),
154            }
155        } else {
156            Self {
157                lo: 0.0,
158                hi: next_up((self.lo * self.lo).max(self.hi * self.hi)),
159            }
160        }
161    }
162
163    /// Divide by an interval known to be strictly positive.
164    fn div_positive(self, denominator: Self) -> Self {
165        assert!(
166            denominator.lo > 0.0,
167            "div_positive requires a strictly positive denominator interval, got lo={}",
168            denominator.lo
169        );
170        let reciprocal = Self {
171            lo: next_down(1.0 / denominator.hi).max(0.0),
172            hi: next_up(1.0 / denominator.lo),
173        };
174        self.mul(reciprocal)
175    }
176
177    #[inline]
178    fn nonnegative(self) -> Self {
179        Self {
180            lo: self.lo.max(0.0),
181            hi: self.hi.max(0.0),
182        }
183    }
184}
185
186/// Value and first two analytic derivatives at one abscissa.
187#[derive(Clone, Copy, Debug, PartialEq)]
188pub struct ScoreJet {
189    pub value: f64,
190    pub derivative: f64,
191    pub curvature: f64,
192}
193
194/// A point evaluation augmented with its abscissa.
195#[derive(Clone, Copy, Debug, PartialEq)]
196pub struct ScoreSample {
197    pub x: f64,
198    pub value: f64,
199    pub derivative: f64,
200    pub curvature: f64,
201}
202
203/// Outer derivative ranges supplied to the certified search.
204#[derive(Clone, Copy, Debug, PartialEq)]
205pub struct DerivativeEnclosure {
206    pub derivative: ClosedInterval,
207    pub curvature: ClosedInterval,
208}
209
210/// One stationary point together with the final bracket that certifies its
211/// location.  The bracket width is no larger than the requested resolution,
212/// unless the point was represented exactly (a zero-width bracket).
213#[derive(Clone, Copy, Debug, PartialEq)]
214pub struct StationaryPoint {
215    pub sample: ScoreSample,
216    pub bracket: ClosedInterval,
217}
218
219#[derive(Clone, Copy, Debug, PartialEq, Eq)]
220pub enum ScoreOptimumLocation {
221    LowerBoundary,
222    UpperBoundary,
223    Stationary(usize),
224}
225
226/// Complete successful search result.  Endpoints are retained explicitly so
227/// the global comparison is independently checkable by the caller.
228#[derive(Clone, Debug, PartialEq)]
229pub struct ScoreSearchResult {
230    pub optimum: ScoreSample,
231    pub location: ScoreOptimumLocation,
232    pub lower_boundary: ScoreSample,
233    pub upper_boundary: ScoreSample,
234    pub stationary_points: Vec<StationaryPoint>,
235}
236
237/// Failure of the generic certified search.
238#[derive(Debug)]
239pub enum ScoreSearchError<E> {
240    InvalidDomain {
241        lo: f64,
242        hi: f64,
243    },
244    InvalidResolution {
245        resolution: f64,
246    },
247    PointEvaluation {
248        x: f64,
249        source: E,
250    },
251    EnclosureEvaluation {
252        lo: f64,
253        hi: f64,
254        source: E,
255    },
256    NonFiniteSample {
257        sample: ScoreSample,
258    },
259    InvalidEnclosure {
260        lo: f64,
261        hi: f64,
262        enclosure: DerivativeEnclosure,
263    },
264    EnclosureMissesEndpoint {
265        lo: f64,
266        hi: f64,
267        endpoint: ScoreSample,
268        enclosure: DerivativeEnclosure,
269    },
270    /// The enclosure still admits both a stationary point and a curvature
271    /// zero, so uniqueness could not be proved before the requested or
272    /// floating-point resolution floor.
273    Unresolved {
274        lo: f64,
275        hi: f64,
276        requested_resolution: f64,
277        enclosure: DerivativeEnclosure,
278    },
279}
280
281impl<E: fmt::Display> fmt::Display for ScoreSearchError<E> {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        match self {
284            Self::InvalidDomain { lo, hi } => {
285                write!(f, "score search: invalid domain [{lo}, {hi}]")
286            }
287            Self::InvalidResolution { resolution } => {
288                write!(f, "score search: invalid resolution {resolution}")
289            }
290            Self::PointEvaluation { x, source } => {
291                write!(f, "score search: evaluation failed at {x}: {source}")
292            }
293            Self::EnclosureEvaluation { lo, hi, source } => write!(
294                f,
295                "score search: derivative enclosure failed on [{lo}, {hi}]: {source}"
296            ),
297            Self::NonFiniteSample { sample } => write!(
298                f,
299                "score search: non-finite jet at {} (value {}, derivative {}, curvature {})",
300                sample.x, sample.value, sample.derivative, sample.curvature
301            ),
302            Self::InvalidEnclosure { lo, hi, enclosure } => write!(
303                f,
304                "score search: invalid derivative enclosure on [{lo}, {hi}]: {enclosure:?}"
305            ),
306            Self::EnclosureMissesEndpoint {
307                lo,
308                hi,
309                endpoint,
310                enclosure,
311            } => write!(
312                f,
313                "score search: enclosure on [{lo}, {hi}] misses endpoint jet at {}: {endpoint:?} not in {enclosure:?}",
314                endpoint.x
315            ),
316            Self::Unresolved {
317                lo,
318                hi,
319                requested_resolution,
320                enclosure,
321            } => write!(
322                f,
323                "score search: stationary structure unresolved on [{lo}, {hi}] at requested resolution {requested_resolution}: {enclosure:?}"
324            ),
325        }
326    }
327}
328
329impl<E: std::error::Error + 'static> std::error::Error for ScoreSearchError<E> {}
330
331#[derive(Clone, Copy)]
332struct SearchNode {
333    left: ScoreSample,
334    right: ScoreSample,
335}
336
337fn evaluate_sample<E, F>(x: f64, evaluate: &mut F) -> Result<ScoreSample, ScoreSearchError<E>>
338where
339    F: FnMut(f64) -> Result<ScoreJet, E>,
340{
341    let jet = evaluate(x).map_err(|source| ScoreSearchError::PointEvaluation { x, source })?;
342    let sample = ScoreSample {
343        x,
344        value: jet.value,
345        derivative: jet.derivative,
346        curvature: jet.curvature,
347    };
348    if sample.value.is_finite() && sample.derivative.is_finite() && sample.curvature.is_finite() {
349        Ok(sample)
350    } else {
351        Err(ScoreSearchError::NonFiniteSample { sample })
352    }
353}
354
355fn checked_enclosure<E, F>(
356    node: SearchNode,
357    enclose: &mut F,
358) -> Result<DerivativeEnclosure, ScoreSearchError<E>>
359where
360    F: FnMut(f64, f64) -> Result<DerivativeEnclosure, E>,
361{
362    let lo = node.left.x;
363    let hi = node.right.x;
364    let enclosure = enclose(lo, hi).map_err(|source| ScoreSearchError::EnclosureEvaluation {
365        lo,
366        hi,
367        source,
368    })?;
369    if !(enclosure.derivative.is_valid() && enclosure.curvature.is_valid()) {
370        return Err(ScoreSearchError::InvalidEnclosure { lo, hi, enclosure });
371    }
372    for endpoint in [node.left, node.right] {
373        if !(enclosure.derivative.contains(endpoint.derivative)
374            && enclosure.curvature.contains(endpoint.curvature))
375        {
376            return Err(ScoreSearchError::EnclosureMissesEndpoint {
377                lo,
378                hi,
379                endpoint,
380                enclosure,
381            });
382        }
383    }
384    Ok(enclosure)
385}
386
387/// Refine a UNIQUE derivative root.  The caller has already proved uniqueness
388/// by a curvature enclosure that excludes zero and supplied endpoint
389/// derivatives of opposite sign.
390fn refine_unique_root<E, F>(
391    mut left: ScoreSample,
392    mut right: ScoreSample,
393    resolution: f64,
394    enclosure: DerivativeEnclosure,
395    evaluate: &mut F,
396) -> Result<StationaryPoint, ScoreSearchError<E>>
397where
398    F: FnMut(f64) -> Result<ScoreJet, E>,
399{
400    // A unique-root refinement is only meaningful on a strict sign-change
401    // bracket; anything else is a caller error surfaced as a typed rejection.
402    if left.derivative == 0.0
403        || right.derivative == 0.0
404        || left.derivative.is_sign_positive() == right.derivative.is_sign_positive()
405    {
406        return Err(ScoreSearchError::InvalidEnclosure {
407            lo: left.x,
408            hi: right.x,
409            enclosure,
410        });
411    }
412
413    while right.x - left.x > resolution {
414        let width = right.x - left.x;
415        let midpoint = left.x + 0.5 * width;
416        if !(midpoint > left.x && midpoint < right.x) {
417            return Err(ScoreSearchError::Unresolved {
418                lo: left.x,
419                hi: right.x,
420                requested_resolution: resolution,
421                enclosure,
422            });
423        }
424
425        // Newton is accepted only in the central half of the bracket.  Thus
426        // every accepted point, Newton or midpoint, contracts the maintained
427        // sign bracket by at least one quarter.  The loop has no iteration cap
428        // because its geometric termination follows from this safeguard.
429        let base = if left.derivative.abs() <= right.derivative.abs() {
430            left
431        } else {
432            right
433        };
434        let newton = if base.curvature != 0.0 {
435            base.x - base.derivative / base.curvature
436        } else {
437            f64::NAN
438        };
439        let guard = 0.25 * width;
440        let x = if newton.is_finite() && newton >= left.x + guard && newton <= right.x - guard {
441            newton
442        } else {
443            midpoint
444        };
445        if !(x > left.x && x < right.x) {
446            return Err(ScoreSearchError::Unresolved {
447                lo: left.x,
448                hi: right.x,
449                requested_resolution: resolution,
450                enclosure,
451            });
452        }
453        let sample = evaluate_sample(x, evaluate)?;
454        if sample.derivative == 0.0 {
455            return Ok(StationaryPoint {
456                sample,
457                bracket: ClosedInterval::point(x),
458            });
459        }
460        if sample.derivative.is_sign_positive() == left.derivative.is_sign_positive() {
461            left = sample;
462        } else {
463            right = sample;
464        }
465    }
466
467    let midpoint = left.x + 0.5 * (right.x - left.x);
468    let sample = if midpoint > left.x && midpoint < right.x {
469        evaluate_sample(midpoint, evaluate)?
470    } else if left.derivative.abs() <= right.derivative.abs() {
471        left
472    } else {
473        right
474    };
475    Ok(StationaryPoint {
476        sample,
477        bracket: ClosedInterval::new(left.x, right.x),
478    })
479}
480
481/// Globally maximize a smooth score on `[lo, hi]` by certified stationary
482/// isolation.
483///
484/// `evaluate` returns the score and its first two analytic derivatives at a
485/// point. `enclose(a, b)` must return OUTER ranges containing the first and
486/// second derivative at every point of `[a, b]`.  The search additionally
487/// checks that both endpoint jets lie inside every returned enclosure.
488///
489/// There is no evaluation or subdivision budget.  A successful return means
490/// every stationary interval was either excluded or isolated to `resolution`.
491/// Any interval that cannot be proved before that floor produces
492/// [`ScoreSearchError::Unresolved`].
493pub fn maximize_score_1d<E, Eval, Enclose>(
494    lo: f64,
495    hi: f64,
496    resolution: f64,
497    mut evaluate: Eval,
498    mut enclose: Enclose,
499) -> Result<ScoreSearchResult, ScoreSearchError<E>>
500where
501    Eval: FnMut(f64) -> Result<ScoreJet, E>,
502    Enclose: FnMut(f64, f64) -> Result<DerivativeEnclosure, E>,
503{
504    if !(lo.is_finite() && hi.is_finite() && lo <= hi && (hi - lo).is_finite()) {
505        return Err(ScoreSearchError::InvalidDomain { lo, hi });
506    }
507    if !(resolution.is_finite() && resolution > 0.0) {
508        return Err(ScoreSearchError::InvalidResolution { resolution });
509    }
510
511    let lower_boundary = evaluate_sample(lo, &mut evaluate)?;
512    if lo == hi {
513        return Ok(ScoreSearchResult {
514            optimum: lower_boundary,
515            location: ScoreOptimumLocation::LowerBoundary,
516            lower_boundary,
517            upper_boundary: lower_boundary,
518            stationary_points: Vec::new(),
519        });
520    }
521    let upper_boundary = evaluate_sample(hi, &mut evaluate)?;
522    let (mut optimum, mut location) = if upper_boundary.value > lower_boundary.value {
523        (upper_boundary, ScoreOptimumLocation::UpperBoundary)
524    } else {
525        (lower_boundary, ScoreOptimumLocation::LowerBoundary)
526    };
527
528    let mut stationary_points = Vec::<StationaryPoint>::new();
529    let mut stack = vec![SearchNode {
530        left: lower_boundary,
531        right: upper_boundary,
532    }];
533    while let Some(node) = stack.pop() {
534        let enclosure = checked_enclosure(node, &mut enclose)?;
535        if !enclosure.derivative.contains_zero() {
536            continue;
537        }
538
539        let monotone = !enclosure.curvature.contains_zero();
540        if monotone {
541            let stationary = if node.left.derivative == 0.0 {
542                Some(StationaryPoint {
543                    sample: node.left,
544                    bracket: ClosedInterval::point(node.left.x),
545                })
546            } else if node.right.derivative == 0.0 {
547                Some(StationaryPoint {
548                    sample: node.right,
549                    bracket: ClosedInterval::point(node.right.x),
550                })
551            } else if node.left.derivative.is_sign_positive()
552                != node.right.derivative.is_sign_positive()
553            {
554                Some(refine_unique_root(
555                    node.left,
556                    node.right,
557                    resolution,
558                    enclosure,
559                    &mut evaluate,
560                )?)
561            } else {
562                None
563            };
564
565            if let Some(stationary) = stationary {
566                // Two adjacent certified cells can report the same exact root
567                // when it lies on their common boundary.  Preserve one copy.
568                let duplicate = stationary_points
569                    .last()
570                    .is_some_and(|previous| previous.sample.x == stationary.sample.x);
571                if !duplicate {
572                    let index = stationary_points.len();
573                    if stationary.sample.value > optimum.value {
574                        optimum = stationary.sample;
575                        location = ScoreOptimumLocation::Stationary(index);
576                    }
577                    stationary_points.push(stationary);
578                }
579            }
580            continue;
581        }
582
583        let width = node.right.x - node.left.x;
584        let midpoint = node.left.x + 0.5 * width;
585        if width <= resolution || !(midpoint > node.left.x && midpoint < node.right.x) {
586            return Err(ScoreSearchError::Unresolved {
587                lo: node.left.x,
588                hi: node.right.x,
589                requested_resolution: resolution,
590                enclosure,
591            });
592        }
593        let middle = evaluate_sample(midpoint, &mut evaluate)?;
594        // Right first, then left: the LIFO traversal emits stationary points
595        // in ascending x, which makes exact-boundary de-duplication stable.
596        stack.push(SearchNode {
597            left: middle,
598            right: node.right,
599        });
600        stack.push(SearchNode {
601            left: node.left,
602            right: middle,
603        });
604    }
605
606    Ok(ScoreSearchResult {
607        optimum,
608        location,
609        lower_boundary,
610        upper_boundary,
611        stationary_points,
612    })
613}
614
615/// Static validation or evaluation failure for [`AffineRemlProfile`].
616#[derive(Clone, Copy, Debug, PartialEq)]
617pub enum AffineRemlError {
618    EmptyModes,
619    EmptyResponses,
620    ShapeMismatch {
621        gram_modes: usize,
622        penalty_modes: usize,
623        projected_rhs_squared: usize,
624        responses: usize,
625    },
626    InvalidMode {
627        index: usize,
628        gram: f64,
629        penalty: f64,
630    },
631    InvalidProjectedSquare {
632        index: usize,
633        value: f64,
634    },
635    InvalidResponseEnergy {
636        output: usize,
637        value: f64,
638    },
639    InvalidResidualDof {
640        value: f64,
641    },
642    InvalidLogdetConstant {
643        value: f64,
644    },
645    RankMismatch {
646        supplied: usize,
647        inferred: usize,
648    },
649    InvalidLogLambda {
650        value: f64,
651    },
652    InvalidLogLambdaInterval {
653        lo: f64,
654        hi: f64,
655    },
656    NonPositiveMode {
657        index: usize,
658        log_lambda: f64,
659        value: f64,
660    },
661    NonPositiveResidual {
662        output: usize,
663        log_lambda: f64,
664        value: f64,
665    },
666    NonPositiveResidualInterval {
667        output: usize,
668        lo: f64,
669        hi: f64,
670        lower_bound: f64,
671    },
672}
673
674impl fmt::Display for AffineRemlError {
675    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
676        match self {
677            Self::EmptyModes => write!(f, "affine REML profile has no modes"),
678            Self::EmptyResponses => write!(f, "affine REML profile has no responses"),
679            Self::ShapeMismatch {
680                gram_modes,
681                penalty_modes,
682                projected_rhs_squared,
683                responses,
684            } => write!(
685                f,
686                "affine REML profile shape mismatch: gram {gram_modes}, penalty {penalty_modes}, projected squares {projected_rhs_squared}, responses {responses}"
687            ),
688            Self::InvalidMode {
689                index,
690                gram,
691                penalty,
692            } => write!(
693                f,
694                "affine REML mode {index} must have finite nonnegative (g,s), not both zero; got ({gram}, {penalty})"
695            ),
696            Self::InvalidProjectedSquare { index, value } => write!(
697                f,
698                "affine REML projected square {index} must be finite and nonnegative, got {value}"
699            ),
700            Self::InvalidResponseEnergy { output, value } => write!(
701                f,
702                "affine REML response energy {output} must be finite and nonnegative, got {value}"
703            ),
704            Self::InvalidResidualDof { value } => {
705                write!(
706                    f,
707                    "affine REML residual dof must be finite and positive, got {value}"
708                )
709            }
710            Self::InvalidLogdetConstant { value } => write!(
711                f,
712                "affine REML log-determinant constant must be finite, got {value}"
713            ),
714            Self::RankMismatch { supplied, inferred } => write!(
715                f,
716                "affine REML determinant rank {supplied} disagrees with {inferred} positive penalty modes"
717            ),
718            Self::InvalidLogLambda { value } => {
719                write!(f, "affine REML invalid log lambda {value}")
720            }
721            Self::InvalidLogLambdaInterval { lo, hi } => {
722                write!(f, "affine REML invalid log-lambda interval [{lo}, {hi}]")
723            }
724            Self::NonPositiveMode {
725                index,
726                log_lambda,
727                value,
728            } => write!(
729                f,
730                "affine REML mode {index} is nonpositive at log lambda {log_lambda}: {value}"
731            ),
732            Self::NonPositiveResidual {
733                output,
734                log_lambda,
735                value,
736            } => write!(
737                f,
738                "affine REML residual {output} is nonpositive at log lambda {log_lambda}: {value}"
739            ),
740            Self::NonPositiveResidualInterval {
741                output,
742                lo,
743                hi,
744                lower_bound,
745            } => write!(
746                f,
747                "affine REML residual {output} is not certified positive on [{lo}, {hi}] (lower bound {lower_bound})"
748            ),
749        }
750    }
751}
752
753impl std::error::Error for AffineRemlError {}
754
755/// Spectral REML/profile score with affine diagonal modes
756/// `h_i(lambda) = g_i + lambda s_i`.
757///
758/// `projected_rhs_squared` is RESPONSE-MAJOR: entry `(d, i)` is stored at
759/// `d * n_modes + i`.  The score is
760///
761/// `-1/2 { D [logdet_constant + sum log h_i - rank log(lambda)]
762///          + residual_dof * sum_d log(R_d / residual_dof) }`,
763///
764/// where `R_d = response_energy[d] - sum_i q[d,i] / h_i`.
765#[derive(Clone, Copy, Debug)]
766pub struct AffineRemlProfile<'a> {
767    gram_modes: &'a [f64],
768    penalty_modes: &'a [f64],
769    projected_rhs_squared: &'a [f64],
770    response_energy: &'a [f64],
771    residual_dof: f64,
772    determinant_rank: usize,
773    logdet_constant: f64,
774}
775
776impl<'a> AffineRemlProfile<'a> {
777    pub fn new(
778        gram_modes: &'a [f64],
779        penalty_modes: &'a [f64],
780        projected_rhs_squared: &'a [f64],
781        response_energy: &'a [f64],
782        residual_dof: f64,
783        determinant_rank: usize,
784        logdet_constant: f64,
785    ) -> Result<Self, AffineRemlError> {
786        let modes = gram_modes.len();
787        let responses = response_energy.len();
788        if modes == 0 {
789            return Err(AffineRemlError::EmptyModes);
790        }
791        if responses == 0 {
792            return Err(AffineRemlError::EmptyResponses);
793        }
794        if penalty_modes.len() != modes
795            || projected_rhs_squared.len() != modes.saturating_mul(responses)
796        {
797            return Err(AffineRemlError::ShapeMismatch {
798                gram_modes: modes,
799                penalty_modes: penalty_modes.len(),
800                projected_rhs_squared: projected_rhs_squared.len(),
801                responses,
802            });
803        }
804        for (index, (&gram, &penalty)) in gram_modes.iter().zip(penalty_modes).enumerate() {
805            if !(gram.is_finite()
806                && penalty.is_finite()
807                && gram >= 0.0
808                && penalty >= 0.0
809                && (gram > 0.0 || penalty > 0.0))
810            {
811                return Err(AffineRemlError::InvalidMode {
812                    index,
813                    gram,
814                    penalty,
815                });
816            }
817        }
818        for (index, &value) in projected_rhs_squared.iter().enumerate() {
819            if !(value.is_finite() && value >= 0.0) {
820                return Err(AffineRemlError::InvalidProjectedSquare { index, value });
821            }
822        }
823        for (output, &value) in response_energy.iter().enumerate() {
824            if !(value.is_finite() && value >= 0.0) {
825                return Err(AffineRemlError::InvalidResponseEnergy { output, value });
826            }
827        }
828        if !(residual_dof.is_finite() && residual_dof > 0.0) {
829            return Err(AffineRemlError::InvalidResidualDof {
830                value: residual_dof,
831            });
832        }
833        if !logdet_constant.is_finite() {
834            return Err(AffineRemlError::InvalidLogdetConstant {
835                value: logdet_constant,
836            });
837        }
838        let inferred_rank = penalty_modes.iter().filter(|&&value| value > 0.0).count();
839        if determinant_rank != inferred_rank {
840            return Err(AffineRemlError::RankMismatch {
841                supplied: determinant_rank,
842                inferred: inferred_rank,
843            });
844        }
845        Ok(Self {
846            gram_modes,
847            penalty_modes,
848            projected_rhs_squared,
849            response_energy,
850            residual_dof,
851            determinant_rank,
852            logdet_constant,
853        })
854    }
855
856    #[inline]
857    pub fn num_modes(&self) -> usize {
858        self.gram_modes.len()
859    }
860
861    #[inline]
862    pub fn num_responses(&self) -> usize {
863        self.response_energy.len()
864    }
865
866    /// Exact score value, first derivative, and second derivative in
867    /// `log(lambda)`.
868    pub fn evaluate(&self, log_lambda: f64) -> Result<ScoreJet, AffineRemlError> {
869        if !log_lambda.is_finite() {
870            return Err(AffineRemlError::InvalidLogLambda { value: log_lambda });
871        }
872        let lambda = log_lambda.exp();
873        if !(lambda.is_finite() && lambda > 0.0) {
874            return Err(AffineRemlError::InvalidLogLambda { value: log_lambda });
875        }
876
877        let mut logdet = self.logdet_constant;
878        let mut determinant_derivative = -(self.determinant_rank as f64);
879        let mut determinant_curvature = 0.0;
880        for (index, (&gram, &penalty)) in self.gram_modes.iter().zip(self.penalty_modes).enumerate()
881        {
882            let h = lambda.mul_add(penalty, gram);
883            if !(h.is_finite() && h > 0.0) {
884                return Err(AffineRemlError::NonPositiveMode {
885                    index,
886                    log_lambda,
887                    value: h,
888                });
889            }
890            let u = lambda * penalty / h;
891            logdet += h.ln();
892            determinant_derivative += u;
893            determinant_curvature += u * (1.0 - u);
894        }
895        logdet -= (self.determinant_rank as f64) * log_lambda;
896
897        let modes = self.num_modes();
898        let mut residual_log_sum = 0.0;
899        let mut residual_derivative_sum = 0.0;
900        let mut residual_curvature_sum = 0.0;
901        for (output, &energy) in self.response_energy.iter().enumerate() {
902            let mut residual = energy;
903            let mut first = 0.0;
904            let mut second = 0.0;
905            for i in 0..modes {
906                let h = lambda.mul_add(self.penalty_modes[i], self.gram_modes[i]);
907                let u = lambda * self.penalty_modes[i] / h;
908                let projected_square = self.projected_rhs_squared[output * modes + i];
909                residual -= projected_square / h;
910                first += projected_square * u / h;
911                second += projected_square * u * (1.0 - 2.0 * u) / h;
912            }
913            if !(residual.is_finite() && residual > 0.0) {
914                return Err(AffineRemlError::NonPositiveResidual {
915                    output,
916                    log_lambda,
917                    value: residual,
918                });
919            }
920            let log_derivative = first / residual;
921            residual_log_sum += (residual / self.residual_dof).ln();
922            residual_derivative_sum += log_derivative;
923            residual_curvature_sum += second / residual - log_derivative * log_derivative;
924        }
925
926        let outputs = self.num_responses() as f64;
927        Ok(ScoreJet {
928            value: -0.5 * (outputs * logdet + self.residual_dof * residual_log_sum),
929            derivative: -0.5
930                * (outputs * determinant_derivative + self.residual_dof * residual_derivative_sum),
931            curvature: -0.5
932                * (outputs * determinant_curvature + self.residual_dof * residual_curvature_sum),
933        })
934    }
935
936    /// Outward enclosure of the first two score derivatives on a bounded
937    /// log-lambda interval.
938    pub fn enclose(&self, lo: f64, hi: f64) -> Result<DerivativeEnclosure, AffineRemlError> {
939        if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
940            return Err(AffineRemlError::InvalidLogLambdaInterval { lo, hi });
941        }
942        let lambda = ClosedInterval::new(next_down(lo.exp()), next_up(hi.exp()));
943        if !(lambda.lo.is_finite() && lambda.lo > 0.0 && lambda.hi.is_finite()) {
944            return Err(AffineRemlError::InvalidLogLambdaInterval { lo, hi });
945        }
946
947        let mut determinant_first = ClosedInterval::point(0.0);
948        let mut determinant_second = ClosedInterval::point(0.0);
949        for i in 0..self.num_modes() {
950            let ranges = mode_ranges(self.gram_modes[i], self.penalty_modes[i], 0.0, lambda);
951            determinant_first = determinant_first.add(ranges.u);
952            determinant_second = determinant_second.add(ranges.w);
953        }
954        determinant_first =
955            determinant_first.sub(ClosedInterval::point(self.determinant_rank as f64));
956
957        let mut residual_first_sum = ClosedInterval::point(0.0);
958        let mut residual_second_sum = ClosedInterval::point(0.0);
959        let modes = self.num_modes();
960        for (output, &energy) in self.response_energy.iter().enumerate() {
961            let mut fitted_quadratic = ClosedInterval::point(0.0);
962            let mut first = ClosedInterval::point(0.0);
963            let mut second = ClosedInterval::point(0.0);
964            for i in 0..modes {
965                let ranges = mode_ranges(
966                    self.gram_modes[i],
967                    self.penalty_modes[i],
968                    self.projected_rhs_squared[output * modes + i],
969                    lambda,
970                );
971                fitted_quadratic = fitted_quadratic.add(ranges.v);
972                first = first.add(ranges.p);
973                second = second.add(ranges.q);
974            }
975            let residual = ClosedInterval::point(energy).sub(fitted_quadratic);
976            if !(residual.lo > 0.0 && residual.is_valid()) {
977                return Err(AffineRemlError::NonPositiveResidualInterval {
978                    output,
979                    lo,
980                    hi,
981                    lower_bound: residual.lo,
982                });
983            }
984            let first_ratio = first.div_positive(residual).nonnegative();
985            let second_ratio = second.div_positive(residual);
986            residual_first_sum = residual_first_sum.add(first_ratio);
987            residual_second_sum = residual_second_sum.add(second_ratio.sub(first_ratio.square()));
988        }
989
990        let outputs = self.num_responses() as f64;
991        let first_bracket = determinant_first
992            .scale(outputs)
993            .add(residual_first_sum.scale(self.residual_dof));
994        let second_bracket = determinant_second
995            .scale(outputs)
996            .add(residual_second_sum.scale(self.residual_dof));
997        Ok(DerivativeEnclosure {
998            derivative: first_bracket.scale(-0.5),
999            curvature: second_bracket.scale(-0.5),
1000        })
1001    }
1002
1003    pub fn maximize(
1004        &self,
1005        lo: f64,
1006        hi: f64,
1007        resolution: f64,
1008    ) -> Result<ScoreSearchResult, ScoreSearchError<AffineRemlError>> {
1009        maximize_score_1d(
1010            lo,
1011            hi,
1012            resolution,
1013            |x| self.evaluate(x),
1014            |a, b| self.enclose(a, b),
1015        )
1016    }
1017}
1018
1019#[derive(Clone, Copy)]
1020struct ModeRanges {
1021    /// `u = lambda s / h`.
1022    u: ClosedInterval,
1023    /// `u(1-u)`.
1024    w: ClosedInterval,
1025    /// `projected_square / h`.
1026    v: ClosedInterval,
1027    /// First derivative of the residual contribution:
1028    /// `projected_square * lambda s / h^2`.
1029    p: ClosedInterval,
1030    /// Second derivative of the residual contribution:
1031    /// `projected_square * lambda s (g-lambda s) / h^3`.
1032    q: ClosedInterval,
1033}
1034
1035fn mode_ranges(
1036    gram: f64,
1037    penalty: f64,
1038    projected_square: f64,
1039    lambda: ClosedInterval,
1040) -> ModeRanges {
1041    if penalty == 0.0 {
1042        let v = ClosedInterval::point(projected_square)
1043            .div_positive(ClosedInterval::point(gram))
1044            .nonnegative();
1045        return ModeRanges {
1046            u: ClosedInterval::point(0.0),
1047            w: ClosedInterval::point(0.0),
1048            v,
1049            p: ClosedInterval::point(0.0),
1050            q: ClosedInterval::point(0.0),
1051        };
1052    }
1053    if gram == 0.0 {
1054        let h = lambda.mul(ClosedInterval::point(penalty)).nonnegative();
1055        let v = ClosedInterval::point(projected_square)
1056            .div_positive(h)
1057            .nonnegative();
1058        return ModeRanges {
1059            u: ClosedInterval::point(1.0),
1060            w: ClosedInterval::point(0.0),
1061            v,
1062            p: v,
1063            q: v.neg(),
1064        };
1065    }
1066
1067    // Normalize by g: h = g(1+t), t = lambda*s/g.  The four kernels below
1068    // have known global critical points, so endpoint evaluation plus any
1069    // critical point contained by the t-window gives an exact real range;
1070    // interval arithmetic rounds every primitive outward.
1071    let t = lambda
1072        .mul(ClosedInterval::point(penalty))
1073        .div_positive(ClosedInterval::point(gram))
1074        .nonnegative();
1075    let scale = ClosedInterval::point(projected_square)
1076        .div_positive(ClosedInterval::point(gram))
1077        .nonnegative();
1078    let kernels = kernel_ranges(t);
1079    ModeRanges {
1080        u: kernels.u,
1081        w: kernels.w,
1082        v: scale.mul(kernels.v).nonnegative(),
1083        p: scale.mul(kernels.w).nonnegative(),
1084        q: scale.mul(kernels.k),
1085    }
1086}
1087
1088#[derive(Clone, Copy)]
1089struct KernelRanges {
1090    /// `t/(1+t)`.
1091    u: ClosedInterval,
1092    /// `1/(1+t)`.
1093    v: ClosedInterval,
1094    /// `t/(1+t)^2`.
1095    w: ClosedInterval,
1096    /// `t(1-t)/(1+t)^3`.
1097    k: ClosedInterval,
1098}
1099
1100fn kernel_at(t: ClosedInterval) -> KernelRanges {
1101    let one = ClosedInterval::point(1.0);
1102    let denom = one.add(t);
1103    let v = one.div_positive(denom).nonnegative();
1104    let u = t.mul(v).nonnegative();
1105    let w = u.mul(v).nonnegative();
1106    let k = w.mul(one.sub(t)).div_positive(denom);
1107    KernelRanges { u, v, w, k }
1108}
1109
1110fn kernel_ranges(t: ClosedInterval) -> KernelRanges {
1111    let left = kernel_at(ClosedInterval::point(t.lo));
1112    let right = kernel_at(ClosedInterval::point(t.hi));
1113    let mut u = ClosedInterval::new(left.u.lo, right.u.hi).nonnegative();
1114    let mut v = ClosedInterval::new(right.v.lo, left.v.hi).nonnegative();
1115    let mut w = left.w.hull(right.w).nonnegative();
1116    let mut k = left.k.hull(right.k);
1117
1118    if t.contains(1.0) {
1119        let critical = kernel_at(ClosedInterval::point(1.0));
1120        w = w.hull(critical.w).nonnegative();
1121    }
1122
1123    // k'(t) has its only positive roots at 2 +/- sqrt(3).  Enclose sqrt(3)
1124    // itself before subtraction/addition so the exact irrational critical
1125    // points are not lost to nearest-rounded scalar arithmetic.
1126    let sqrt_three = ClosedInterval::new(next_down(3.0_f64.sqrt()), next_up(3.0_f64.sqrt()));
1127    let critical_points = [
1128        ClosedInterval::point(2.0).sub(sqrt_three),
1129        ClosedInterval::point(2.0).add(sqrt_three),
1130    ];
1131    for critical in critical_points {
1132        if critical.hi >= t.lo && critical.lo <= t.hi {
1133            k = k.hull(kernel_at(critical).k);
1134        }
1135    }
1136
1137    // Monotonicity gives tighter endpoint ranges than a dependency-heavy
1138    // interval evaluation, but retain outward endpoint arithmetic.
1139    u.lo = u.lo.max(0.0);
1140    u.hi = u.hi.min(next_up(1.0));
1141    v.lo = v.lo.max(0.0);
1142    v.hi = v.hi.min(next_up(1.0));
1143    KernelRanges { u, v, w, k }
1144}
1145
1146/// Next representable number below `value`, used for directed outward
1147/// rounding of interval lower bounds.
1148fn next_down(value: f64) -> f64 {
1149    if value.is_nan() || value == f64::NEG_INFINITY {
1150        return value;
1151    }
1152    if value == 0.0 {
1153        return -f64::from_bits(1);
1154    }
1155    let bits = value.to_bits();
1156    f64::from_bits(if value > 0.0 { bits - 1 } else { bits + 1 })
1157}
1158
1159/// Next representable number above `value`, used for directed outward
1160/// rounding of interval upper bounds.
1161fn next_up(value: f64) -> f64 {
1162    if value.is_nan() || value == f64::INFINITY {
1163        return value;
1164    }
1165    if value == 0.0 {
1166        return f64::from_bits(1);
1167    }
1168    let bits = value.to_bits();
1169    f64::from_bits(if value > 0.0 { bits + 1 } else { bits - 1 })
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    fn polynomial_hidden_bump_jet(x: f64) -> ScoreJet {
1177        let p = x * (x - 0.5) * (x - 1.0);
1178        let dp = 3.0 * x * x - 3.0 * x + 0.5;
1179        let ddp = 6.0 * x - 3.0;
1180        ScoreJet {
1181            value: x + 1000.0 * p * p,
1182            derivative: 1.0 + 2000.0 * p * dp,
1183            curvature: 2000.0 * (dp * dp + p * ddp),
1184        }
1185    }
1186
1187    fn polynomial_hidden_bump_enclosure(lo: f64, hi: f64) -> DerivativeEnclosure {
1188        let x = ClosedInterval::new(lo, hi);
1189        let p = x
1190            .mul(x.sub(ClosedInterval::point(0.5)))
1191            .mul(x.sub(ClosedInterval::point(1.0)));
1192        let dp = x
1193            .square()
1194            .scale(3.0)
1195            .sub(x.scale(3.0))
1196            .add(ClosedInterval::point(0.5));
1197        let ddp = x.scale(6.0).sub(ClosedInterval::point(3.0));
1198        DerivativeEnclosure {
1199            derivative: ClosedInterval::point(1.0).add(p.mul(dp).scale(2000.0)),
1200            curvature: dp.square().add(p.mul(ddp)).scale(2000.0),
1201        }
1202    }
1203
1204    #[test]
1205    fn hidden_between_endpoint_and_midpoint_samples_is_found() {
1206        let result = maximize_score_1d(
1207            0.0,
1208            1.0,
1209            1.0e-9,
1210            |x| -> Result<_, String> { Ok(polynomial_hidden_bump_jet(x)) },
1211            |lo, hi| -> Result<_, String> { Ok(polynomial_hidden_bump_enclosure(lo, hi)) },
1212        )
1213        .expect("certified search");
1214
1215        // At x=0, 1/2, 1 both value and derivative agree exactly with f=x;
1216        // the former midpoint/Hermite heuristic therefore returned x=1.
1217        assert_eq!(polynomial_hidden_bump_jet(0.0).derivative, 1.0);
1218        assert_eq!(polynomial_hidden_bump_jet(0.5).derivative, 1.0);
1219        assert_eq!(polynomial_hidden_bump_jet(1.0).derivative, 1.0);
1220        assert!(result.optimum.x > 0.5 && result.optimum.x < 1.0);
1221        assert!(result.optimum.value > 2.9);
1222        assert_eq!(result.stationary_points.len(), 4);
1223    }
1224
1225    fn quartic_jet(x: f64) -> ScoreJet {
1226        ScoreJet {
1227            value: -(x * x - 1.0).powi(2),
1228            derivative: 4.0 * x - 4.0 * x * x * x,
1229            curvature: 4.0 - 12.0 * x * x,
1230        }
1231    }
1232
1233    fn quartic_enclosure(lo: f64, hi: f64) -> DerivativeEnclosure {
1234        let x = ClosedInterval::new(lo, hi);
1235        DerivativeEnclosure {
1236            derivative: x.scale(4.0).sub(x.mul(x).mul(x).scale(4.0)),
1237            curvature: ClosedInterval::point(4.0).sub(x.square().scale(12.0)),
1238        }
1239    }
1240
1241    #[test]
1242    fn multiple_roots_in_initial_bracket_are_all_isolated() {
1243        let result = maximize_score_1d(
1244            -2.0,
1245            2.0,
1246            1.0e-10,
1247            |x| -> Result<_, String> { Ok(quartic_jet(x)) },
1248            |lo, hi| -> Result<_, String> { Ok(quartic_enclosure(lo, hi)) },
1249        )
1250        .expect("certified search");
1251        assert_eq!(result.stationary_points.len(), 3);
1252        for (point, expected) in result.stationary_points.iter().zip([-1.0_f64, 0.0, 1.0]) {
1253            assert!((point.sample.x - expected).abs() <= 1.0e-9);
1254            assert!(point.bracket.hi - point.bracket.lo <= 1.0e-10);
1255        }
1256        assert!((result.optimum.x.abs() - 1.0).abs() <= 1.0e-9);
1257    }
1258
1259    #[test]
1260    fn monotone_score_selects_exact_boundary() {
1261        let result = maximize_score_1d(
1262            -4.0,
1263            9.0,
1264            1.0e-9,
1265            |x| -> Result<_, String> {
1266                Ok(ScoreJet {
1267                    value: 0.3 * x,
1268                    derivative: 0.3,
1269                    curvature: 0.0,
1270                })
1271            },
1272            |_, _| -> Result<_, String> {
1273                Ok(DerivativeEnclosure {
1274                    derivative: ClosedInterval::point(0.3),
1275                    curvature: ClosedInterval::point(0.0),
1276                })
1277            },
1278        )
1279        .expect("certified search");
1280        assert_eq!(result.location, ScoreOptimumLocation::UpperBoundary);
1281        assert_eq!(result.optimum.x, 9.0);
1282        assert!(result.stationary_points.is_empty());
1283    }
1284
1285    #[test]
1286    fn unresolved_tangential_stationary_point_is_typed() {
1287        let error = maximize_score_1d(
1288            -1.0,
1289            1.0,
1290            1.0e-8,
1291            |x| -> Result<_, String> {
1292                Ok(ScoreJet {
1293                    value: x * x * x,
1294                    derivative: 3.0 * x * x,
1295                    curvature: 6.0 * x,
1296                })
1297            },
1298            |lo, hi| -> Result<_, String> {
1299                let x = ClosedInterval::new(lo, hi);
1300                Ok(DerivativeEnclosure {
1301                    derivative: x.square().scale(3.0),
1302                    curvature: x.scale(6.0),
1303                })
1304            },
1305        )
1306        .expect_err("a tangential root needs stronger structural bounds");
1307        assert!(matches!(error, ScoreSearchError::Unresolved { .. }));
1308    }
1309
1310    fn affine_fixture() -> AffineRemlProfile<'static> {
1311        const G: &[f64] = &[2.0, 0.5, 0.0, 3.0];
1312        const S: &[f64] = &[1.0, 0.0, 2.0, 0.25];
1313        const Q: &[f64] = &[
1314            0.6, 0.1, 0.02, 0.3, // response 0
1315            0.2, 0.4, 0.01, 0.5, // response 1
1316        ];
1317        const Y2: &[f64] = &[8.0, 10.0];
1318        AffineRemlProfile::new(G, S, Q, Y2, 12.0, 3, 0.7).expect("valid fixture")
1319    }
1320
1321    #[test]
1322    fn affine_reml_jet_matches_test_only_differences() {
1323        let profile = affine_fixture();
1324        for x in [-2.0_f64, -0.4, 0.7, 2.0] {
1325            let h = 1.0e-5;
1326            let center = profile.evaluate(x).unwrap();
1327            let left = profile.evaluate(x - h).unwrap();
1328            let right = profile.evaluate(x + h).unwrap();
1329            let derivative = (right.value - left.value) / (2.0 * h);
1330            let curvature = (right.derivative - left.derivative) / (2.0 * h);
1331            assert!(
1332                (center.derivative - derivative).abs() <= 2.0e-8 * (1.0 + derivative.abs()),
1333                "first derivative mismatch at {x}: analytic {}, difference {derivative}",
1334                center.derivative
1335            );
1336            assert!(
1337                (center.curvature - curvature).abs() <= 2.0e-8 * (1.0 + curvature.abs()),
1338                "curvature mismatch at {x}: analytic {}, difference {curvature}",
1339                center.curvature
1340            );
1341        }
1342    }
1343
1344    #[test]
1345    fn affine_reml_enclosure_contains_value_jets() {
1346        let profile = affine_fixture();
1347        let enclosure = profile.enclose(-2.5, 1.75).expect("enclosure");
1348        for x in [-2.5_f64, -1.7, -0.3, 0.0, 0.9, 1.75] {
1349            let jet = profile.evaluate(x).unwrap();
1350            assert!(
1351                enclosure.derivative.contains(jet.derivative),
1352                "gradient {} at {x} outside {:?}",
1353                jet.derivative,
1354                enclosure.derivative
1355            );
1356            assert!(
1357                enclosure.curvature.contains(jet.curvature),
1358                "curvature {} at {x} outside {:?}",
1359                jet.curvature,
1360                enclosure.curvature
1361            );
1362        }
1363    }
1364
1365    #[test]
1366    fn affine_reml_rejects_nonpositive_profile_residual() {
1367        let profile = AffineRemlProfile::new(&[1.0], &[1.0], &[2.0], &[1.0], 4.0, 1, 0.0)
1368            .expect("statically valid");
1369        assert!(matches!(
1370            profile.evaluate(-2.0),
1371            Err(AffineRemlError::NonPositiveResidual { .. })
1372        ));
1373    }
1374}