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