1use std::fmt;
28
29#[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 #[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 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#[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#[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#[derive(Clone, Copy, Debug, PartialEq)]
216pub struct DerivativeEnclosure {
217 pub derivative: ClosedInterval,
218 pub curvature: ClosedInterval,
219}
220
221#[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#[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#[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 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 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
405fn 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 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 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
499pub 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 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 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#[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#[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 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 third: 0.0,
963 })
964 }
965
966 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: ClosedInterval,
1053 w: ClosedInterval,
1055 v: ClosedInterval,
1057 p: ClosedInterval,
1060 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 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 u: ClosedInterval,
1122 v: ClosedInterval,
1124 w: ClosedInterval,
1126 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 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 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
1176fn 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
1189fn 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 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, 0.2, 0.4, 0.01, 0.5, ];
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}