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)]
188pub struct ScoreJet {
189 pub value: f64,
190 pub derivative: f64,
191 pub curvature: f64,
192}
193
194#[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#[derive(Clone, Copy, Debug, PartialEq)]
205pub struct DerivativeEnclosure {
206 pub derivative: ClosedInterval,
207 pub curvature: ClosedInterval,
208}
209
210#[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#[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#[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 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
387fn 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 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 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
481pub 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 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 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#[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#[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 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 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: ClosedInterval,
1023 w: ClosedInterval,
1025 v: ClosedInterval,
1027 p: ClosedInterval,
1030 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 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 u: ClosedInterval,
1092 v: ClosedInterval,
1094 w: ClosedInterval,
1096 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 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 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
1146fn 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
1159fn 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 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, 0.2, 0.4, 0.01, 0.5, ];
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}