1use gam_math::probability::{
135 normal_cdf, normal_logsf, signed_probit_logcdf_and_mills_ratio, standard_normal_quantile,
136 standard_normal_quantile_from_log_cdf,
137};
138use gam_problem::LinearInequalityConstraints;
139
140#[inline]
146fn log1mexp_of_log_removed_mass(d: f64) -> f64 {
147 if d >= 0.0 {
148 return f64::NEG_INFINITY;
149 }
150 gam_math::probability::log1mexp_positive(-d)
151}
152use ndarray::{Array1, Array2, ArrayView2};
153use rayon::prelude::*;
154use serde::{Deserialize, Serialize};
155
156const ORTHANT_MOMENT_RELATIVE_TOLERANCE: f64 = 1e-3;
179
180const ORTHANT_MOMENT_INITIAL_POINTS: usize = 1 << 11;
186
187const ORTHANT_MOMENT_REPLICATES: usize = 8;
194
195const ORTHANT_MOMENT_MAXIMUM_POINTS: usize = 1 << 25;
208
209#[derive(Clone, Debug, Serialize, Deserialize)]
217pub struct ConstrainedPosteriorCorrection {
218 pub lift: Array2<f64>,
220 pub removed_normal_variance: Array2<f64>,
223 pub normal_mean_shift: Array1<f64>,
229 pub rows: Vec<usize>,
231 #[serde(default, with = "gam_problem::serde_extended_real::vec_f64")]
254 pub normal_upper_limits: Vec<f64>,
255}
256
257impl ConstrainedPosteriorCorrection {
258 pub fn apply_to_covariance_in_place(&self, covariance: &mut Array2<f64>) {
261 let scaled = self.lift.dot(&self.removed_normal_variance);
262 let p = covariance.nrows();
263 for i in 0..p {
264 for j in 0..=i {
265 let removed = scaled.row(i).dot(&self.lift.row(j));
266 covariance[[i, j]] -= removed;
267 if i != j {
268 covariance[[j, i]] = covariance[[i, j]];
269 }
270 }
271 }
272 }
273
274 pub fn apply_to_covariance(&self, covariance: &Array2<f64>) -> Array2<f64> {
276 let mut corrected = covariance.clone();
277 self.apply_to_covariance_in_place(&mut corrected);
278 corrected
279 }
280
281 pub fn truncated_covariance_psd(
319 &self,
320 covariance: &Array2<f64>,
321 constraints: &LinearInequalityConstraints,
322 ) -> Result<Array2<f64>, String> {
323 use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh};
324
325 let p = covariance.nrows();
326 if covariance.ncols() != p {
327 return Err(format!(
328 "truncated covariance needs a square Σ, got {}x{}",
329 covariance.nrows(),
330 covariance.ncols()
331 ));
332 }
333 if self.lift.nrows() != p {
334 return Err(format!(
335 "truncated covariance: the lift has {} rows against a {p}x{p} Σ",
336 self.lift.nrows()
337 ));
338 }
339 if constraints.a.ncols() != p {
340 return Err(format!(
341 "truncated covariance: the constraint system has {} columns against a {p}x{p} Σ",
342 constraints.a.ncols()
343 ));
344 }
345 let q = self.rows.len();
346 if self.lift.ncols() != q || self.removed_normal_variance.dim() != (q, q) {
347 return Err(format!(
348 "truncated covariance: {q} retained row(s) against a lift of {} column(s) and a \
349 removed-variance block of {:?}",
350 self.lift.ncols(),
351 self.removed_normal_variance.dim()
352 ));
353 }
354 let mut retained = Array2::<f64>::zeros((q, p));
355 for (position, &row) in self.rows.iter().enumerate() {
356 if row >= constraints.a.nrows() {
357 return Err(format!(
358 "truncated covariance: retained row {row} is outside the {}-row constraint \
359 system it indexes",
360 constraints.a.nrows()
361 ));
362 }
363 retained.row_mut(position).assign(&constraints.a.row(row));
364 }
365
366 let sigma_at = covariance.dot(&retained.t());
369 let mut w = retained.dot(&sigma_at);
370 gam_linalg::matrix::symmetrize_in_place(&mut w);
371 let mut truncated_normal = &w - &self.removed_normal_variance;
372 gam_linalg::matrix::symmetrize_in_place(&mut truncated_normal);
373
374 let (eigenvalues, eigenvectors) = truncated_normal
375 .eigh(faer::Side::Lower)
376 .map_err(|error| format!("truncated constraint-normal covariance eigendecomposition: {error:?}"))?;
377 let pre_truncation_scale = (0..q).fold(0.0_f64, |worst, index| worst.max(w[[index, index]]));
383 let negative_floor = -ORTHANT_MOMENT_RELATIVE_TOLERANCE * (q as f64) * pre_truncation_scale;
384 let mut normal_factor = Array2::<f64>::zeros((q, q));
385 for index in 0..q {
386 let eigenvalue = eigenvalues[index];
387 if !eigenvalue.is_finite() {
388 return Err(format!(
389 "truncated constraint-normal covariance has a non-finite eigenvalue at {index}"
390 ));
391 }
392 if eigenvalue < negative_floor {
393 return Err(format!(
394 "the truncated constraint-normal covariance is materially indefinite: \
395 eigenvalue {eigenvalue:.6e} at {index} is below the cubature's own \
396 resolution {negative_floor:.6e} (pre-truncation scale \
397 {pre_truncation_scale:.6e} over {q} retained row(s))"
398 ));
399 }
400 let scale = eigenvalue.max(0.0).sqrt();
401 for row in 0..q {
402 normal_factor[[row, index]] = eigenvectors[[row, index]] * scale;
403 }
404 }
405
406 let sigma_factor = covariance
407 .cholesky(faer::Side::Lower)
408 .map_err(|error| {
409 format!("truncated covariance requires an SPD Σ to factor: {error:?}")
410 })?
411 .lower_triangular();
412 let projected_factor = &sigma_factor - &self.lift.dot(&retained.dot(&sigma_factor));
415 let normal_lift = self.lift.dot(&normal_factor);
416
417 let mut truncated = projected_factor.dot(&projected_factor.t());
418 truncated += &normal_lift.dot(&normal_lift.t());
419 gam_linalg::matrix::symmetrize_in_place(&mut truncated);
420 for index in 0..p {
424 let projected_row = projected_factor.row(index);
425 let normal_row = normal_lift.row(index);
426 truncated[[index, index]] =
427 projected_row.dot(&projected_row) + normal_row.dot(&normal_row);
428 }
429 Ok(truncated)
430 }
431
432 pub fn removed_variance_diagonal(&self) -> Array1<f64> {
435 let scaled = self.lift.dot(&self.removed_normal_variance);
436 let p = self.lift.nrows();
437 let mut diagonal = Array1::<f64>::zeros(p);
438 for i in 0..p {
439 diagonal[i] = scaled.row(i).dot(&self.lift.row(i));
440 }
441 diagonal
442 }
443
444 pub fn diagonal_uncertainty(&self) -> Array1<f64> {
462 self.removed_variance_diagonal() * ORTHANT_MOMENT_RELATIVE_TOLERANCE
463 }
464
465 pub fn posterior_mean(&self, unconstrained_center: &Array1<f64>) -> Array1<f64> {
467 unconstrained_center + &self.lift.dot(&self.normal_mean_shift)
468 }
469
470 pub fn upper_limits(&self) -> Vec<f64> {
473 if self.normal_upper_limits.is_empty() {
474 vec![f64::INFINITY; self.rows.len()]
475 } else {
476 self.normal_upper_limits.clone()
477 }
478 }
479}
480
481#[derive(Clone, Debug, Serialize, Deserialize)]
483pub enum ConePropernessEvidence {
484 Certificate(crate::cone_reduction::ConeProperness),
485 CertificationFailed { reason: String },
486}
487
488impl ConePropernessEvidence {
489 pub fn is_proper(&self) -> Option<bool> {
490 match self {
491 Self::Certificate(certificate) => certificate.is_proper(),
492 Self::CertificationFailed { .. } => None,
493 }
494 }
495
496 pub fn summary(&self) -> String {
497 match self {
498 Self::Certificate(certificate) => certificate.summary(),
499 Self::CertificationFailed { reason } => format!(
500 "cone-truncated posterior properness could not be certified: {reason}"
501 ),
502 }
503 }
504
505 fn validate(&self, ambient_dimension: usize, constraint_count: usize) -> Result<(), String> {
506 match self {
507 Self::CertificationFailed { reason } => {
508 if reason.trim().is_empty() {
509 return Err(
510 "cone properness certification failure has an empty reason".to_string(),
511 );
512 }
513 }
514 Self::Certificate(certificate) => {
515 if certificate.reduced.dim() != (constraint_count, constraint_count) {
516 return Err(format!(
517 "cone properness reduced precision has shape {:?}, expected ({constraint_count}, {constraint_count})",
518 certificate.reduced.dim(),
519 ));
520 }
521 if certificate.reduced.iter().any(|value| !value.is_finite())
522 || certificate
523 .copositive_minimum
524 .is_some_and(|value| !value.is_finite())
525 {
526 return Err(
527 "cone properness certificate contains a non-finite value".to_string(),
528 );
529 }
530 let total = |inertia: crate::cone_reduction::Inertia| {
531 inertia.positive + inertia.zero + inertia.negative
532 };
533 if total(certificate.ambient_inertia) != ambient_dimension
534 || total(certificate.reduced_inertia) != constraint_count
535 || total(certificate.lineality_inertia)
536 != ambient_dimension.saturating_sub(constraint_count)
537 {
538 return Err(format!(
539 "cone properness inertia dimensions disagree with ambient p={ambient_dimension} and face q={constraint_count}"
540 ));
541 }
542 if certificate.ambient_inertia.positive
543 != certificate.reduced_inertia.positive
544 + certificate.lineality_inertia.positive
545 || certificate.ambient_inertia.zero
546 != certificate.reduced_inertia.zero
547 + certificate.lineality_inertia.zero
548 || certificate.ambient_inertia.negative
549 != certificate.reduced_inertia.negative
550 + certificate.lineality_inertia.negative
551 {
552 return Err(
553 "cone properness certificate violates Haynsworth inertia additivity"
554 .to_string(),
555 );
556 }
557 if certificate.is_proper() == Some(false) {
558 return Err(
559 "a proved-improper cone posterior cannot be stored as a moment decline"
560 .to_string(),
561 );
562 }
563 }
564 }
565 Ok(())
566 }
567}
568
569#[derive(Clone, Debug, Serialize, Deserialize)]
571pub struct ConePosteriorMomentDecline {
572 pub ambient_precision_failure: String,
573 pub properness: ConePropernessEvidence,
574}
575
576impl ConePosteriorMomentDecline {
577 pub fn summary(&self) -> String {
578 format!(
579 "ambient covariance route declined ({}); {}",
580 self.ambient_precision_failure,
581 self.properness.summary(),
582 )
583 }
584}
585
586#[derive(Clone, Debug, Serialize, Deserialize)]
588pub enum ConstrainedPosteriorMomentStatus {
589 Available,
590 Declined(ConePosteriorMomentDecline),
591}
592
593#[derive(Clone, Debug, Serialize, Deserialize)]
600pub struct ConstrainedPosteriorGeometry {
601 pub constraints: LinearInequalityConstraints,
604 pub mode: Array1<f64>,
606 unconstrained_center: Option<Array1<f64>>,
607 correction: Option<ConstrainedPosteriorCorrection>,
610 pub moment_status: ConstrainedPosteriorMomentStatus,
612}
613
614impl ConstrainedPosteriorGeometry {
615 pub fn with_moments(
616 constraints: LinearInequalityConstraints,
617 mode: Array1<f64>,
618 unconstrained_center: Array1<f64>,
619 correction: Option<ConstrainedPosteriorCorrection>,
620 ) -> Self {
621 Self {
622 constraints,
623 mode,
624 unconstrained_center: Some(unconstrained_center),
625 correction,
626 moment_status: ConstrainedPosteriorMomentStatus::Available,
627 }
628 }
629
630 pub fn with_decline(
631 constraints: LinearInequalityConstraints,
632 mode: Array1<f64>,
633 decline: ConePosteriorMomentDecline,
634 ) -> Self {
635 Self {
636 constraints,
637 mode,
638 unconstrained_center: None,
639 correction: None,
640 moment_status: ConstrainedPosteriorMomentStatus::Declined(decline),
641 }
642 }
643
644 pub fn decline(&self) -> Option<&ConePosteriorMomentDecline> {
645 match &self.moment_status {
646 ConstrainedPosteriorMomentStatus::Available => None,
647 ConstrainedPosteriorMomentStatus::Declined(decline) => Some(decline),
648 }
649 }
650
651 pub fn unconstrained_center(&self) -> Result<&Array1<f64>, String> {
652 match &self.moment_status {
653 ConstrainedPosteriorMomentStatus::Available => self
654 .unconstrained_center
655 .as_ref()
656 .ok_or_else(|| {
657 "available constrained posterior is missing its ambient centre".to_string()
658 }),
659 ConstrainedPosteriorMomentStatus::Declined(decline) => Err(format!(
660 "constrained posterior has no ambient centre because its moments were declined: {}",
661 decline.summary(),
662 )),
663 }
664 }
665
666 pub fn correction(&self) -> Result<Option<&ConstrainedPosteriorCorrection>, String> {
667 match &self.moment_status {
668 ConstrainedPosteriorMomentStatus::Available => Ok(self.correction.as_ref()),
669 ConstrainedPosteriorMomentStatus::Declined(decline) => Err(format!(
670 "constrained posterior has no moment correction because its moments were declined: {}",
671 decline.summary(),
672 )),
673 }
674 }
675
676 pub fn available_parts_mut(
677 &mut self,
678 ) -> Option<(&mut Array1<f64>, Option<&mut ConstrainedPosteriorCorrection>)> {
679 match &self.moment_status {
680 ConstrainedPosteriorMomentStatus::Available => Some((
681 self.unconstrained_center.as_mut()?,
682 self.correction.as_mut(),
683 )),
684 ConstrainedPosteriorMomentStatus::Declined(_) => None,
685 }
686 }
687
688 pub fn posterior_mean(&self) -> Result<Array1<f64>, String> {
689 let center = self.unconstrained_center()?;
690 Ok(self
691 .correction()?
692 .map(|correction| correction.posterior_mean(center))
693 .unwrap_or_else(|| center.clone()))
694 }
695
696 pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), String> {
697 if self.constraints.a.ncols() != dimension
698 || self.constraints.a.nrows() != self.constraints.b.len()
699 {
700 return Err(format!(
701 "constrained posterior inequalities have shape {}x{} with {} bounds, expected {dimension} columns",
702 self.constraints.a.nrows(),
703 self.constraints.a.ncols(),
704 self.constraints.b.len()
705 ));
706 }
707 if self.mode.len() != dimension {
708 return Err(format!(
709 "constrained posterior mode has length {}, expected {dimension}",
710 self.mode.len(),
711 ));
712 }
713 if self
714 .mode
715 .iter()
716 .chain(self.unconstrained_center.iter().flat_map(|center| center.iter()))
717 .chain(self.constraints.a.iter())
718 .chain(self.constraints.b.iter())
719 .any(|value| !value.is_finite())
720 {
721 return Err("constrained posterior geometry contains a non-finite value".to_string());
722 }
723 match &self.moment_status {
724 ConstrainedPosteriorMomentStatus::Available => {
725 if self
726 .unconstrained_center
727 .as_ref()
728 .is_none_or(|center| center.len() != dimension)
729 {
730 return Err(format!(
731 "available constrained posterior centre has length {:?}, expected {dimension}",
732 self.unconstrained_center.as_ref().map(Array1::len),
733 ));
734 }
735 }
736 ConstrainedPosteriorMomentStatus::Declined(decline) => {
737 if self.unconstrained_center.is_some() || self.correction.is_some() {
738 return Err(
739 "declined constrained posterior must not carry fabricated ambient moments"
740 .to_string(),
741 );
742 }
743 if decline.ambient_precision_failure.trim().is_empty() {
744 return Err(
745 "constrained posterior moment decline has an empty ambient-precision reason"
746 .to_string(),
747 );
748 }
749 decline.properness.validate(dimension, self.constraints.a.nrows())?;
750 }
751 }
752 if let Some(correction) = self.correction.as_ref() {
753 let q = correction.lift.ncols();
754 if correction.lift.nrows() != dimension {
755 return Err(format!(
756 "constrained posterior lift has {} rows, expected {dimension}",
757 correction.lift.nrows()
758 ));
759 }
760 if correction.removed_normal_variance.dim() != (q, q)
761 || correction.normal_mean_shift.len() != q
762 || correction.rows.len() != q
763 {
764 return Err(format!(
765 "constrained posterior normal geometry is inconsistent: lift={}x{q}, removed={:?}, mean={}, rows={}",
766 correction.lift.nrows(),
767 correction.removed_normal_variance.dim(),
768 correction.normal_mean_shift.len(),
769 correction.rows.len()
770 ));
771 }
772 let mut unique_rows = correction.rows.clone();
773 unique_rows.sort_unstable();
774 unique_rows.dedup();
775 if unique_rows.len() != q
776 || unique_rows
777 .iter()
778 .any(|&row| row >= self.constraints.a.nrows())
779 {
780 return Err(format!(
781 "constrained posterior retained rows {:?} are not unique valid indices for {} inequalities",
782 correction.rows,
783 self.constraints.a.nrows()
784 ));
785 }
786 if correction
787 .lift
788 .iter()
789 .chain(correction.removed_normal_variance.iter())
790 .chain(correction.normal_mean_shift.iter())
791 .any(|value| !value.is_finite())
792 {
793 return Err(
794 "constrained posterior correction contains a non-finite value".to_string()
795 );
796 }
797 if !correction.normal_upper_limits.is_empty()
798 && correction.normal_upper_limits.len() != q
799 {
800 return Err(format!(
801 "constrained posterior carries {} upper limits for {q} retained rows",
802 correction.normal_upper_limits.len()
803 ));
804 }
805 if correction
808 .normal_upper_limits
809 .iter()
810 .any(|limit| !(*limit > 0.0))
811 {
812 return Err(format!(
813 "constrained posterior upper limits must be positive, got {:?}",
814 correction.normal_upper_limits
815 ));
816 }
817 }
818 Ok(())
819 }
820}
821
822struct TruncatedProjection {
847 posterior_mean: f64,
849 normal_center: Array1<f64>,
851 normal_covariance: Array2<f64>,
852 upper_limits: Vec<f64>,
853 projection_lift: Array1<f64>,
855 residual_variance: f64,
859}
860
861struct ProjectionDecomposition {
862 ambient_mean: f64,
863 ambient_variance: f64,
864 truncated: Option<TruncatedProjection>,
867}
868
869fn decompose_projection(
870 ambient_covariance: &Array2<f64>,
871 geometry: &ConstrainedPosteriorGeometry,
872 contrast: &Array1<f64>,
873) -> Result<ProjectionDecomposition, String> {
874 let p = contrast.len();
875 geometry.validate_for_dimension(p)?;
876 if ambient_covariance.dim() != (p, p) {
877 return Err(format!(
878 "constrained projection needs a {p}x{p} ambient covariance, got {:?}",
879 ambient_covariance.dim()
880 ));
881 }
882 if ambient_covariance.iter().any(|value| !value.is_finite())
883 || contrast.iter().any(|value| !value.is_finite())
884 {
885 return Err(
886 "constrained projection received a non-finite covariance or contrast".to_string(),
887 );
888 }
889
890 let ambient_mean = contrast.dot(geometry.unconstrained_center()?);
891 let sigma_c = ambient_covariance.dot(contrast);
892 let ambient_variance = contrast.dot(&sigma_c);
893 let covariance_scale = ambient_covariance
894 .diag()
895 .iter()
896 .map(|value| value.abs())
897 .fold(f64::MIN_POSITIVE, f64::max);
898 let contrast_scale = contrast.dot(contrast).max(f64::MIN_POSITIVE);
899 let variance_floor = (p.max(1) as f64) * f64::EPSILON * covariance_scale * contrast_scale;
900 if ambient_variance < -variance_floor || !ambient_variance.is_finite() {
901 return Err(format!(
902 "constrained projection has invalid ambient variance {ambient_variance:.6e}"
903 ));
904 }
905 let ambient_variance = ambient_variance.max(0.0);
906
907 let Some(correction) = geometry.correction()? else {
908 return Ok(ProjectionDecomposition {
909 ambient_mean,
910 ambient_variance,
911 truncated: None,
912 });
913 };
914
915 let q = correction.rows.len();
916 let mut normal_center = Array1::<f64>::zeros(q);
917 let mut normal_covariance = Array2::<f64>::zeros((q, q));
918 let mut sigma_a = Array2::<f64>::zeros((p, q));
919 for (position, &row) in correction.rows.iter().enumerate() {
920 let a = geometry.constraints.a.row(row);
921 normal_center[position] =
922 a.dot(geometry.unconstrained_center()?) - geometry.constraints.b[row];
923 sigma_a
924 .column_mut(position)
925 .assign(&ambient_covariance.dot(&a));
926 }
927 for i in 0..q {
928 let ai = geometry.constraints.a.row(correction.rows[i]);
929 for j in 0..=i {
930 let value = ai.dot(&sigma_a.column(j));
931 normal_covariance[[i, j]] = value;
932 normal_covariance[[j, i]] = value;
933 }
934 }
935
936 let projection_lift = correction.lift.t().dot(contrast);
937 let normal_component_variance = projection_lift.dot(&normal_covariance.dot(&projection_lift));
938 let residual_variance = ambient_variance - normal_component_variance;
939 let residual_floor = (p.max(q).max(1) as f64)
940 * f64::EPSILON
941 * ambient_variance
942 .max(normal_component_variance)
943 .max(f64::MIN_POSITIVE);
944 if residual_variance < -residual_floor || !residual_variance.is_finite() {
945 return Err(format!(
946 "constrained projection decomposition produced residual variance \
947 {residual_variance:.6e} from ambient {ambient_variance:.6e}"
948 ));
949 }
950 let residual_variance = residual_variance.max(0.0);
951 let posterior_mean = ambient_mean + projection_lift.dot(&correction.normal_mean_shift);
952 let upper_limits = correction.upper_limits();
953 if upper_limits.len() != q {
954 return Err(format!(
955 "constrained projection: {q} retained rows carry {} upper limits",
956 upper_limits.len()
957 ));
958 }
959 Ok(ProjectionDecomposition {
960 ambient_mean,
961 ambient_variance,
962 truncated: Some(TruncatedProjection {
963 posterior_mean,
964 normal_center,
965 normal_covariance,
966 upper_limits,
967 projection_lift,
968 residual_variance,
969 }),
970 })
971}
972
973pub struct ConstrainedProjectionLaw {
1001 pub nodes: Vec<(f64, f64)>,
1005 pub residual_variance: f64,
1009}
1010
1011impl ConstrainedProjectionLaw {
1012 pub fn mean(&self) -> f64 {
1014 self.nodes
1015 .iter()
1016 .map(|(location, weight)| location * weight)
1017 .sum()
1018 }
1019
1020 pub fn variance(&self) -> f64 {
1022 let mean = self.mean();
1023 let spread = self
1024 .nodes
1025 .iter()
1026 .map(|(location, weight)| weight * (location - mean) * (location - mean))
1027 .sum::<f64>();
1028 spread + self.residual_variance
1029 }
1030}
1031
1032#[derive(Clone, Debug)]
1036pub struct ConstrainedPosteriorJointPoint {
1037 pub normal_coordinates: Array1<f64>,
1042 pub tangent: Array1<f64>,
1048 pub weight: f64,
1050}
1051
1052pub fn constrained_posterior_joint_cubature(
1080 normal_center: &Array1<f64>,
1081 normal_covariance: &Array2<f64>,
1082 upper_limits: &[f64],
1083 tangent_dimension: usize,
1084 points: usize,
1085) -> Result<Vec<ConstrainedPosteriorJointPoint>, String> {
1086 let q = normal_center.len();
1087 if q == 0 {
1088 return Err("joint constrained cubature needs at least one constraint normal".to_string());
1089 }
1090 if normal_covariance.dim() != (q, q) || upper_limits.len() != q {
1091 return Err(format!(
1092 "joint constrained cubature geometry mismatch: centre={q}, covariance={:?}, \
1093 upper limits={}",
1094 normal_covariance.dim(),
1095 upper_limits.len()
1096 ));
1097 }
1098 if points == 0 {
1099 return Err("joint constrained cubature needs a positive point count".to_string());
1100 }
1101 if upper_limits.iter().any(|limit| !(*limit > 0.0)) {
1102 return Err(format!(
1103 "joint constrained cubature: every upper limit must sit strictly above its wall, \
1104 got {upper_limits:?}"
1105 ));
1106 }
1107 let rule = OrthantRule::new(normal_center, upper_limits, normal_covariance, tangent_dimension)?;
1111 let mut accumulator = JointCubatureAccumulator {
1112 points: Vec::with_capacity(points),
1113 };
1114 rule.accumulate(&mut accumulator, 0, 0, points)?;
1115 accumulator.normalized()
1116}
1117
1118struct JointCubatureAccumulator {
1120 points: Vec<ConstrainedPosteriorJointPoint>,
1124}
1125
1126impl JointCubatureAccumulator {
1127 fn normalized(self) -> Result<Vec<ConstrainedPosteriorJointPoint>, String> {
1128 let max_log_weight = self
1129 .points
1130 .iter()
1131 .map(|point| point.weight)
1132 .fold(f64::NEG_INFINITY, f64::max);
1133 if !max_log_weight.is_finite() {
1134 return Err("joint constrained cubature accumulated no finite node weight".to_string());
1135 }
1136 let weight_sum = self
1137 .points
1138 .iter()
1139 .map(|point| (point.weight - max_log_weight).exp())
1140 .sum::<f64>();
1141 if !(weight_sum.is_finite() && weight_sum > 0.0) {
1142 return Err(format!(
1143 "joint constrained cubature has invalid normalized weight sum {weight_sum:?}"
1144 ));
1145 }
1146 let mut points = self.points;
1147 for point in points.iter_mut() {
1148 point.weight = (point.weight - max_log_weight).exp() / weight_sum;
1149 }
1150 Ok(points)
1151 }
1152}
1153
1154impl OrthantNodeSink for JointCubatureAccumulator {
1155 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
1156 self.push_joint(log_weight, point, &[]);
1157 }
1158
1159 fn push_joint(&mut self, log_weight: f64, point: &Array1<f64>, tangent: &[f64]) {
1160 self.points.push(ConstrainedPosteriorJointPoint {
1161 normal_coordinates: point.clone(),
1162 tangent: Array1::from_vec(tangent.to_vec()),
1163 weight: log_weight,
1164 });
1165 }
1166}
1167
1168pub fn constrained_projection_equal_tailed_interval(
1179 ambient_covariance: &Array2<f64>,
1180 geometry: &ConstrainedPosteriorGeometry,
1181 contrast: &Array1<f64>,
1182 level: f64,
1183) -> Result<(f64, f64), String> {
1184 if !(level.is_finite() && level > 0.0 && level < 1.0) {
1185 return Err(format!(
1186 "constrained projection interval level must lie in (0, 1), got {level}"
1187 ));
1188 }
1189 let decomposition = decompose_projection(ambient_covariance, geometry, contrast)?;
1190 let ambient_mean = decomposition.ambient_mean;
1191 let ambient_variance = decomposition.ambient_variance;
1192 let alpha = 0.5 * (1.0 - level);
1193
1194 let Some(truncated) = decomposition.truncated else {
1195 let sd = ambient_variance.sqrt();
1196 if sd == 0.0 {
1197 return Ok((ambient_mean, ambient_mean));
1198 }
1199 let z = standard_normal_quantile(1.0 - alpha)
1200 .map_err(|error| format!("constrained projection normal quantile: {error}"))?;
1201 return Ok((ambient_mean - z * sd, ambient_mean + z * sd));
1202 };
1203
1204 let TruncatedProjection {
1205 posterior_mean,
1206 normal_center,
1207 normal_covariance,
1208 upper_limits,
1209 projection_lift,
1210 residual_variance,
1211 } = truncated;
1212 let q = normal_center.len();
1213 if q == 1 && residual_variance == 0.0 && projection_lift[0] != 0.0 {
1214 let scalar_quantile = |probability: f64| -> Result<f64, String> {
1215 let normal_probability = if projection_lift[0] > 0.0 {
1216 probability
1217 } else {
1218 1.0 - probability
1219 };
1220 let value = scalar_truncated_quantile(
1221 normal_center[0],
1222 normal_covariance[[0, 0]],
1223 upper_limits[0],
1224 normal_probability,
1225 )?;
1226 Ok(ambient_mean + projection_lift[0] * (value - normal_center[0]))
1227 };
1228 return Ok((scalar_quantile(alpha)?, scalar_quantile(1.0 - alpha)?));
1229 }
1230 let nodes = converged_projection_nodes(
1231 &normal_center,
1232 &normal_covariance,
1233 &upper_limits,
1234 &projection_lift,
1235 ambient_mean,
1236 )?;
1237 let lower = projection_quantile(
1238 &nodes,
1239 residual_variance,
1240 alpha,
1241 posterior_mean,
1242 ambient_variance.sqrt(),
1243 )?;
1244 let upper = projection_quantile(
1245 &nodes,
1246 residual_variance,
1247 1.0 - alpha,
1248 posterior_mean,
1249 ambient_variance.sqrt(),
1250 )?;
1251 Ok((lower, upper))
1252}
1253
1254fn scalar_truncated_quantile(
1256 mean: f64,
1257 variance: f64,
1258 upper: f64,
1259 probability: f64,
1260) -> Result<f64, String> {
1261 if !(variance.is_finite() && variance > 0.0) {
1262 return Err(format!(
1263 "scalar truncated quantile needs positive finite variance, got {variance:?}"
1264 ));
1265 }
1266 if !(probability.is_finite() && probability > 0.0 && probability < 1.0) {
1267 return Err(format!(
1268 "scalar truncated quantile probability must lie in (0, 1), got {probability}"
1269 ));
1270 }
1271 if !(upper > 0.0) {
1272 return Err(format!(
1273 "scalar truncated quantile needs the upper limit above the wall, got {upper:?}"
1274 ));
1275 }
1276 let sd = variance.sqrt();
1277 let alpha = -mean / sd;
1278 if !upper.is_finite() {
1279 let log_tail = (1.0 - probability).ln() + normal_logsf(alpha);
1282 let z = -standard_normal_quantile_from_log_cdf(log_tail)
1283 .map_err(|error| format!("scalar truncated quantile: {error}"))?;
1284 return Ok(mean + sd * z);
1285 }
1286 let beta = (upper - mean) / sd;
1287 let reflect = alpha + beta < 0.0;
1291 let (low, high, probability) = if reflect {
1292 (-beta, -alpha, 1.0 - probability)
1293 } else {
1294 (alpha, beta, probability)
1295 };
1296 let log_tail_low = normal_logsf(low);
1297 let removed = normal_logsf(high) - log_tail_low;
1298 let log_tail = log_tail_low + (-probability * -removed.exp_m1()).ln_1p();
1300 let z = -standard_normal_quantile_from_log_cdf(log_tail)
1301 .map_err(|error| format!("scalar truncated quantile: {error}"))?;
1302 let z = z.clamp(low, high);
1303 Ok(if reflect {
1305 mean - sd * z
1306 } else {
1307 mean + sd * z
1308 })
1309}
1310
1311pub fn constrained_posterior_correction_from_covariance(
1326 covariance: &Array2<f64>,
1327 unconstrained_center: &Array1<f64>,
1328 constraints: &LinearInequalityConstraints,
1329) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
1330 let p = covariance.nrows();
1331 if covariance.ncols() != p {
1332 return Err(format!(
1333 "constrained posterior correction needs a square covariance, got {}x{}",
1334 covariance.nrows(),
1335 covariance.ncols()
1336 ));
1337 }
1338 if constraints.a.ncols() != p {
1339 return Err(format!(
1340 "constrained posterior correction: covariance is {p}x{p} but the constraint \
1341 system has {} columns",
1342 constraints.a.ncols()
1343 ));
1344 }
1345 let sigma_times_at = covariance.dot(&constraints.a.t());
1346 constrained_posterior_correction(sigma_times_at.view(), unconstrained_center, constraints)
1347}
1348
1349pub fn constrained_posterior_correction(
1356 sigma_times_constraint_transpose: ArrayView2<'_, f64>,
1357 unconstrained_center: &Array1<f64>,
1358 constraints: &LinearInequalityConstraints,
1359) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
1360 let p = sigma_times_constraint_transpose.nrows();
1361 if sigma_times_constraint_transpose.ncols() != constraints.a.nrows() {
1362 return Err(format!(
1363 "constrained posterior correction: the constraint system has {} rows but \
1364 Sigma·Aᵀ has {} columns",
1365 constraints.a.nrows(),
1366 sigma_times_constraint_transpose.ncols()
1367 ));
1368 }
1369 if unconstrained_center.len() != p {
1370 return Err(format!(
1371 "constrained posterior correction: Sigma·Aᵀ has {p} rows but the centre has \
1372 length {}",
1373 unconstrained_center.len()
1374 ));
1375 }
1376 if constraints.a.ncols() != p {
1377 return Err(format!(
1378 "constrained posterior correction: Sigma·Aᵀ has {p} rows but the constraint \
1379 system has {} columns",
1380 constraints.a.ncols()
1381 ));
1382 }
1383
1384 let candidates = constraint_face_candidates(
1385 sigma_times_constraint_transpose,
1386 unconstrained_center,
1387 constraints,
1388 )?;
1389 if candidates.is_empty() {
1390 return Ok(None);
1391 }
1392
1393 let demanded_accuracy = ORTHANT_MOMENT_RELATIVE_TOLERANCE;
1449 let mut first_pass = true;
1450 let mut faces_tried = 0usize;
1451 let mut ladder: Vec<LadderRung> = Vec::new();
1452 let mut excluded: Vec<usize> = Vec::new();
1453 let mut last_refused: Option<RefusedFace> = None;
1454 while excluded.len() <= candidates.len() {
1455 let Some(face) = assemble_retained_face(
1456 &candidates,
1457 demanded_accuracy,
1458 constraints,
1459 unconstrained_center,
1460 &excluded,
1461 )?
1462 else {
1463 if first_pass {
1464 return Ok(None);
1465 }
1466 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1467 return Err(format!(
1468 "no constraint face survives the accuracy its own lift must deliver: excluding \
1469 {} of {} candidate row(s) at the retention floor {demanded_accuracy:.3e} left \
1470 no retained row after {faces_tried} face(s){}",
1471 excluded.len(),
1472 candidates.len(),
1473 render_ladder(&ladder, candidates.len())
1474 ));
1475 };
1476 first_pass = false;
1477 faces_tried += 1;
1478 let lift = cholesky_solve_right(&face.factor, &face.sigma_at)?;
1481 let departure = lift_identity_departure(&lift, constraints, &face.rows)?;
1482 ladder.push(LadderRung {
1483 excluded: excluded.len(),
1484 retained: face.rows.len(),
1485 departure,
1486 });
1487 if departure > ORTHANT_MOMENT_RELATIVE_TOLERANCE {
1488 last_refused = Some(RefusedFace {
1489 rows: face.rows.clone(),
1490 w: face.w.clone(),
1491 });
1492 if face.rows.len() == 1 {
1493 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1494 return Err(format!(
1495 "a single retained constraint row still misses the identity that defines \
1496 its lift: max|A G - I| = {departure:.6e} exceeds \
1497 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e}, which one row cannot be \
1498 ill-conditioned enough to cause{}",
1499 render_ladder(&ladder, candidates.len())
1500 ));
1501 }
1502 if face.least_independent_direction.is_empty() {
1507 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1508 return Err(format!(
1509 "the constraint face misses the identity that defines its lift \
1510 (max|A G - I| = {departure:.6e} against \
1511 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e}) over {} retained row(s), and \
1512 names no least-independent direction to drop, after {faces_tried} \
1513 face(s){}",
1514 face.rows.len(),
1515 render_ladder(&ladder, candidates.len())
1516 ));
1517 }
1518 excluded.extend_from_slice(&face.least_independent_direction);
1519 continue;
1520 }
1521
1522 let q = face.rows.len();
1523 let mut normal_center = Array1::<f64>::zeros(q);
1524 for (position, &row_index) in face.rows.iter().enumerate() {
1525 normal_center[position] =
1526 constraints.a.row(row_index).dot(unconstrained_center) - constraints.b[row_index];
1527 }
1528
1529 let (normal_mean, normal_covariance) =
1530 box_truncated_moments(&normal_center, &face.upper, &face.w)?;
1531
1532 let mut removed = &face.w - &normal_covariance;
1533 gam_linalg::matrix::symmetrize_in_place(&mut removed);
1534 certify_removed_variance(&removed, &face.w)?;
1535
1536 if !excluded.is_empty() {
1537 log::info!(
1543 "[CONSTRAINED-FACE] {} of {} candidate constraint row(s) retained after \
1544 dropping {} nearly dependent direction(s) over {faces_tried} face(s); the \
1545 retained lift satisfies its identity to {departure:.3e}",
1546 face.rows.len(),
1547 candidates.len(),
1548 excluded.len()
1549 );
1550 }
1551
1552 return Ok(Some(ConstrainedPosteriorCorrection {
1553 lift,
1554 removed_normal_variance: removed,
1555 normal_mean_shift: normal_mean - normal_center,
1556 rows: face.rows,
1557 normal_upper_limits: face.upper,
1558 }));
1559 }
1560 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1561 Err(format!(
1562 "the constraint-normal lift never reached the accuracy it is certified to: \
1563 {faces_tried} constraint face(s) were tried and every candidate row was excluded. \
1564 The walk drops one accepted direction per pass and a single-row face's lift is \
1565 exact, so this says the single-row face was never reached — which it must be.{}",
1566 render_ladder(&ladder, candidates.len())
1567 ))
1568}
1569
1570fn report_terminal_refusal(
1573 last_refused: Option<&RefusedFace>,
1574 ladder: &[LadderRung],
1575 excluded: usize,
1576) {
1577 let Some(face) = last_refused else {
1578 return;
1579 };
1580 let departure = ladder.last().map_or(f64::NAN, |rung| rung.departure);
1581 log_refused_face(face, departure, excluded);
1582}
1583
1584struct LadderRung {
1595 excluded: usize,
1596 retained: usize,
1597 departure: f64,
1598}
1599
1600fn render_ladder(ladder: &[LadderRung], candidates: usize) -> String {
1611 const SHOWN_AT_EACH_END: usize = 4;
1612 let mut rendered = format!(" [walk over {candidates} candidate row(s):");
1613 let render_rung = |rung: &LadderRung, into: &mut String| {
1614 into.push_str(&format!(
1615 " (excluded={} retained={} departure={:.3e})",
1616 rung.excluded, rung.retained, rung.departure
1617 ));
1618 };
1619 if ladder.len() <= 2 * SHOWN_AT_EACH_END + 1 {
1620 for rung in ladder {
1621 render_rung(rung, &mut rendered);
1622 }
1623 } else {
1624 for rung in &ladder[..SHOWN_AT_EACH_END] {
1625 render_rung(rung, &mut rendered);
1626 }
1627 rendered.push_str(&format!(
1628 " ... {} further rung(s) ...",
1629 ladder.len() - 2 * SHOWN_AT_EACH_END
1630 ));
1631 for rung in &ladder[ladder.len() - SHOWN_AT_EACH_END..] {
1632 render_rung(rung, &mut rendered);
1633 }
1634 }
1635 rendered.push(']');
1636 rendered
1637}
1638
1639fn log_refused_face(face: &RefusedFace, departure: f64, excluded: usize) {
1649 if !log::log_enabled!(log::Level::Warn) {
1650 return;
1651 }
1652 let q = face.rows.len();
1653 let mut rendered = String::new();
1654 for i in 0..q {
1655 for j in 0..q {
1656 rendered.push_str(&format!("{:.17e},", face.w[[i, j]]));
1657 }
1658 }
1659 log::warn!(
1660 "[CONSTRAINED-FACE] refused excluded={excluded} departure={departure:.6e} \
1661 q={q} rows={:?} w=[{rendered}]",
1662 face.rows
1663 );
1664}
1665
1666struct RefusedFace {
1673 rows: Vec<usize>,
1674 w: Array2<f64>,
1675}
1676
1677fn constraint_face_candidates(
1696 sigma_times_constraint_transpose: ArrayView2<'_, f64>,
1697 unconstrained_center: &Array1<f64>,
1698 constraints: &LinearInequalityConstraints,
1699) -> Result<Vec<(usize, f64, Array1<f64>)>, String> {
1700 let slack_horizon = -standard_normal_quantile(f64::EPSILON)
1701 .map_err(|error| format!("resolution horizon for the constraint slack: {error}"))?;
1702 let mut candidates: Vec<(usize, f64, Array1<f64>)> = Vec::new();
1703 for row_index in 0..constraints.a.nrows() {
1704 let row = constraints.a.row(row_index).to_owned();
1705 let sigma_row = sigma_times_constraint_transpose
1706 .column(row_index)
1707 .to_owned();
1708 let variance = row.dot(&sigma_row);
1709 if !(variance.is_finite() && variance > 0.0) {
1710 continue;
1713 }
1714 let slack = (row.dot(unconstrained_center) - constraints.b[row_index]) / variance.sqrt();
1715 if !slack.is_finite() {
1716 return Err(format!(
1717 "constraint row {row_index} produced a non-finite standardized slack"
1718 ));
1719 }
1720 if slack < slack_horizon {
1721 candidates.push((row_index, slack, sigma_row));
1722 }
1723 }
1724 candidates.sort_by(|left, right| {
1725 left.1
1726 .partial_cmp(&right.1)
1727 .unwrap_or(std::cmp::Ordering::Equal)
1728 .then_with(|| left.0.cmp(&right.0))
1729 });
1730 Ok(candidates)
1731}
1732
1733struct RetainedFace {
1737 rows: Vec<usize>,
1738 factor: Array2<f64>,
1739 w: Array2<f64>,
1740 sigma_at: Array2<f64>,
1741 upper: Vec<f64>,
1743 least_independent_direction: Vec<usize>,
1773}
1774
1775fn assemble_retained_face(
1789 candidates: &[(usize, f64, Array1<f64>)],
1790 demanded_accuracy: f64,
1791 constraints: &LinearInequalityConstraints,
1792 unconstrained_center: &Array1<f64>,
1793 excluded: &[usize],
1794) -> Result<Option<RetainedFace>, String> {
1795 let columns = constraints.a.ncols();
1796 let antiparallel_tolerance = 4.0 * (columns as f64 + 1.0) * f64::EPSILON;
1803 let mut rows: Vec<usize> = Vec::new();
1804 let mut least_independent: Option<(usize, f64)> = None;
1805 let mut sigma_a_columns: Vec<Array1<f64>> = Vec::new();
1806 let mut upper: Vec<f64> = Vec::new();
1807 let mut folded: Vec<Vec<usize>> = Vec::new();
1810 let mut w_accepted = Array2::<f64>::zeros((0, 0));
1811 let mut factor = Array2::<f64>::zeros((0, 0));
1812 for (row_index, _, sigma_row) in candidates {
1813 if excluded.contains(row_index) {
1814 continue;
1815 }
1816 let row = constraints.a.row(*row_index);
1817 let accepted = rows.len();
1818 let diagonal = row.dot(sigma_row);
1819 let mut cross = Array1::<f64>::zeros(accepted);
1820 for (position, column) in sigma_a_columns.iter().enumerate() {
1821 cross[position] = row.dot(column);
1822 }
1823 let mut new_column = Array1::<f64>::zeros(accepted);
1825 for i in 0..accepted {
1826 let mut sum = cross[i];
1827 for k in 0..i {
1828 sum -= factor[[i, k]] * new_column[k];
1829 }
1830 new_column[i] = sum / factor[[i, i]];
1831 }
1832 let pivot = diagonal - new_column.dot(&new_column);
1833 let rank_floor = (accepted + 1) as f64 * f64::EPSILON * diagonal / demanded_accuracy;
1856 if !(pivot.is_finite() && pivot > rank_floor) {
1857 if let Some(position) = record_opposed_face_limit(
1871 *row_index,
1872 &cross,
1873 diagonal,
1874 &AcceptedFace {
1875 w_accepted: &w_accepted,
1876 rows: &rows,
1877 constraints,
1878 unconstrained_center,
1879 antiparallel_tolerance,
1880 },
1881 &mut upper,
1882 )? {
1883 folded[position].push(*row_index);
1884 }
1885 continue;
1886 }
1887 let mut grown = Array2::<f64>::zeros((accepted + 1, accepted + 1));
1888 grown
1889 .slice_mut(ndarray::s![..accepted, ..accepted])
1890 .assign(&factor);
1891 for i in 0..accepted {
1892 grown[[accepted, i]] = new_column[i];
1893 }
1894 grown[[accepted, accepted]] = pivot.sqrt();
1895 factor = grown;
1896
1897 let mut grown_w = Array2::<f64>::zeros((accepted + 1, accepted + 1));
1898 grown_w
1899 .slice_mut(ndarray::s![..accepted, ..accepted])
1900 .assign(&w_accepted);
1901 for i in 0..accepted {
1902 grown_w[[accepted, i]] = cross[i];
1903 grown_w[[i, accepted]] = cross[i];
1904 }
1905 grown_w[[accepted, accepted]] = diagonal;
1906 w_accepted = grown_w;
1907
1908 rows.push(*row_index);
1909 sigma_a_columns.push(sigma_row.clone());
1910 upper.push(f64::INFINITY);
1911 folded.push(Vec::new());
1912 let independence = pivot / diagonal;
1925 if least_independent.is_none_or(|(_, best)| independence <= best) {
1926 least_independent = Some((rows.len() - 1, independence));
1927 }
1928 }
1929 if rows.is_empty() {
1930 return Ok(None);
1931 }
1932
1933 let q = rows.len();
1934 let p = sigma_a_columns[0].len();
1935 let mut sigma_at = Array2::<f64>::zeros((p, q));
1936 for (position, column) in sigma_a_columns.iter().enumerate() {
1937 sigma_at.column_mut(position).assign(column);
1938 }
1939 let least_independent_direction = match least_independent {
1940 Some((position, _)) => {
1941 let mut direction = vec![rows[position]];
1942 direction.extend_from_slice(&folded[position]);
1943 direction
1944 }
1945 None => Vec::new(),
1946 };
1947 Ok(Some(RetainedFace {
1948 rows,
1949 factor,
1950 w: w_accepted,
1951 sigma_at,
1952 upper,
1953 least_independent_direction,
1954 }))
1955}
1956
1957struct AcceptedFace<'a> {
1965 w_accepted: &'a Array2<f64>,
1966 rows: &'a [usize],
1967 constraints: &'a LinearInequalityConstraints,
1968 unconstrained_center: &'a Array1<f64>,
1969 antiparallel_tolerance: f64,
1970}
1971
1972fn record_opposed_face_limit(
2005 row_index: usize,
2006 cross: &Array1<f64>,
2007 diagonal: f64,
2008 face: &AcceptedFace<'_>,
2009 upper: &mut [f64],
2010) -> Result<Option<usize>, String> {
2011 let mut opposed: Option<(usize, f64, f64)> = None;
2012 for position in 0..face.rows.len() {
2013 let w_kk = face.w_accepted[[position, position]];
2014 let scale = (w_kk * diagonal).sqrt();
2015 if !(scale.is_finite() && scale > 0.0) {
2016 continue;
2017 }
2018 let correlation = cross[position] / scale;
2019 if correlation + 1.0 > face.antiparallel_tolerance {
2020 continue;
2021 }
2022 let gamma = -cross[position] / w_kk;
2023 if !(gamma.is_finite() && gamma > 0.0) {
2024 continue;
2025 }
2026 if opposed.is_none_or(|(_, best, _)| correlation < best) {
2030 opposed = Some((position, correlation, gamma));
2031 }
2032 }
2033 let Some((position, _, gamma)) = opposed else {
2034 return Ok(None);
2035 };
2036 let accepted_row = face.rows[position];
2037 let delta = (face.constraints.a.row(row_index).dot(face.unconstrained_center)
2038 - face.constraints.b[row_index])
2039 + gamma
2040 * (face.constraints.a.row(accepted_row).dot(face.unconstrained_center)
2041 - face.constraints.b[accepted_row]);
2042 let limit = delta / gamma;
2043 if !(limit.is_finite() && limit > 0.0) {
2044 return Err(format!(
2045 "constraint rows {accepted_row} and {row_index} bound the same coefficient \
2046 direction from opposite sides with no width between them (upper limit \
2047 {limit:.6e} above the lower wall): the retained region is empty or a single \
2048 point, which is an equality constraint and not a posterior this module can \
2049 report moments for"
2050 ));
2051 }
2052 if limit < upper[position] {
2053 upper[position] = limit;
2054 }
2055 Ok(Some(position))
2056}
2057
2058fn lift_identity_departure(
2068 lift: &Array2<f64>,
2069 constraints: &LinearInequalityConstraints,
2070 rows: &[usize],
2071) -> Result<f64, String> {
2072 let q = rows.len();
2073 let mut departure = 0.0_f64;
2074 for (i, &row_index) in rows.iter().enumerate() {
2075 let row = constraints.a.row(row_index);
2076 for j in 0..q {
2077 let entry = row.dot(&lift.column(j));
2078 let target = if i == j { 1.0 } else { 0.0 };
2079 let deviation = (entry - target).abs();
2080 if !deviation.is_finite() {
2081 return Err(format!(
2082 "the constraint-normal lift is not finite at retained row {row_index}, \
2083 constraint-normal coordinate {j}"
2084 ));
2085 }
2086 departure = departure.max(deviation);
2087 }
2088 }
2089 Ok(departure)
2090}
2091
2092fn cholesky_solve_right(factor: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>, String> {
2095 let q = factor.nrows();
2096 if b.ncols() != q {
2097 return Err(format!(
2098 "constraint-normal solve: factor is {q}x{q} but the right-hand side has {} columns",
2099 b.ncols()
2100 ));
2101 }
2102 let rows = b.nrows();
2103 let mut out = Array2::<f64>::zeros((rows, q));
2104 let mut work = Array1::<f64>::zeros(q);
2105 for r in 0..rows {
2106 for i in 0..q {
2107 let mut sum = b[[r, i]];
2108 for k in 0..i {
2109 sum -= factor[[i, k]] * work[k];
2110 }
2111 work[i] = sum / factor[[i, i]];
2112 }
2113 for i in (0..q).rev() {
2114 let mut sum = work[i];
2115 for k in (i + 1)..q {
2116 sum -= factor[[k, i]] * out[[r, k]];
2117 }
2118 out[[r, i]] = sum / factor[[i, i]];
2119 }
2120 }
2121 Ok(out)
2122}
2123
2124fn certify_removed_variance(removed: &Array2<f64>, w: &Array2<f64>) -> Result<(), String> {
2131 let q = removed.nrows();
2132 let slack = ORTHANT_MOMENT_RELATIVE_TOLERANCE * (q as f64);
2135 for i in 0..q {
2136 let scale = w[[i, i]];
2137 if removed[[i, i]] < -slack * scale {
2138 return Err(format!(
2139 "truncated orthant moments inflated the constraint-normal variance at index {i} \
2140 (removed {:.6e} against scale {scale:.6e}); truncation cannot increase a \
2141 Gaussian covariance",
2142 removed[[i, i]]
2143 ));
2144 }
2145 if removed[[i, i]] > (1.0 + slack) * scale {
2146 return Err(format!(
2147 "truncated orthant moments removed more variance than exists at index {i} \
2148 (removed {:.6e} against scale {scale:.6e})",
2149 removed[[i, i]]
2150 ));
2151 }
2152 for j in 0..q {
2153 if !removed[[i, j]].is_finite() {
2154 return Err(format!(
2155 "truncated orthant moments produced a non-finite entry at ({i},{j})"
2156 ));
2157 }
2158 }
2159 }
2160 Ok(())
2161}
2162
2163fn box_truncated_moments(
2180 mean: &Array1<f64>,
2181 upper: &[f64],
2182 covariance: &Array2<f64>,
2183) -> Result<(Array1<f64>, Array2<f64>), String> {
2184 let q = mean.len();
2185 if covariance.nrows() != q || covariance.ncols() != q {
2186 return Err(format!(
2187 "truncated moments: mean has length {q} but the covariance is {}x{}",
2188 covariance.nrows(),
2189 covariance.ncols()
2190 ));
2191 }
2192 if upper.len() != q {
2193 return Err(format!(
2194 "truncated moments: mean has length {q} but {} upper limits were supplied",
2195 upper.len()
2196 ));
2197 }
2198 if upper.iter().any(|limit| !(*limit > 0.0)) {
2199 return Err(format!(
2200 "truncated moments: every upper limit must sit strictly above its wall, got {upper:?}"
2201 ));
2202 }
2203 if q == 1 {
2204 return scalar_truncated_moments(mean[0], covariance[[0, 0]], upper[0]);
2205 }
2206 let rule = OrthantRule::new(mean, upper, covariance, 0)?;
2207 let mut sinks: Vec<OrthantAccumulator> = (0..ORTHANT_MOMENT_REPLICATES)
2208 .map(|_| OrthantAccumulator::new(q))
2209 .collect();
2210 let certified = certified_orthant_moments(&rule, covariance, &mut sinks)?;
2211 log::debug!(
2212 "[orthant-cubature] q={q} certified at {} nodes over {ORTHANT_MOMENT_REPLICATES} \
2213 replicate lattices: replicate standard error {:.3e} (target \
2214 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e}), proposal efficiency {:.3}%, tilt {}",
2215 certified.nodes,
2216 certified.error,
2217 100.0 * certified.efficiency,
2218 rule.tilt_status
2219 );
2220 Ok((certified.mean, certified.covariance))
2221}
2222
2223struct CertifiedOrthantMoments {
2226 mean: Array1<f64>,
2227 covariance: Array2<f64>,
2228 error: f64,
2231 nodes: usize,
2233 efficiency: f64,
2235}
2236
2237trait ReplicateSink: OrthantNodeSink + Send {
2241 fn accumulator(&self) -> &OrthantAccumulator;
2242}
2243
2244impl ReplicateSink for OrthantAccumulator {
2245 fn accumulator(&self) -> &OrthantAccumulator {
2246 self
2247 }
2248}
2249
2250fn certified_orthant_moments<S: ReplicateSink>(
2269 rule: &OrthantRule,
2270 covariance: &Array2<f64>,
2271 sinks: &mut [S],
2272) -> Result<CertifiedOrthantMoments, String> {
2273 let q = rule.dimension();
2274 let replicates = sinks.len();
2275 if replicates < 2 {
2276 return Err(format!(
2277 "the orthant certificate needs at least two replicate lattices, got {replicates}"
2278 ));
2279 }
2280 if covariance.dim() != (q, q) {
2281 return Err(format!(
2282 "orthant certificate: the rule has {q} coordinates but the covariance is {:?}",
2283 covariance.dim()
2284 ));
2285 }
2286 let scale: Vec<f64> = (0..q).map(|i| covariance[[i, i]].sqrt()).collect();
2287 let mut evaluated = 0usize;
2288 loop {
2289 let target = if evaluated == 0 {
2290 ORTHANT_MOMENT_INITIAL_POINTS
2291 } else {
2292 evaluated * 2
2293 };
2294 sinks
2295 .par_iter_mut()
2296 .enumerate()
2297 .try_for_each(|(replicate, sink)| rule.accumulate(sink, replicate, evaluated, target))?;
2298 evaluated = target;
2299 let nodes = evaluated * replicates;
2300 let mut per_replicate = Vec::with_capacity(replicates);
2301 for sink in sinks.iter() {
2302 per_replicate.push(sink.accumulator().moments()?);
2303 }
2304 let parts: Vec<&OrthantAccumulator> = sinks.iter().map(ReplicateSink::accumulator).collect();
2305 let pooled = OrthantAccumulator::pooled(&parts)?;
2306 let (mean, pooled_covariance) = pooled.moments()?;
2307 let error = replicate_error(&per_replicate, &scale);
2308 let efficiency = pooled.effective_sample_size() / nodes as f64;
2309 if error <= ORTHANT_MOMENT_RELATIVE_TOLERANCE {
2310 return Ok(CertifiedOrthantMoments {
2311 mean,
2312 covariance: pooled_covariance,
2313 error,
2314 nodes,
2315 efficiency,
2316 });
2317 }
2318 if nodes >= ORTHANT_MOMENT_MAXIMUM_POINTS {
2319 return Err(rule.refusal(covariance, error, nodes, efficiency));
2320 }
2321 }
2322}
2323
2324fn replicate_error(per_replicate: &[(Array1<f64>, Array2<f64>)], scale: &[f64]) -> f64 {
2333 let replicates = per_replicate.len() as f64;
2334 let q = scale.len();
2335 let mut worst = 0.0f64;
2336 for i in 0..q {
2337 let mean_i = per_replicate.iter().map(|(m, _)| m[i]).sum::<f64>() / replicates;
2338 let spread_i = per_replicate
2339 .iter()
2340 .map(|(m, _)| (m[i] - mean_i) * (m[i] - mean_i))
2341 .sum::<f64>()
2342 / (replicates - 1.0);
2343 worst = worst.max((spread_i / replicates).sqrt() / scale[i]);
2344 for j in 0..=i {
2345 let mean_ij = per_replicate.iter().map(|(_, c)| c[[i, j]]).sum::<f64>() / replicates;
2346 let spread_ij = per_replicate
2347 .iter()
2348 .map(|(_, c)| (c[[i, j]] - mean_ij) * (c[[i, j]] - mean_ij))
2349 .sum::<f64>()
2350 / (replicates - 1.0);
2351 worst = worst.max((spread_ij / replicates).sqrt() / (scale[i] * scale[j]));
2352 }
2353 }
2354 worst
2355}
2356
2357struct OrthantAccumulator {
2365 log_scale: f64,
2366 weight_sum: f64,
2367 weight_square_sum: f64,
2369 weighted_mean: Array1<f64>,
2370 weighted_second: Array2<f64>,
2371}
2372
2373trait OrthantNodeSink {
2374 fn push(&mut self, log_weight: f64, point: &Array1<f64>);
2375
2376 fn push_joint(&mut self, log_weight: f64, point: &Array1<f64>, tangent: &[f64]) {
2387 assert!(
2392 tangent.is_empty(),
2393 "a sink with no joint tangent block was handed {} tangent coordinates",
2394 tangent.len()
2395 );
2396 self.push(log_weight, point);
2397 }
2398}
2399
2400impl OrthantAccumulator {
2401 fn new(q: usize) -> Self {
2402 Self {
2403 log_scale: f64::NEG_INFINITY,
2404 weight_sum: 0.0,
2405 weight_square_sum: 0.0,
2406 weighted_mean: Array1::zeros(q),
2407 weighted_second: Array2::zeros((q, q)),
2408 }
2409 }
2410
2411 fn rescale_to(&mut self, log_scale: f64) {
2412 if log_scale > self.log_scale {
2413 let rescale = (self.log_scale - log_scale).exp();
2414 self.weight_sum *= rescale;
2415 self.weight_square_sum *= rescale * rescale;
2416 self.weighted_mean *= rescale;
2417 self.weighted_second *= rescale;
2418 self.log_scale = log_scale;
2419 }
2420 }
2421
2422 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
2423 let q = point.len();
2424 self.rescale_to(log_weight);
2425 let weight = (log_weight - self.log_scale).exp();
2426 self.weight_sum += weight;
2427 self.weight_square_sum += weight * weight;
2428 for i in 0..q {
2429 self.weighted_mean[i] += weight * point[i];
2430 for j in 0..=i {
2431 self.weighted_second[[i, j]] += weight * point[i] * point[j];
2432 }
2433 }
2434 }
2435
2436 fn pooled(parts: &[&OrthantAccumulator]) -> Result<Self, String> {
2439 let Some(first) = parts.first() else {
2440 return Err("pooling orthant accumulators needs at least one part".to_string());
2441 };
2442 let q = first.weighted_mean.len();
2443 let mut pooled = Self::new(q);
2444 for part in parts {
2445 if part.weighted_mean.len() != q {
2446 return Err(format!(
2447 "pooling orthant accumulators of different widths ({q} and {})",
2448 part.weighted_mean.len()
2449 ));
2450 }
2451 if !part.log_scale.is_finite() {
2452 continue;
2453 }
2454 pooled.rescale_to(part.log_scale);
2455 let factor = (part.log_scale - pooled.log_scale).exp();
2456 pooled.weight_sum += factor * part.weight_sum;
2457 pooled.weight_square_sum += factor * factor * part.weight_square_sum;
2458 pooled.weighted_mean.scaled_add(factor, &part.weighted_mean);
2459 pooled.weighted_second.scaled_add(factor, &part.weighted_second);
2460 }
2461 Ok(pooled)
2462 }
2463
2464 fn effective_sample_size(&self) -> f64 {
2467 if !(self.weight_square_sum > 0.0) {
2468 return 0.0;
2469 }
2470 self.weight_sum * self.weight_sum / self.weight_square_sum
2471 }
2472
2473 fn moments(&self) -> Result<(Array1<f64>, Array2<f64>), String> {
2474 if !(self.weight_sum.is_finite() && self.weight_sum > 0.0) {
2475 return Err(format!(
2476 "orthant cubature accumulated no feasible mass (weight sum {:?}); the \
2477 constraint face has no representable interior",
2478 self.weight_sum
2479 ));
2480 }
2481 let q = self.weighted_mean.len();
2482 let mean = &self.weighted_mean / self.weight_sum;
2483 let mut covariance = Array2::<f64>::zeros((q, q));
2484 for i in 0..q {
2485 for j in 0..=i {
2486 let centered = self.weighted_second[[i, j]] / self.weight_sum - mean[i] * mean[j];
2487 covariance[[i, j]] = centered;
2488 covariance[[j, i]] = centered;
2489 }
2490 }
2491 Ok((mean, covariance))
2492 }
2493}
2494
2495impl OrthantNodeSink for OrthantAccumulator {
2496 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
2497 OrthantAccumulator::push(self, log_weight, point);
2498 }
2499}
2500
2501#[derive(Clone, Debug)]
2515pub struct StandardizedCeiling {
2516 coefficients: Array1<f64>,
2518 bound: f64,
2520 pivot: usize,
2521}
2522
2523impl StandardizedCeiling {
2524 pub fn new(
2531 normal: &Array1<f64>,
2532 bound: f64,
2533 mean: &Array1<f64>,
2534 factor: ArrayView2<'_, f64>,
2535 ) -> Result<Self, String> {
2536 let q = mean.len();
2537 if normal.len() != q {
2538 return Err(format!(
2539 "affine ceiling: the normal has length {} but the law has {q} coordinates",
2540 normal.len()
2541 ));
2542 }
2543 let mut coefficients = Array1::<f64>::zeros(q);
2544 for j in 0..q {
2545 let mut total = 0.0;
2546 for k in j..q {
2547 total += factor[[k, j]] * normal[k];
2548 }
2549 coefficients[j] = total;
2550 }
2551 let scale = coefficients
2552 .iter()
2553 .fold(0.0f64, |worst, value| worst.max(value.abs()));
2554 if !(scale.is_finite() && scale > 0.0) {
2555 return Err(format!(
2556 "affine ceiling: the standardized normal vanished (scale {scale:?}); the wall \
2557 constrains no cubature coordinate"
2558 ));
2559 }
2560 let floor = 8.0 * f64::EPSILON * scale;
2561 let pivot = (0..q)
2562 .rev()
2563 .find(|j| coefficients[*j].abs() > floor)
2564 .ok_or_else(|| "affine ceiling: no coordinate clears the pivot floor".to_string())?;
2565 let offset = normal.dot(mean);
2566 if !(bound - offset).is_finite() {
2567 return Err(format!(
2568 "affine ceiling: the standardized bound is not finite (bound {bound:?}, \
2569 offset {offset:?})"
2570 ));
2571 }
2572 Ok(Self {
2573 coefficients,
2574 bound: bound - offset,
2575 pivot,
2576 })
2577 }
2578
2579 fn limit(&self, z: &Array1<f64>) -> (f64, f64) {
2583 let mut remaining = self.bound;
2584 for j in 0..self.pivot {
2585 remaining -= self.coefficients[j] * z[j];
2586 }
2587 let coefficient = self.coefficients[self.pivot];
2588 let limit = remaining / coefficient;
2589 if coefficient > 0.0 {
2590 (f64::NEG_INFINITY, limit)
2591 } else {
2592 (limit, f64::INFINITY)
2593 }
2594 }
2595}
2596
2597struct TruncatedStandardNormal {
2600 log_mass: f64,
2602 mean: f64,
2604 mean_wall_derivative: f64,
2608}
2609
2610fn standard_normal_log_density(t: f64) -> f64 {
2612 const LOG_SQRT_2PI: f64 = 0.918_938_533_204_672_7;
2613 -0.5 * t * t - LOG_SQRT_2PI
2614}
2615
2616fn truncated_standard_normal(low: f64, high: f64) -> Option<TruncatedStandardNormal> {
2628 if !high.is_finite() {
2629 let log_mass = normal_logsf(low);
2630 if !log_mass.is_finite() {
2631 return None;
2632 }
2633 let mean = (standard_normal_log_density(low) - log_mass).exp();
2634 let mean_wall_derivative = if low.is_finite() {
2637 mean * (mean - low)
2638 } else {
2639 0.0
2640 };
2641 return Some(TruncatedStandardNormal {
2642 log_mass,
2643 mean,
2644 mean_wall_derivative,
2645 });
2646 }
2647 if !(high > low) {
2648 return None;
2649 }
2650 let reflect = low + high < 0.0;
2651 let (a, b) = if reflect { (-high, -low) } else { (low, high) };
2652 let log_tail_a = normal_logsf(a);
2653 let log_tail_b = normal_logsf(b);
2654 if !log_tail_a.is_finite() {
2655 return None;
2656 }
2657 let log_mass = log_tail_a + log1mexp_of_log_removed_mass(log_tail_b - log_tail_a);
2658 if !log_mass.is_finite() {
2659 return None;
2660 }
2661 let density_a = (standard_normal_log_density(a) - log_mass).exp();
2662 let density_b = (standard_normal_log_density(b) - log_mass).exp();
2663 let reflected_mean = density_a - density_b;
2664 let mean_wall_derivative =
2665 reflected_mean * reflected_mean - (a * density_a - b * density_b);
2666 Some(TruncatedStandardNormal {
2667 log_mass,
2668 mean: if reflect { -reflected_mean } else { reflected_mean },
2669 mean_wall_derivative,
2670 })
2671}
2672
2673struct OrderedFace {
2701 order: Vec<usize>,
2704 mean: Array1<f64>,
2706 upper: Vec<f64>,
2707 factor: Array2<f64>,
2708}
2709
2710fn ordered_face(
2711 mean: &Array1<f64>,
2712 upper: &[f64],
2713 covariance: &Array2<f64>,
2714) -> Result<OrderedFace, String> {
2715 let q = mean.len();
2716 let mut order: Vec<usize> = (0..q).collect();
2717 let mut permuted_covariance = covariance.clone();
2718 let mut permuted_mean = mean.clone();
2719 let mut permuted_upper = upper.to_vec();
2720 let mut factor = Array2::<f64>::zeros((q, q));
2721 let mut placed = Array1::<f64>::zeros(q);
2723 for position in 0..q {
2724 let mut best: Option<(usize, f64)> = None;
2725 for candidate in position..q {
2726 let mut conditional_variance = permuted_covariance[[candidate, candidate]];
2727 for k in 0..position {
2728 conditional_variance -= factor[[candidate, k]] * factor[[candidate, k]];
2729 }
2730 if !(conditional_variance.is_finite() && conditional_variance > 0.0) {
2731 return Err(format!(
2732 "the constraint-normal covariance is not positive definite: coordinate {} \
2733 has conditional variance {conditional_variance:.3e} given {position} \
2734 retained coordinate(s)",
2735 order[candidate]
2736 ));
2737 }
2738 let conditional_sd = conditional_variance.sqrt();
2739 let mut conditional_mean = permuted_mean[candidate];
2740 for k in 0..position {
2741 conditional_mean += factor[[candidate, k]] * placed[k];
2742 }
2743 let low = -conditional_mean / conditional_sd;
2744 let high = if permuted_upper[candidate].is_finite() {
2745 (permuted_upper[candidate] - conditional_mean) / conditional_sd
2746 } else {
2747 f64::INFINITY
2748 };
2749 let log_mass = truncated_standard_normal(low, high)
2750 .map_or(f64::NEG_INFINITY, |law| law.log_mass);
2751 if best.is_none_or(|(_, current)| log_mass < current) {
2752 best = Some((candidate, log_mass));
2753 }
2754 }
2755 let (pick, _) = best.expect("a non-empty candidate range always yields a pick");
2756 if pick != position {
2757 order.swap(position, pick);
2758 permuted_mean.swap(position, pick);
2759 permuted_upper.swap(position, pick);
2760 for k in 0..q {
2761 let swapped = permuted_covariance[[position, k]];
2762 permuted_covariance[[position, k]] = permuted_covariance[[pick, k]];
2763 permuted_covariance[[pick, k]] = swapped;
2764 }
2765 for k in 0..q {
2766 let swapped = permuted_covariance[[k, position]];
2767 permuted_covariance[[k, position]] = permuted_covariance[[k, pick]];
2768 permuted_covariance[[k, pick]] = swapped;
2769 }
2770 for k in 0..position {
2771 let swapped = factor[[position, k]];
2772 factor[[position, k]] = factor[[pick, k]];
2773 factor[[pick, k]] = swapped;
2774 }
2775 }
2776 let mut pivot = permuted_covariance[[position, position]];
2777 for k in 0..position {
2778 pivot -= factor[[position, k]] * factor[[position, k]];
2779 }
2780 if !(pivot.is_finite() && pivot > 0.0) {
2781 return Err(format!(
2782 "the constraint-normal covariance is not positive definite at coordinate {} \
2783 (pivot {pivot:.3e})",
2784 order[position]
2785 ));
2786 }
2787 let diagonal = pivot.sqrt();
2788 factor[[position, position]] = diagonal;
2789 for i in (position + 1)..q {
2790 let mut value = permuted_covariance[[i, position]];
2791 for k in 0..position {
2792 value -= factor[[i, k]] * factor[[position, k]];
2793 }
2794 factor[[i, position]] = value / diagonal;
2795 }
2796 let mut conditional_mean = permuted_mean[position];
2797 for k in 0..position {
2798 conditional_mean += factor[[position, k]] * placed[k];
2799 }
2800 let low = -conditional_mean / diagonal;
2801 let high = if permuted_upper[position].is_finite() {
2802 (permuted_upper[position] - conditional_mean) / diagonal
2803 } else {
2804 f64::INFINITY
2805 };
2806 placed[position] = truncated_standard_normal(low, high).map_or(low, |law| law.mean);
2809 }
2810 Ok(OrderedFace {
2811 order,
2812 mean: permuted_mean,
2813 upper: permuted_upper,
2814 factor,
2815 })
2816}
2817
2818#[derive(Clone, Debug)]
2821enum TiltStatus {
2822 Converged { iterations: usize, residual: f64 },
2824 Untilted { reason: String },
2827}
2828
2829impl std::fmt::Display for TiltStatus {
2830 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2831 match self {
2832 TiltStatus::Converged {
2833 iterations,
2834 residual,
2835 } => write!(
2836 f,
2837 "converged to the saddle point in {iterations} Newton step(s) (residual {residual:.1e})"
2838 ),
2839 TiltStatus::Untilted { reason } => write!(f, "untilted ({reason})"),
2840 }
2841 }
2842}
2843
2844fn solve_dense_square(mut a: Vec<f64>, n: usize, mut b: Vec<f64>) -> Option<Vec<f64>> {
2848 if a.len() != n * n || b.len() != n {
2849 return None;
2850 }
2851 for column in 0..n {
2852 let mut pivot_row = column;
2853 let mut pivot_magnitude = a[column * n + column].abs();
2854 for row in (column + 1)..n {
2855 let magnitude = a[row * n + column].abs();
2856 if magnitude > pivot_magnitude {
2857 pivot_magnitude = magnitude;
2858 pivot_row = row;
2859 }
2860 }
2861 if !(pivot_magnitude.is_finite() && pivot_magnitude > 0.0) {
2862 return None;
2863 }
2864 if pivot_row != column {
2865 for k in 0..n {
2866 a.swap(column * n + k, pivot_row * n + k);
2867 }
2868 b.swap(column, pivot_row);
2869 }
2870 let pivot = a[column * n + column];
2871 for row in (column + 1)..n {
2872 let multiplier = a[row * n + column] / pivot;
2873 if multiplier == 0.0 {
2874 continue;
2875 }
2876 for k in (column + 1)..n {
2877 a[row * n + k] -= multiplier * a[column * n + k];
2878 }
2879 b[row] -= multiplier * b[column];
2880 }
2881 }
2882 let mut x = vec![0.0f64; n];
2883 for row in (0..n).rev() {
2884 let mut total = b[row];
2885 for k in (row + 1)..n {
2886 total -= a[row * n + k] * x[k];
2887 }
2888 x[row] = total / a[row * n + row];
2889 if !x[row].is_finite() {
2890 return None;
2891 }
2892 }
2893 Some(x)
2894}
2895
2896fn saddle_point_tilt(
2964 mean: &Array1<f64>,
2965 upper: &[f64],
2966 factor: &Array2<f64>,
2967) -> (Option<Array1<f64>>, TiltStatus) {
2968 let q = mean.len();
2969 if q == 0 || factor.dim() != (q, q) || upper.len() != q {
2970 return (
2971 None,
2972 TiltStatus::Untilted {
2973 reason: "the face geometry is inconsistent".to_string(),
2974 },
2975 );
2976 }
2977 for i in 0..q {
2978 if !(factor[[i, i]] > 0.0) {
2979 return (
2980 None,
2981 TiltStatus::Untilted {
2982 reason: format!("Cholesky pivot {i} is not positive"),
2983 },
2984 );
2985 }
2986 }
2987 let diagonal: Vec<f64> = (0..q).map(|i| factor[[i, i]]).collect();
2988 let unknowns = 2 * q - 1;
2989 let residual = |v: &[f64]| -> Option<(Vec<f64>, Vec<f64>, Vec<f64>)> {
2992 let (z, mu) = v.split_at(q);
2993 let tilt_at = |i: usize| if i + 1 < q { mu[i] } else { 0.0 };
2994 let mut rho = vec![0.0f64; q];
2995 let mut wall_derivative = vec![0.0f64; q];
2996 for i in 0..q {
2997 let mut bound = -mean[i];
2998 for j in 0..i {
2999 bound -= factor[[i, j]] * z[j];
3000 }
3001 let low = bound / diagonal[i] - tilt_at(i);
3002 let high = if upper[i].is_finite() {
3003 low + upper[i] / diagonal[i]
3004 } else {
3005 f64::INFINITY
3006 };
3007 let law = truncated_standard_normal(low, high)?;
3008 rho[i] = law.mean;
3009 wall_derivative[i] = law.mean_wall_derivative;
3010 }
3011 let mut f = vec![0.0f64; unknowns];
3012 for i in 0..q {
3013 f[i] = z[i] - tilt_at(i) - rho[i];
3014 }
3015 for k in 0..(q - 1) {
3016 let mut coupling = 0.0;
3017 for i in (k + 1)..q {
3018 coupling += rho[i] * factor[[i, k]] / diagonal[i];
3019 }
3020 f[q + k] = mu[k] - coupling;
3021 }
3022 if f.iter().any(|value| !value.is_finite()) {
3023 return None;
3024 }
3025 Some((f, rho, wall_derivative))
3026 };
3027 let infinity_norm = |f: &[f64]| f.iter().fold(0.0f64, |worst, value| worst.max(value.abs()));
3028
3029 let mut v = vec![0.0f64; unknowns];
3030 let Some((mut f, _, mut wall_derivative)) = residual(&v) else {
3031 return (
3032 None,
3033 TiltStatus::Untilted {
3034 reason: "the untilted rule has no representable conditional mass at the origin"
3035 .to_string(),
3036 },
3037 );
3038 };
3039 const MAX_NEWTON_STEPS: usize = 200;
3040 for iteration in 0..=MAX_NEWTON_STEPS {
3041 let norm = infinity_norm(&f);
3042 let scale = 1.0 + v.iter().fold(0.0f64, |worst, value| worst.max(value.abs()));
3043 if norm <= 1e-10 * scale {
3044 let mut tilt = Array1::<f64>::zeros(q);
3045 for k in 0..(q - 1) {
3046 tilt[k] = v[q + k];
3047 }
3048 return (
3049 Some(tilt),
3050 TiltStatus::Converged {
3051 iterations: iteration,
3052 residual: norm,
3053 },
3054 );
3055 }
3056 if iteration == MAX_NEWTON_STEPS {
3057 break;
3058 }
3059 let mut jacobian = vec![0.0f64; unknowns * unknowns];
3061 for i in 0..q {
3062 jacobian[i * unknowns + i] += 1.0;
3063 for j in 0..i {
3064 jacobian[i * unknowns + j] += wall_derivative[i] * factor[[i, j]] / diagonal[i];
3065 }
3066 if i + 1 < q {
3067 jacobian[i * unknowns + q + i] = -(1.0 - wall_derivative[i]);
3068 }
3069 }
3070 for k in 0..(q - 1) {
3071 let row = q + k;
3072 jacobian[row * unknowns + row] += 1.0;
3073 for j in 0..q {
3074 let mut value = 0.0;
3075 for i in (k.max(j) + 1)..q {
3076 value += wall_derivative[i] * factor[[i, j]] * factor[[i, k]]
3077 / (diagonal[i] * diagonal[i]);
3078 }
3079 jacobian[row * unknowns + j] = value;
3080 }
3081 for j in (k + 1)..(q - 1) {
3082 jacobian[row * unknowns + q + j] += wall_derivative[j] * factor[[j, k]] / diagonal[j];
3083 }
3084 }
3085 let negated: Vec<f64> = f.iter().map(|value| -value).collect();
3086 let Some(step) = solve_dense_square(jacobian, unknowns, negated) else {
3087 return (
3088 None,
3089 TiltStatus::Untilted {
3090 reason: format!("the saddle-point Jacobian is singular at Newton step {iteration}"),
3091 },
3092 );
3093 };
3094 let mut alpha = 1.0f64;
3098 let mut accepted = false;
3099 while alpha > 1e-12 {
3100 let trial: Vec<f64> = v
3101 .iter()
3102 .zip(step.iter())
3103 .map(|(value, delta)| value + alpha * delta)
3104 .collect();
3105 if let Some((trial_f, _, trial_derivative)) = residual(&trial)
3106 && infinity_norm(&trial_f) <= (1.0 - 1e-4 * alpha) * norm
3107 {
3108 v = trial;
3109 f = trial_f;
3110 wall_derivative = trial_derivative;
3111 accepted = true;
3112 break;
3113 }
3114 alpha *= 0.5;
3115 }
3116 if !accepted {
3117 return (
3118 None,
3119 TiltStatus::Untilted {
3120 reason: format!(
3121 "the saddle-point line search stalled at Newton step {iteration} \
3122 (residual {norm:.3e})"
3123 ),
3124 },
3125 );
3126 }
3127 }
3128 (
3129 None,
3130 TiltStatus::Untilted {
3131 reason: format!(
3132 "the saddle point was not reached in {MAX_NEWTON_STEPS} Newton steps (residual \
3133 {:.3e})",
3134 infinity_norm(&f)
3135 ),
3136 },
3137 )
3138}
3139
3140struct OrthantRule {
3151 face: OrderedFace,
3152 tilt: Option<Array1<f64>>,
3154 tilt_status: TiltStatus,
3155 generator: Vec<f64>,
3158 tangent_dimension: usize,
3159 ceiling: Option<StandardizedCeiling>,
3161}
3162
3163impl OrthantRule {
3164 fn new(
3165 mean: &Array1<f64>,
3166 upper: &[f64],
3167 covariance: &Array2<f64>,
3168 tangent_dimension: usize,
3169 ) -> Result<Self, String> {
3170 let q = mean.len();
3171 if q == 0 {
3172 return Err("the orthant rule needs at least one constraint normal".to_string());
3173 }
3174 if covariance.dim() != (q, q) || upper.len() != q {
3175 return Err(format!(
3176 "orthant rule geometry mismatch: centre={q}, covariance={:?}, upper limits={}",
3177 covariance.dim(),
3178 upper.len()
3179 ));
3180 }
3181 let face = ordered_face(mean, upper, covariance)?;
3182 let (tilt, tilt_status) = saddle_point_tilt(&face.mean, &face.upper, &face.factor);
3183 if let TiltStatus::Untilted { reason } = &tilt_status {
3184 log::debug!("[orthant-cubature] q={q} runs untilted: {reason}");
3185 }
3186 Ok(Self::from_face(face, tilt, tilt_status, tangent_dimension))
3187 }
3188
3189 fn from_face(
3190 face: OrderedFace,
3191 tilt: Option<Array1<f64>>,
3192 tilt_status: TiltStatus,
3193 tangent_dimension: usize,
3194 ) -> Self {
3195 let dimension = face.mean.len() + tangent_dimension;
3196 let generator = kronecker_generator((ORTHANT_MOMENT_REPLICATES + 1) * dimension);
3197 Self {
3198 face,
3199 tilt,
3200 tilt_status,
3201 generator,
3202 tangent_dimension,
3203 ceiling: None,
3204 }
3205 }
3206
3207 fn dimension(&self) -> usize {
3208 self.face.mean.len()
3209 }
3210
3211 fn replicate_shift(&self, replicate: usize) -> Result<&[f64], String> {
3212 let dimension = self.dimension() + self.tangent_dimension;
3213 let start = (replicate + 1) * dimension;
3214 self.generator
3215 .get(start..start + dimension)
3216 .ok_or_else(|| {
3217 format!(
3218 "the orthant rule carries {ORTHANT_MOMENT_REPLICATES} replicate lattices; \
3219 replicate {replicate} does not exist"
3220 )
3221 })
3222 }
3223
3224 fn accumulate<S: OrthantNodeSink>(
3227 &self,
3228 sink: &mut S,
3229 replicate: usize,
3230 first: usize,
3231 last: usize,
3232 ) -> Result<(), String> {
3233 let q = self.dimension();
3234 let dimension = q + self.tangent_dimension;
3235 let base = &self.generator[..dimension];
3236 let shift = self.replicate_shift(replicate)?;
3237 let mean = &self.face.mean;
3238 let upper = &self.face.upper;
3239 let factor = &self.face.factor;
3240 let mut z = Array1::<f64>::zeros(q);
3241 let mut ordered_point = Array1::<f64>::zeros(q);
3242 let mut point = Array1::<f64>::zeros(q);
3243 let mut tangent = vec![0.0f64; self.tangent_dimension];
3244 for node in first..last {
3245 let offset = node as f64 + 0.5;
3246 let mut log_weight = 0.0f64;
3247 for i in 0..q {
3248 let mu = self.tilt.as_ref().map_or(0.0, |tilt| tilt[i]);
3253 let mut bound = -mean[i];
3254 for j in 0..i {
3255 bound -= factor[[i, j]] * z[j];
3256 }
3257 let mut wall = bound / factor[[i, i]] - mu;
3258 let mut affine_ceiling = f64::INFINITY;
3265 if let Some(wall_rule) = &self.ceiling
3266 && wall_rule.pivot == i
3267 {
3268 let (raised, capped) = wall_rule.limit(&z);
3269 if raised - mu > wall {
3270 wall = raised - mu;
3271 }
3272 affine_ceiling = capped - mu;
3273 }
3274 let (lattice, _) = folded_lattice_coordinate(offset, base[i], shift[i]);
3275 if !upper[i].is_finite() && !affine_ceiling.is_finite() {
3276 let log_tail = normal_logsf(wall);
3277 if !log_tail.is_finite() {
3278 log_weight = f64::NEG_INFINITY;
3283 break;
3284 }
3285 log_weight += log_tail;
3286 let log_fraction = (1.0 - lattice).max(f64::MIN_POSITIVE).ln();
3295 let log_upper_tail = log_fraction + log_tail;
3296 let resolved = if log_upper_tail < 0.0 {
3297 log_upper_tail
3298 } else {
3299 -f64::MIN_POSITIVE
3300 };
3301 let shifted = -standard_normal_quantile_from_log_cdf(resolved)
3302 .map_err(|error| format!("orthant cubature coordinate {i}: {error}"))?;
3303 z[i] = shifted + mu;
3304 log_weight += 0.5 * mu * mu - mu * z[i];
3308 continue;
3309 }
3310
3311 let boxed = if upper[i].is_finite() {
3317 wall + upper[i] / factor[[i, i]]
3318 } else {
3319 f64::INFINITY
3320 };
3321 let ceiling = boxed.min(affine_ceiling);
3322 if !(ceiling > wall) {
3323 log_weight = f64::NEG_INFINITY;
3327 break;
3328 }
3329 let reflect = wall + ceiling < 0.0;
3339 let (low, high) = if reflect {
3340 (-ceiling, -wall)
3341 } else {
3342 (wall, ceiling)
3343 };
3344 let log_tail_low = normal_logsf(low);
3345 let log_tail_high = normal_logsf(high);
3346 if !log_tail_low.is_finite() {
3347 log_weight = f64::NEG_INFINITY;
3348 break;
3349 }
3350 let removed = log_tail_high - log_tail_low;
3353 let log_mass = log_tail_low + log1mexp_of_log_removed_mass(removed);
3354 if !log_mass.is_finite() {
3355 log_weight = f64::NEG_INFINITY;
3359 break;
3360 }
3361 log_weight += log_mass;
3362 let retained = (-lattice * (-removed.exp_m1())).ln_1p();
3366 let log_upper_tail = log_tail_low + retained;
3367 let resolved = if log_upper_tail < 0.0 {
3368 log_upper_tail
3369 } else {
3370 -f64::MIN_POSITIVE
3371 };
3372 let sampled = -standard_normal_quantile_from_log_cdf(resolved)
3373 .map_err(|error| format!("truncated cubature coordinate {i}: {error}"))?;
3374 let clamped = sampled.clamp(low, high);
3378 z[i] = if reflect { -clamped } else { clamped } + mu;
3379 log_weight += 0.5 * mu * mu - mu * z[i];
3380 }
3381 if !log_weight.is_finite() {
3382 continue;
3383 }
3384 for i in 0..q {
3385 let mut value = mean[i];
3386 for j in 0..=i {
3387 value += factor[[i, j]] * z[j];
3388 }
3389 ordered_point[i] = value;
3390 }
3391 for (position, &original) in self.face.order.iter().enumerate() {
3392 point[original] = ordered_point[position];
3393 }
3394 for (slot, tangent_value) in tangent.iter_mut().enumerate() {
3401 let (lattice, upper_side) =
3402 folded_lattice_coordinate(offset, base[q + slot], shift[q + slot]);
3403 *tangent_value = if lattice <= 0.5 {
3404 standard_normal_quantile_from_log_cdf(lattice.max(f64::MIN_POSITIVE).ln())
3405 .map_err(|error| {
3406 format!("joint cubature tangent coordinate {slot}: {error}")
3407 })?
3408 } else {
3409 -standard_normal_quantile_from_log_cdf(
3410 upper_side.max(f64::MIN_POSITIVE).ln(),
3411 )
3412 .map_err(|error| format!("joint cubature tangent coordinate {slot}: {error}"))?
3413 };
3414 }
3415 sink.push_joint(log_weight, &point, &tangent);
3416 }
3417 Ok(())
3418 }
3419
3420 fn refusal(&self, covariance: &Array2<f64>, error: f64, nodes: usize, efficiency: f64) -> String {
3426 let q = self.dimension();
3427 let depth: Vec<f64> = (0..q)
3428 .map(|position| {
3429 let original = self.face.order[position];
3430 -self.face.mean[position] / covariance[[original, original]].sqrt()
3431 })
3432 .collect();
3433 let depth_min = depth.iter().copied().fold(f64::INFINITY, f64::min);
3434 let depth_max = depth.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3435 let mut corr_max: f64 = 0.0;
3436 for i in 0..q {
3437 for j in 0..i {
3438 let denominator = (covariance[[i, i]] * covariance[[j, j]]).sqrt();
3439 if denominator > 0.0 {
3440 corr_max = corr_max.max((covariance[[i, j]] / denominator).abs());
3441 }
3442 }
3443 }
3444 let mut original_mean = Array1::<f64>::zeros(q);
3445 for (position, &original) in self.face.order.iter().enumerate() {
3446 original_mean[original] = self.face.mean[position];
3447 }
3448 log::debug!(
3449 "[orthant-face] q={q} mean={:?} covariance={:?}",
3450 original_mean.as_slice().map(<[f64]>::to_vec),
3451 covariance.as_slice().map(<[f64]>::to_vec),
3452 );
3453 format!(
3454 "truncated moments for a {q}-dimensional constraint face did not reach the certified \
3455 accuracy: the replicate standard error {error:.3e} still exceeds \
3456 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e} at {nodes} cubature nodes over \
3457 {ORTHANT_MOMENT_REPLICATES} replicate lattices; the proposal's effective sample size \
3458 is {:.4}% of its nodes and its tilt {} (wall depth {depth_min:.2}..{depth_max:.2} \
3459 sd, max |correlation| between constraint normals {corr_max:.3})",
3460 100.0 * efficiency,
3461 self.tilt_status
3462 )
3463 }
3464}
3465
3466fn folded_lattice_coordinate(offset: f64, generator: f64, shift: f64) -> (f64, f64) {
3473 let raw = offset * generator + shift;
3474 let fractional = raw - raw.floor();
3475 let upper_side = (2.0 * fractional - 1.0).abs();
3476 (1.0 - upper_side, upper_side)
3477}
3478
3479#[derive(Clone, Copy)]
3480struct WeightedProjectionNode {
3481 conditional_mean: f64,
3482 weight: f64,
3483}
3484
3485struct ProjectionNodeAccumulator<'a> {
3486 moments: OrthantAccumulator,
3487 normal_center: &'a Array1<f64>,
3488 projection_lift: &'a Array1<f64>,
3489 ambient_mean: f64,
3490 nodes: Vec<(f64, f64)>,
3491}
3492
3493impl<'a> ProjectionNodeAccumulator<'a> {
3494 fn new(
3495 normal_center: &'a Array1<f64>,
3496 projection_lift: &'a Array1<f64>,
3497 ambient_mean: f64,
3498 ) -> Self {
3499 Self {
3500 moments: OrthantAccumulator::new(normal_center.len()),
3501 normal_center,
3502 projection_lift,
3503 ambient_mean,
3504 nodes: Vec::new(),
3505 }
3506 }
3507
3508 fn normalized_nodes(sinks: Vec<Self>) -> Result<Vec<WeightedProjectionNode>, String> {
3512 let max_log_weight = sinks
3513 .iter()
3514 .flat_map(|sink| sink.nodes.iter().map(|(_, log_weight)| *log_weight))
3515 .fold(f64::NEG_INFINITY, f64::max);
3516 if !max_log_weight.is_finite() {
3517 return Err(
3518 "orthant projection cubature accumulated no finite node weight".to_string(),
3519 );
3520 }
3521 let weight_sum = sinks
3522 .iter()
3523 .flat_map(|sink| sink.nodes.iter().map(|(_, log_weight)| *log_weight))
3524 .map(|log_weight| (log_weight - max_log_weight).exp())
3525 .sum::<f64>();
3526 if !(weight_sum.is_finite() && weight_sum > 0.0) {
3527 return Err(format!(
3528 "orthant projection cubature has invalid normalized weight sum {weight_sum:?}"
3529 ));
3530 }
3531 Ok(sinks
3532 .into_iter()
3533 .flat_map(|sink| sink.nodes.into_iter())
3534 .map(|(conditional_mean, log_weight)| WeightedProjectionNode {
3535 conditional_mean,
3536 weight: (log_weight - max_log_weight).exp() / weight_sum,
3537 })
3538 .collect())
3539 }
3540}
3541
3542impl ReplicateSink for ProjectionNodeAccumulator<'_> {
3543 fn accumulator(&self) -> &OrthantAccumulator {
3544 &self.moments
3545 }
3546}
3547
3548impl OrthantNodeSink for ProjectionNodeAccumulator<'_> {
3549 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
3550 self.moments.push(log_weight, point);
3551 let conditional_mean = self.ambient_mean
3552 + self
3553 .projection_lift
3554 .iter()
3555 .zip(point.iter().zip(self.normal_center.iter()))
3556 .map(|(&lift, (&value, ¢er))| lift * (value - center))
3557 .sum::<f64>();
3558 self.nodes.push((conditional_mean, log_weight));
3559 }
3560}
3561
3562fn converged_projection_nodes(
3563 mean: &Array1<f64>,
3564 covariance: &Array2<f64>,
3565 upper: &[f64],
3566 projection_lift: &Array1<f64>,
3567 ambient_mean: f64,
3568) -> Result<Vec<WeightedProjectionNode>, String> {
3569 let q = mean.len();
3570 if covariance.dim() != (q, q) || projection_lift.len() != q || upper.len() != q {
3571 return Err(format!(
3572 "truncated projection geometry mismatch: mean={q}, covariance={:?}, lift={}, \
3573 upper limits={}",
3574 covariance.dim(),
3575 projection_lift.len(),
3576 upper.len()
3577 ));
3578 }
3579 let rule = OrthantRule::new(mean, upper, covariance, 0)?;
3584 let mut sinks: Vec<ProjectionNodeAccumulator<'_>> = (0..ORTHANT_MOMENT_REPLICATES)
3585 .map(|_| ProjectionNodeAccumulator::new(mean, projection_lift, ambient_mean))
3586 .collect();
3587 certified_orthant_moments(&rule, covariance, &mut sinks).map_err(|error| {
3588 format!("orthant projection for a {q}-dimensional constraint face: {error}")
3589 })?;
3590 ProjectionNodeAccumulator::normalized_nodes(sinks)
3591}
3592
3593fn projection_quantile(
3594 nodes: &[WeightedProjectionNode],
3595 residual_variance: f64,
3596 probability: f64,
3597 posterior_mean: f64,
3598 ambient_sd: f64,
3599) -> Result<f64, String> {
3600 if nodes.is_empty() {
3601 return Err("orthant projection quantile received no cubature nodes".to_string());
3602 }
3603 if residual_variance == 0.0 {
3604 let mut ordered = nodes.to_vec();
3605 ordered.sort_by(|left, right| left.conditional_mean.total_cmp(&right.conditional_mean));
3606 let mut cumulative = 0.0;
3607 for node in &ordered {
3608 cumulative += node.weight;
3609 if cumulative >= probability {
3610 return Ok(node.conditional_mean);
3611 }
3612 }
3613 return Ok(ordered
3614 .last()
3615 .expect("non-empty projection node set")
3616 .conditional_mean);
3617 }
3618
3619 let residual_sd = residual_variance.sqrt();
3620 let cdf = |value: f64| {
3621 nodes
3622 .iter()
3623 .map(|node| {
3624 node.weight * normal_cdf((value - node.conditional_mean) / residual_sd)
3625 })
3626 .sum::<f64>()
3627 };
3628 let mut step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
3629 let mut lower = posterior_mean - step;
3630 let mut upper = posterior_mean + step;
3631 while cdf(lower) > probability {
3632 step *= 2.0;
3633 lower = posterior_mean - step;
3634 if !lower.is_finite() {
3635 return Err(format!(
3636 "orthant projection quantile could not bracket lower probability {probability}"
3637 ));
3638 }
3639 }
3640 step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
3641 while cdf(upper) < probability {
3642 step *= 2.0;
3643 upper = posterior_mean + step;
3644 if !upper.is_finite() {
3645 return Err(format!(
3646 "orthant projection quantile could not bracket upper probability {probability}"
3647 ));
3648 }
3649 }
3650
3651 let resolution = f64::EPSILON.sqrt() * ambient_sd.max(residual_sd);
3652 loop {
3653 let midpoint = lower + 0.5 * (upper - lower);
3654 if midpoint == lower || midpoint == upper || upper - lower <= resolution {
3655 return Ok(midpoint);
3656 }
3657 if cdf(midpoint) < probability {
3658 lower = midpoint;
3659 } else {
3660 upper = midpoint;
3661 }
3662 }
3663}
3664
3665fn scalar_truncated_moments(
3671 mean: f64,
3672 variance: f64,
3673 upper: f64,
3674) -> Result<(Array1<f64>, Array2<f64>), String> {
3675 if !(variance.is_finite() && variance > 0.0) {
3676 return Err(format!(
3677 "scalar truncated moments need a positive finite variance, got {variance:?}"
3678 ));
3679 }
3680 let sd = variance.sqrt();
3681 let alpha = -mean / sd;
3685 if !upper.is_finite() {
3686 let mills = signed_probit_logcdf_and_mills_ratio(-alpha).1;
3687 if !(mills.is_finite() && mills >= 0.0) {
3688 return Err(format!(
3689 "scalar truncated moments: inverse Mills ratio at {alpha} is {mills:?}"
3690 ));
3691 }
3692 let truncated_mean = mean + sd * mills;
3693 let truncated_variance = variance * (1.0 + alpha * mills - mills * mills);
3694 if !(truncated_variance.is_finite() && truncated_variance >= 0.0) {
3695 return Err(format!(
3696 "scalar truncated moments produced variance {truncated_variance:?} at \
3697 standardized truncation point {alpha}"
3698 ));
3699 }
3700 return Ok((
3701 Array1::from_elem(1, truncated_mean),
3702 Array2::from_elem((1, 1), truncated_variance),
3703 ));
3704 }
3705 if !(upper > 0.0) {
3706 return Err(format!(
3707 "scalar truncated moments need the upper limit above the wall, got {upper:?}"
3708 ));
3709 }
3710 let beta = (upper - mean) / sd;
3711 let reflect = alpha + beta < 0.0;
3715 let (low, high, centre) = if reflect {
3716 (-beta, -alpha, -mean)
3717 } else {
3718 (alpha, beta, mean)
3719 };
3720 let log_tail_low = normal_logsf(low);
3721 let log_tail_high = normal_logsf(high);
3722 let log_mass = log_tail_low + log1mexp_of_log_removed_mass(log_tail_high - log_tail_low);
3723 if !log_mass.is_finite() {
3724 return Err(format!(
3725 "scalar truncated moments: the interval [0, {upper:.6e}] around mean {mean:.6e} \
3726 with standard deviation {sd:.6e} carries no representable mass"
3727 ));
3728 }
3729 let log_density_ratio = 0.5 * (low - high) * (low + high);
3733 let density_ratio = log_density_ratio.exp();
3734 let scale = (-0.5 * low * low - 0.5 * (2.0 * std::f64::consts::PI).ln() - log_mass).exp();
3735 let first = scale * -log_density_ratio.exp_m1();
3736 let second = scale * (low - high * density_ratio);
3737 let truncated_mean = centre + sd * first;
3738 let truncated_variance = variance * (1.0 + second - first * first);
3739 if !(truncated_variance.is_finite() && truncated_variance >= 0.0) {
3740 return Err(format!(
3741 "scalar truncated moments produced variance {truncated_variance:?} on the \
3742 standardized interval [{low}, {high}]"
3743 ));
3744 }
3745 Ok((
3746 Array1::from_elem(1, if reflect { -truncated_mean } else { truncated_mean }),
3747 Array2::from_elem((1, 1), truncated_variance),
3748 ))
3749}
3750
3751
3752fn kronecker_generator(dimension: usize) -> Vec<f64> {
3757 let mut generator = Vec::with_capacity(dimension);
3758 let mut candidate = 2u64;
3759 while generator.len() < dimension {
3760 if is_prime(candidate) {
3761 let root = (candidate as f64).sqrt();
3762 generator.push(root - root.floor());
3763 }
3764 candidate += 1;
3765 }
3766 generator
3767}
3768
3769fn is_prime(value: u64) -> bool {
3770 if value < 2 {
3771 return false;
3772 }
3773 let mut divisor = 2u64;
3774 while divisor * divisor <= value {
3775 if value % divisor == 0 {
3776 return false;
3777 }
3778 divisor += 1;
3779 }
3780 true
3781}
3782
3783#[cfg(test)]
3787mod tests_orthant_rule_support {
3788 use super::*;
3789
3790 impl OrthantRule {
3791 pub(super) fn in_given_order_untilted(
3795 mean: &Array1<f64>,
3796 upper: &[f64],
3797 covariance: &Array2<f64>,
3798 ) -> Result<Self, String> {
3799 let q = mean.len();
3800 let factor = gam_linalg::triangular::cholesky_factor_in_place(
3801 covariance.view(),
3802 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
3803 )
3804 .ok_or_else(|| {
3805 "the constraint-normal covariance is not numerically positive definite".to_string()
3806 })?;
3807 let face = OrderedFace {
3808 order: (0..q).collect(),
3809 mean: mean.clone(),
3810 upper: upper.to_vec(),
3811 factor,
3812 };
3813 Ok(Self::from_face(
3814 face,
3815 None,
3816 TiltStatus::Untilted {
3817 reason: "test rule".to_string(),
3818 },
3819 0,
3820 ))
3821 }
3822
3823 pub(super) fn with_affine_ceiling(mut self, normal: &Array1<f64>, bound: f64) -> Result<Self, String> {
3826 let q = self.dimension();
3827 if normal.len() != q {
3828 return Err(format!(
3829 "affine ceiling: the normal has length {} but the face has {q} coordinates",
3830 normal.len()
3831 ));
3832 }
3833 let mut permuted = Array1::<f64>::zeros(q);
3834 for (position, &original) in self.face.order.iter().enumerate() {
3835 permuted[position] = normal[original];
3836 }
3837 self.ceiling = Some(StandardizedCeiling::new(
3838 &permuted,
3839 bound,
3840 &self.face.mean,
3841 self.face.factor.view(),
3842 )?);
3843 Ok(self)
3844 }
3845
3846 pub(super) fn original_index(&self, position: usize) -> usize {
3848 self.face.order[position]
3849 }
3850 }
3851
3852 pub(super) fn moment_relative_change(
3856 previous: &(Array1<f64>, Array2<f64>),
3857 current: &(Array1<f64>, Array2<f64>),
3858 w: &Array2<f64>,
3859 ) -> f64 {
3860 let q = current.0.len();
3861 let mut worst = 0.0f64;
3862 for i in 0..q {
3863 let sd_i = w[[i, i]].sqrt();
3864 worst = worst.max((current.0[i] - previous.0[i]).abs() / sd_i);
3865 for j in 0..q {
3866 let sd_j = w[[j, j]].sqrt();
3867 worst =
3868 worst.max((current.1[[i, j]] - previous.1[[i, j]]).abs() / (sd_i * sd_j));
3869 }
3870 }
3871 worst
3872 }
3873}
3874
3875#[cfg(test)]
3876mod tests {
3877 use super::*;
3878 use ndarray::array;
3879
3880 fn quadrature_truncated_moments(mean: f64, variance: f64) -> (f64, f64) {
3884 let sd = variance.sqrt();
3885 let alpha = -mean / sd;
3886 let panels = 400_000usize;
3887 let upper = alpha + 60.0;
3888 let step = (upper - alpha) / panels as f64;
3889 let mut mass = 0.0f64;
3890 let mut first = 0.0f64;
3891 let mut second = 0.0f64;
3892 for index in 0..=panels {
3893 let z = alpha + step * index as f64;
3894 let simpson = if index == 0 || index == panels {
3895 1.0
3896 } else if index % 2 == 1 {
3897 4.0
3898 } else {
3899 2.0
3900 };
3901 let density = (-(z * z - alpha * alpha) / 2.0).exp();
3902 mass += simpson * density;
3903 first += simpson * density * z;
3904 second += simpson * density * z * z;
3905 }
3906 let m1 = first / mass;
3907 let m2 = second / mass;
3908 (mean + sd * m1, variance * (m2 - m1 * m1))
3909 }
3910
3911 #[test]
3914 fn scalar_truncated_moments_match_the_closed_form_at_every_regime() {
3915 let (mean, variance) = scalar_truncated_moments(0.0, 1.0, f64::INFINITY).expect("half normal");
3917 let expected_mean = (2.0 / std::f64::consts::PI).sqrt();
3918 assert!(
3919 (mean[0] - expected_mean).abs() < 1e-12,
3920 "half-normal mean {} vs {expected_mean}",
3921 mean[0]
3922 );
3923 let expected_variance = 1.0 - 2.0 / std::f64::consts::PI;
3924 assert!(
3925 (variance[[0, 0]] - expected_variance).abs() < 1e-12,
3926 "half-normal variance {} vs {expected_variance}",
3927 variance[[0, 0]]
3928 );
3929 assert!(
3930 variance[[0, 0]] > 0.36 && variance[[0, 0]] < 0.37,
3931 "a coefficient whose mode sits exactly on its bound keeps a THIRD of its \
3932 unconstrained variance, not zero: got {}",
3933 variance[[0, 0]]
3934 );
3935
3936 for center in [-2.0, -4.0, -8.0] {
3941 let (deep_mean, deep) = scalar_truncated_moments(center, 1.0, f64::INFINITY).expect("deep tail");
3942 let (reference_mean, reference_variance) = quadrature_truncated_moments(center, 1.0);
3943 assert!(
3944 (deep_mean[0] - reference_mean).abs() < 1e-9 * reference_mean.abs().max(1.0),
3945 "closed-form mean {} vs quadrature {reference_mean} at centre {center}",
3946 deep_mean[0]
3947 );
3948 assert!(
3949 (deep[[0, 0]] / reference_variance - 1.0).abs() < 1e-8,
3950 "closed-form variance {} vs quadrature {reference_variance} at centre {center}",
3951 deep[[0, 0]]
3952 );
3953 assert!(
3954 deep[[0, 0]] > 0.0,
3955 "a finite multiplier never gives zero variance, got {} at centre {center}",
3956 deep[[0, 0]]
3957 );
3958 }
3959 let (_, at_eight) = scalar_truncated_moments(-8.0, 1.0, f64::INFINITY).expect("deep tail");
3962 assert!(
3963 at_eight[[0, 0]] * 64.0 > 0.9 && at_eight[[0, 0]] * 64.0 < 1.0,
3964 "variance times alpha^2 should approach one from below, got {}",
3965 at_eight[[0, 0]] * 64.0
3966 );
3967
3968 let (far_mean, far_variance) = scalar_truncated_moments(10.0, 4.0, f64::INFINITY).expect("inactive");
3974 let (reference_mean, reference_variance) = quadrature_truncated_moments(10.0, 4.0);
3975 assert!(
3976 (far_mean[0] - reference_mean).abs() < 1e-9,
3977 "inactive-bound mean {} vs quadrature {reference_mean}",
3978 far_mean[0]
3979 );
3980 assert!(
3981 (far_variance[[0, 0]] - reference_variance).abs() < 1e-9,
3982 "inactive-bound variance {} vs quadrature {reference_variance}",
3983 far_variance[[0, 0]]
3984 );
3985 assert!(
3986 (far_mean[0] - 10.0).abs() < 1e-5 && far_mean[0] > 10.0,
3987 "a bound five sd away moves the mean by the tail mass and no more, got {}",
3988 far_mean[0]
3989 );
3990 assert!(
3991 (far_variance[[0, 0]] - 4.0).abs() < 1e-4 && far_variance[[0, 0]] < 4.0,
3992 "a bound five sd away shrinks the variance by the tail mass and no more, got {}",
3993 far_variance[[0, 0]]
3994 );
3995 }
3996
3997 #[test]
3998 fn equal_tailed_projection_interval_is_asymmetric_for_a_half_normal() {
3999 let covariance = array![[1.0]];
4000 let center = array![0.0];
4001 let constraints =
4002 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
4003 let correction =
4004 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4005 .expect("correction")
4006 .expect("active half-space");
4007 let geometry = ConstrainedPosteriorGeometry {
4008 constraints,
4009 mode: array![0.0],
4010 unconstrained_center: Some(center),
4011 correction: Some(correction),
4012 moment_status: ConstrainedPosteriorMomentStatus::Available,
4013 };
4014 let (lower, upper) = constrained_projection_equal_tailed_interval(
4015 &covariance,
4016 &geometry,
4017 &array![1.0],
4018 0.95,
4019 )
4020 .expect("equal-tailed interval");
4021
4022 let expected_lower = standard_normal_quantile(0.5125).expect("lower quantile");
4025 let expected_upper = standard_normal_quantile(0.9875).expect("upper quantile");
4026 assert!(
4027 (lower - expected_lower).abs() < 2e-3,
4028 "half-normal lower endpoint {lower} vs {expected_lower}"
4029 );
4030 assert!(
4031 (upper - expected_upper).abs() < 2e-3,
4032 "half-normal upper endpoint {upper} vs {expected_upper}"
4033 );
4034 let posterior_mean = (2.0 / std::f64::consts::PI).sqrt();
4035 assert!(
4036 (posterior_mean - lower) < (upper - posterior_mean),
4037 "the exact skew interval must not collapse back to mean +/- z*sd"
4038 );
4039 }
4040
4041 #[test]
4042 fn equal_tailed_projection_sweep_has_exact_mass_and_repairs_the_short_symmetric_band() {
4043 let covariance = array![[1.0]];
4044 let constraints =
4045 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
4046 let alpha = 0.025;
4047 let ambient_width =
4048 2.0 * standard_normal_quantile(1.0 - alpha).expect("ambient quantile");
4049 let mut saw_repaired_short_symmetric_band = false;
4050
4051 for center_value in [0.0, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 5.0] {
4052 let center = array![center_value];
4053 let correction = constrained_posterior_correction_from_covariance(
4054 &covariance,
4055 ¢er,
4056 &constraints,
4057 )
4058 .expect("correction")
4059 .expect("finite lower truncation");
4060 let posterior_variance =
4061 1.0 - correction.removed_variance_diagonal()[0];
4062 let geometry = ConstrainedPosteriorGeometry {
4063 constraints: constraints.clone(),
4064 mode: array![center_value.max(0.0)],
4065 unconstrained_center: Some(center),
4066 correction: Some(correction),
4067 moment_status: ConstrainedPosteriorMomentStatus::Available,
4068 };
4069 let (lower, upper) = constrained_projection_equal_tailed_interval(
4070 &covariance,
4071 &geometry,
4072 &array![1.0],
4073 0.95,
4074 )
4075 .expect("equal-tailed interval");
4076
4077 let mass_below_bound = normal_cdf(-center_value);
4078 let retained_mass = 1.0 - mass_below_bound;
4079 let truncated_cdf = |value: f64| {
4080 (normal_cdf(value - center_value) - mass_below_bound) / retained_mass
4081 };
4082 assert!(
4083 (truncated_cdf(lower) - alpha).abs() < 2e-8
4084 && (truncated_cdf(upper) - (1.0 - alpha)).abs() < 2e-8,
4085 "centre {center_value}: endpoints [{lower}, {upper}] do not enclose exact \
4086 posterior mass 0.95"
4087 );
4088 assert!(
4089 lower >= 0.0,
4090 "centre {center_value}: lower endpoint {lower} escaped the saved cone"
4091 );
4092 assert!(
4093 upper - lower <= ambient_width + 1e-10,
4094 "centre {center_value}: truncation widened [{lower}, {upper}] beyond the \
4095 ambient Gaussian interval"
4096 );
4097
4098 if center_value == 3.0 {
4099 let symmetric_width = 2.0
4100 * standard_normal_quantile(1.0 - alpha).expect("symmetric quantile")
4101 * posterior_variance.sqrt();
4102 assert!(
4103 upper - lower > symmetric_width,
4104 "the exact 3-SE interval must repair the moment-matched symmetric interval's \
4105 short, under-covering band: exact width {}, symmetric width {symmetric_width}",
4106 upper - lower
4107 );
4108 saw_repaired_short_symmetric_band = true;
4109 }
4110 }
4111
4112 assert!(
4113 saw_repaired_short_symmetric_band,
4114 "the sweep must include its 3-SE regression cell"
4115 );
4116 }
4117
4118 #[test]
4131 fn a_constraint_row_below_the_lift_accuracy_floor_is_dropped_though_detectable() {
4132 let identity = Array2::<f64>::eye(4);
4133 let center = Array1::<f64>::zeros(4);
4134
4135 let mut resolvable = Array2::<f64>::zeros((3, 4));
4136 resolvable[[0, 0]] = 1.0;
4137 resolvable[[1, 1]] = 1.0;
4138 resolvable[[2, 2]] = 1.0;
4139 let constraints = LinearInequalityConstraints::new(resolvable, Array1::<f64>::zeros(3))
4140 .expect("orthogonal constraint rows");
4141 let correction =
4142 constrained_posterior_correction_from_covariance(&identity, ¢er, &constraints)
4143 .expect("orthogonal face")
4144 .expect("an active face at zero slack");
4145 assert_eq!(
4146 correction.rows,
4147 vec![0, 1, 2],
4148 "three mutually independent constraint normals must all be retained"
4149 );
4150
4151 let sine = 3.0e-7;
4154 let pivot = sine * sine;
4155 let diagonal = 1.0 + pivot;
4156 let detectability_limit = 2.0 * f64::EPSILON * diagonal;
4157 assert!(
4158 pivot > detectability_limit,
4159 "the fixture must be DETECTABLE, or the drop below proves nothing: pivot \
4160 {pivot:e} against the bare rank limit {detectability_limit:e}"
4161 );
4162 assert!(
4163 pivot < detectability_limit / ORTHANT_MOMENT_RELATIVE_TOLERANCE,
4164 "the fixture must sit below the accuracy the first pass demands"
4165 );
4166
4167 let mut degenerate = Array2::<f64>::zeros((3, 4));
4168 degenerate[[0, 0]] = 1.0;
4169 degenerate[[1, 0]] = 1.0;
4170 degenerate[[1, 1]] = sine;
4171 degenerate[[2, 2]] = 1.0;
4172 let constraints = LinearInequalityConstraints::new(degenerate, Array1::<f64>::zeros(3))
4173 .expect("near-parallel constraint rows");
4174 let correction =
4175 constrained_posterior_correction_from_covariance(&identity, ¢er, &constraints)
4176 .expect("near-degenerate face")
4177 .expect("an active face at zero slack");
4178 assert_eq!(
4179 correction.rows,
4180 vec![0, 2],
4181 "the near-parallel row must be dropped: retaining it reports a lift whose own \
4182 defining identity A·G = I fails by more than the certified accuracy"
4183 );
4184 }
4185
4186 #[test]
4208 fn the_retained_face_satisfies_the_identity_that_defines_its_lift() {
4209 const ROWS: usize = 7;
4210 const DIMENSION: usize = 8;
4211 const DEGREE: usize = 5;
4212 const SPACING: f64 = 1.0e-2;
4213
4214 let mut a = Array2::<f64>::zeros((ROWS, DIMENSION));
4215 for row in 0..ROWS {
4216 let node = row as f64 * SPACING;
4217 for power in 0..DEGREE {
4218 a[[row, power]] = node.powi(power as i32);
4219 }
4220 }
4221 let constraints = LinearInequalityConstraints::new(a.clone(), Array1::<f64>::zeros(ROWS))
4222 .expect("clustered Vandermonde rows");
4223
4224 let covariance = Array2::<f64>::eye(DIMENSION);
4230 let mut center = Array1::<f64>::zeros(DIMENSION);
4231 center[0] = 7.0;
4232 for row in 0..ROWS {
4233 let normal = a.row(row);
4234 let slack = normal.dot(¢er) / normal.dot(&normal).sqrt();
4235 assert!(
4236 slack < 8.12 && slack > 6.0,
4237 "row {row} must be a candidate inside the resolution horizon, got slack {slack}"
4238 );
4239 }
4240
4241 let correction =
4242 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4243 .expect("clustered Vandermonde face")
4244 .expect("an active face inside the horizon");
4245
4246 assert!(
4247 correction.rows.len() < ROWS,
4248 "the fixture must exercise the filter: all {ROWS} rows were retained"
4249 );
4250 assert!(
4251 correction.rows.len() >= 2,
4252 "the face must not collapse to a single row, or the identity below is vacuous: \
4253 retained {:?}",
4254 correction.rows
4255 );
4256
4257 let mut departure = 0.0_f64;
4258 for (i, &row_index) in correction.rows.iter().enumerate() {
4259 for j in 0..correction.rows.len() {
4260 let entry = a.row(row_index).dot(&correction.lift.column(j));
4261 let target = if i == j { 1.0 } else { 0.0 };
4262 departure = departure.max((entry - target).abs());
4263 }
4264 }
4265 assert!(
4266 departure <= ORTHANT_MOMENT_RELATIVE_TOLERANCE,
4267 "the reported lift must satisfy A·G = I, the identity it is defined by, to the \
4268 accuracy this module certifies its moments to: max|A G - I| = {departure:e} on \
4269 the retained rows {:?}",
4270 correction.rows
4271 );
4272 }
4273
4274 fn face_at_exclusion(
4280 candidates: &[(usize, f64, Array1<f64>)],
4281 constraints: &LinearInequalityConstraints,
4282 center: &Array1<f64>,
4283 excluded: &[usize],
4284 ) -> Option<(Vec<usize>, bool)> {
4285 let face = assemble_retained_face(
4286 candidates,
4287 ORTHANT_MOMENT_RELATIVE_TOLERANCE,
4288 constraints,
4289 center,
4290 excluded,
4291 )
4292 .expect("face assembly")?;
4293 let lift = cholesky_solve_right(&face.factor, &face.sigma_at).expect("lift solve");
4294 let departure =
4295 lift_identity_departure(&lift, constraints, &face.rows).expect("identity departure");
4296 Some((face.rows, departure <= ORTHANT_MOMENT_RELATIVE_TOLERANCE))
4297 }
4298
4299 #[test]
4320 fn the_walk_returns_the_largest_admissible_face_2714() {
4321 const ROWS: usize = 7;
4322 const DIMENSION: usize = 8;
4323 const DEGREE: usize = 5;
4324 const SPACING: f64 = 1.0e-2;
4325
4326 let mut a = Array2::<f64>::zeros((ROWS, DIMENSION));
4327 for row in 0..ROWS {
4328 let node = row as f64 * SPACING;
4329 for power in 0..DEGREE {
4330 a[[row, power]] = node.powi(power as i32);
4331 }
4332 }
4333 let constraints = LinearInequalityConstraints::new(a.clone(), Array1::<f64>::zeros(ROWS))
4334 .expect("clustered Vandermonde rows");
4335 let covariance = Array2::<f64>::eye(DIMENSION);
4336 let mut center = Array1::<f64>::zeros(DIMENSION);
4337 center[0] = 7.0;
4338
4339 let correction =
4340 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4341 .expect("clustered Vandermonde face")
4342 .expect("an active face inside the horizon");
4343
4344 let sigma_at = covariance.dot(&constraints.a.t());
4345 let candidates = constraint_face_candidates(sigma_at.view(), ¢er, &constraints)
4346 .expect("candidate rows");
4347 let candidate_rows: Vec<usize> = candidates.iter().map(|(row, _, _)| *row).collect();
4348
4349 let mut admissible_faces: Vec<Vec<usize>> = Vec::new();
4350 let mut distinct_faces: Vec<Vec<usize>> = Vec::new();
4351 for mask in 0..(1u32 << candidate_rows.len()) {
4352 let excluded: Vec<usize> = candidate_rows
4353 .iter()
4354 .enumerate()
4355 .filter(|(position, _)| mask & (1 << position) != 0)
4356 .map(|(_, row)| *row)
4357 .collect();
4358 let Some((rows, admissible)) =
4359 face_at_exclusion(&candidates, &constraints, ¢er, &excluded)
4360 else {
4361 continue;
4362 };
4363 if !distinct_faces.contains(&rows) {
4364 distinct_faces.push(rows.clone());
4365 }
4366 if admissible && !admissible_faces.contains(&rows) {
4367 admissible_faces.push(rows);
4368 }
4369 }
4370 let largest = admissible_faces
4371 .iter()
4372 .map(Vec::len)
4373 .max()
4374 .expect("some exclusion set must yield a face satisfying its own identity");
4375
4376 assert!(
4380 distinct_faces.len() >= 3,
4381 "#2714: the exclusion sweep saw only {} distinct face(s), so the fixture does not \
4382 exercise a walk: {distinct_faces:?}",
4383 distinct_faces.len()
4384 );
4385 let (unexcluded, unexcluded_admissible) =
4386 face_at_exclusion(&candidates, &constraints, ¢er, &[])
4387 .expect("the unexcluded face");
4388 assert!(
4389 !unexcluded_admissible,
4390 "#2714: the unexcluded face {unexcluded:?} already satisfies its own lift identity, \
4391 so the walk is not exercised"
4392 );
4393 assert!(
4394 unexcluded.len() > largest,
4395 "#2714: the unexcluded face {unexcluded:?} is no larger than the {largest}-row \
4396 answer, so nothing had to be dropped"
4397 );
4398
4399 assert!(
4407 admissible_faces.contains(&correction.rows),
4408 "#2714: the walk returned {:?}, which is not among the {} faces whose lift satisfies \
4409 its own identity: {admissible_faces:?}",
4410 correction.rows,
4411 admissible_faces.len()
4412 );
4413 assert_eq!(
4414 correction.rows.len(),
4415 largest,
4416 "#2714: the walk returned the {}-row face {:?} where an admissible face of {largest} \
4417 rows exists. Dropping the least independent accepted row is the step that reaches \
4418 the largest one; stepping a retention floor cannot, because the floor is a proxy \
4419 for the face and the proxy is not injective.",
4420 correction.rows.len(),
4421 correction.rows
4422 );
4423 assert_eq!(
4431 correction.rows.first(),
4432 candidate_rows.first(),
4433 "#2714: the walk returned {:?}, which does not retain the tightest candidate row \
4434 {:?}. The slack ordering is the reason a dropped row imposes no constraint the \
4435 retained ones do not; dropping the binding wall would relax the posterior by a \
4436 multiple of its own standard deviation.",
4437 correction.rows,
4438 candidate_rows.first()
4439 );
4440 }
4441
4442 #[test]
4464 fn a_rank_deficient_constraint_system_still_yields_a_liftable_face_2714() {
4465 const ROWS: usize = 40;
4466 const DIMENSION: usize = 5;
4467 const SPACING: f64 = 2.0e-2;
4468
4469 let mut a = Array2::<f64>::zeros((ROWS, DIMENSION));
4470 for row in 0..ROWS {
4471 let node = row as f64 * SPACING;
4472 for power in 0..DIMENSION {
4473 a[[row, power]] = node.powi(power as i32);
4474 }
4475 }
4476 let constraints = LinearInequalityConstraints::new(a, Array1::<f64>::zeros(ROWS))
4477 .expect("clustered Vandermonde rows");
4478 let covariance = Array2::<f64>::eye(DIMENSION);
4479 let mut center = Array1::<f64>::zeros(DIMENSION);
4484 center[0] = 1.0;
4485
4486 let sigma_at = covariance.dot(&constraints.a.t());
4487 let candidates = constraint_face_candidates(sigma_at.view(), ¢er, &constraints)
4488 .expect("candidate rows");
4489 assert!(
4490 candidates.len() > DIMENSION,
4491 "#2714: the fixture must be rank-deficient to exercise the walk, and it offers only \
4492 {} candidate row(s) against {DIMENSION} columns",
4493 candidates.len()
4494 );
4495 let (unexcluded, unexcluded_admissible) =
4496 face_at_exclusion(&candidates, &constraints, ¢er, &[])
4497 .expect("the unexcluded face");
4498 assert!(
4499 !unexcluded_admissible,
4500 "#2714: the unexcluded face {unexcluded:?} already satisfies its own lift identity, \
4501 so the walk never runs and this fixture asserts nothing"
4502 );
4503
4504 let correction =
4505 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4506 .expect("a rank-deficient constraint system must still produce a face")
4507 .expect("an active face inside the horizon");
4508
4509 let departure = lift_identity_departure(&correction.lift, &constraints, &correction.rows)
4513 .expect("identity departure of the returned lift");
4514 assert!(
4515 departure <= ORTHANT_MOMENT_RELATIVE_TOLERANCE,
4516 "#2714: the returned {}-row face {:?} misses the identity that defines its lift by \
4517 {departure:.6e}, above {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e}",
4518 correction.rows.len(),
4519 correction.rows
4520 );
4521 assert!(
4522 correction.rows.len() < unexcluded.len(),
4523 "#2714: the walk returned {:?}, which is not smaller than the inadmissible \
4524 unexcluded face {unexcluded:?} — so it accepted a face it had already rejected",
4525 correction.rows
4526 );
4527 assert_eq!(
4528 correction.rows.first(),
4529 candidates.first().map(|(row, _, _)| row),
4530 "#2714: the walk dropped the tightest candidate row"
4531 );
4532 }
4533
4534 #[test]
4552 fn dropping_a_direction_takes_its_opposite_face_with_it_2714() {
4553 const NODES: usize = 40;
4554 const DIMENSION: usize = 5;
4555 const SPACING: f64 = 2.0e-2;
4556 const FAR_WALL: f64 = 3.0;
4562
4563 let mut a = Array2::<f64>::zeros((2 * NODES, DIMENSION));
4564 let mut b = Array1::<f64>::zeros(2 * NODES);
4565 for node in 0..NODES {
4566 let position = node as f64 * SPACING;
4567 for power in 0..DIMENSION {
4568 let entry = position.powi(power as i32);
4569 a[[node, power]] = entry;
4570 a[[NODES + node, power]] = -entry;
4571 }
4572 b[node] = 0.0;
4573 b[NODES + node] = -FAR_WALL;
4574 }
4575 let constraints =
4576 LinearInequalityConstraints::new(a, b).expect("two-sided Vandermonde slabs");
4577 let covariance = Array2::<f64>::eye(DIMENSION);
4578 let mut center = Array1::<f64>::zeros(DIMENSION);
4579 center[0] = 1.0;
4580
4581 let sigma_at = covariance.dot(&constraints.a.t());
4582 let candidates = constraint_face_candidates(sigma_at.view(), ¢er, &constraints)
4583 .expect("candidate rows");
4584 assert_eq!(
4585 candidates.len(),
4586 2 * NODES,
4587 "#2714: both walls of every slab must be candidates, or the fixture is not \
4588 two-sided where the walk runs"
4589 );
4590 let (unexcluded, unexcluded_admissible) =
4591 face_at_exclusion(&candidates, &constraints, ¢er, &[])
4592 .expect("the unexcluded face");
4593 assert!(
4594 !unexcluded_admissible,
4595 "#2714: the unexcluded face {unexcluded:?} already satisfies its own lift identity, \
4596 so no direction is ever dropped and this fixture asserts nothing"
4597 );
4598
4599 let correction =
4600 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4601 .expect("a two-sided rank-deficient system must still produce a face")
4602 .expect("an active face inside the horizon");
4603
4604 assert_eq!(
4606 correction.normal_upper_limits.len(),
4607 correction.rows.len(),
4608 "#2714: one upper limit per retained row"
4609 );
4610 for (position, &row) in correction.rows.iter().enumerate() {
4611 assert!(
4612 correction.normal_upper_limits[position].is_finite(),
4613 "#2714: retained row {row} reports an infinite upper limit on a system where \
4614 EVERY direction is two-sided. Its opposite face was left in the candidate pool \
4615 when the direction that carried the fold was dropped, so a two-sided bound is \
4616 being reported as a half-line: rows {:?}, limits {:?}",
4617 correction.rows,
4618 correction.normal_upper_limits
4619 );
4620 }
4621 for &row in &correction.rows {
4625 assert!(
4626 row < NODES,
4627 "#2714: the walk retained far-wall row {row}, which sits at slack {FAR_WALL} \
4628 against the near wall's 1 — the slacker of the pair replaced the binding one: \
4629 {:?}",
4630 correction.rows
4631 );
4632 }
4633 }
4634
4635 #[test]
4652 fn the_floor_round_trip_retains_the_row_it_was_aimed_at_2714() {
4653 let mut retained = 0usize;
4654 let mut exact_stalls = 0usize;
4655 let mut examined = 0usize;
4656 for accepted in 0..12usize {
4657 for diagonal_exponent in -8i32..=4 {
4658 for pivot_decades in 1..=15i32 {
4659 for tweak in 0..64u32 {
4660 let diagonal = 10.0_f64.powi(diagonal_exponent)
4661 * (1.0 + f64::from(tweak) / 64.0);
4662 let pivot = diagonal * 10.0_f64.powi(-pivot_decades);
4663 let scale = (accepted + 1) as f64 * f64::EPSILON * diagonal;
4664 let step = scale / pivot;
4665 let rebuilt_floor = scale / step;
4666 examined += 1;
4667 if pivot > rebuilt_floor {
4668 retained += 1;
4669 if scale / pivot == step {
4672 exact_stalls += 1;
4673 }
4674 }
4675 }
4676 }
4677 }
4678 }
4679 assert!(
4680 retained > 0,
4681 "#2714: the floor round trip never retained the row it was aimed at across \
4682 {examined} triples, which would make this refutation vacuous"
4683 );
4684 assert_eq!(
4685 retained, exact_stalls,
4686 "#2714: {retained} of {examined} triples retained the row the step was aimed at, and \
4687 {exact_stalls} of those recompute the same step. Every retention IS a stall — the \
4688 face is bit-identical, so the step is a function of unchanged inputs — and the old \
4689 rule's descent assertion fires on each one."
4690 );
4691 }
4692
4693 #[test]
4700 fn cubature_reproduces_independent_coordinates_within_its_certified_accuracy() {
4701 let mean = array![-0.5, 0.25, -1.5];
4702 let covariance = array![[2.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 1.0]];
4703 let (moment_mean, moment_covariance) =
4704 box_truncated_moments(&mean, &vec![f64::INFINITY; mean.len()], &covariance)
4705 .expect("independent orthant");
4706 for i in 0..3 {
4707 let (exact_mean, exact_variance) =
4708 scalar_truncated_moments(mean[i], covariance[[i, i]], f64::INFINITY).expect("scalar");
4709 let scale = covariance[[i, i]].sqrt();
4710 assert!(
4711 (moment_mean[i] - exact_mean[0]).abs()
4712 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * scale,
4713 "coordinate {i} mean {} vs exact {}",
4714 moment_mean[i],
4715 exact_mean[0]
4716 );
4717 assert!(
4718 (moment_covariance[[i, i]] - exact_variance[[0, 0]]).abs()
4719 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * covariance[[i, i]],
4720 "coordinate {i} variance {} vs exact {}",
4721 moment_covariance[[i, i]],
4722 exact_variance[[0, 0]]
4723 );
4724 for j in 0..3 {
4725 if i != j {
4726 assert!(
4727 moment_covariance[[i, j]].abs()
4728 < ORTHANT_MOMENT_RELATIVE_TOLERANCE
4729 * scale
4730 * covariance[[j, j]].sqrt(),
4731 "independent coordinates must stay uncorrelated under an orthant \
4732 truncation, got {} at ({i},{j})",
4733 moment_covariance[[i, j]]
4734 );
4735 }
4736 }
4737 }
4738 }
4739
4740 #[test]
4743 fn correction_lands_strictly_between_full_space_and_active_face() {
4744 let covariance = array![[1.0, 0.4], [0.4, 1.0]];
4745 let constraints =
4746 LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
4747 let center = array![-0.6, 0.3];
4749 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4750 .expect("correction")
4751 .expect("an active row");
4752 let truncated = correction.apply_to_covariance(&covariance);
4753
4754 let mut face = covariance.clone();
4757 let full_removal = correction.lift.dot(&array![[1.0]]).dot(&correction.lift.t());
4758 face -= &full_removal;
4759
4760 assert!(
4761 truncated[[0, 0]] > face[[0, 0]] + 1e-6,
4762 "truncated variance {} must exceed the active-face answer {}",
4763 truncated[[0, 0]],
4764 face[[0, 0]]
4765 );
4766 assert!(
4767 truncated[[0, 0]] < covariance[[0, 0]] - 1e-6,
4768 "truncated variance {} must fall below the unconstrained answer {}",
4769 truncated[[0, 0]],
4770 covariance[[0, 0]]
4771 );
4772 assert!(
4773 face[[0, 0]].abs() < 1e-12,
4774 "the active-face answer for a single pinned coordinate is exactly zero, got {}",
4775 face[[0, 0]]
4776 );
4777 assert!(
4778 correction.normal_mean_shift[0] > 0.0,
4779 "truncation moves the posterior mean INTO the feasible region, shift was {}",
4780 correction.normal_mean_shift[0]
4781 );
4782 }
4783
4784 #[test]
4787 fn inactive_constraints_produce_no_correction() {
4788 let covariance = array![[1.0, 0.0], [0.0, 1.0]];
4789 let constraints =
4790 LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
4791 let center = array![40.0, 0.0];
4792 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4793 .expect("correction");
4794 assert!(
4795 correction.is_none(),
4796 "a bound 40 posterior standard deviations away cannot move any moment at double \
4797 precision"
4798 );
4799 }
4800
4801 #[test]
4803 fn redundant_rows_are_dropped_by_the_rank_filter() {
4804 let covariance = array![[1.0, 0.2], [0.2, 1.0]];
4805 let constraints = LinearInequalityConstraints::new(
4806 array![[1.0, 0.0], [2.0, 0.0], [0.0, 1.0]],
4807 array![0.0, 0.0, 0.0],
4808 )
4809 .expect("cone");
4810 let center = array![-0.2, -0.3];
4811 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4812 .expect("correction")
4813 .expect("active rows");
4814 assert_eq!(
4815 correction.rows.len(),
4816 2,
4817 "the duplicated half-space must be filtered out, kept rows {:?}",
4818 correction.rows
4819 );
4820 }
4821
4822 #[test]
4824 fn corrected_covariance_stays_between_zero_and_the_unconstrained_answer() {
4825 let covariance = array![
4826 [1.0, 0.3, 0.1],
4827 [0.3, 1.2, -0.2],
4828 [0.1, -0.2, 0.8]
4829 ];
4830 let constraints = LinearInequalityConstraints::new(
4831 array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
4832 array![0.0, 0.0],
4833 )
4834 .expect("cone");
4835 for center in [
4836 array![-2.0, -1.0, 0.5],
4837 array![0.0, 0.0, 0.0],
4838 array![-0.1, 0.4, -3.0],
4839 ] {
4840 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4841 .expect("correction")
4842 .expect("active rows");
4843 let truncated = correction.apply_to_covariance(&covariance);
4844 for i in 0..3 {
4845 assert!(
4846 truncated[[i, i]] > 0.0,
4847 "coordinate {i} lost all variance at centre {center:?}: {}",
4848 truncated[[i, i]]
4849 );
4850 assert!(
4851 truncated[[i, i]] <= covariance[[i, i]] + 1e-9,
4852 "coordinate {i} gained variance at centre {center:?}: {} vs {}",
4853 truncated[[i, i]],
4854 covariance[[i, i]]
4855 );
4856 }
4857 let diagonal = correction.removed_variance_diagonal();
4858 for i in 0..3 {
4859 assert!(
4860 (diagonal[i] - (covariance[[i, i]] - truncated[[i, i]])).abs() < 1e-9,
4861 "the diagonal-only accessor must agree with the dense correction at {i}"
4862 );
4863 }
4864 }
4865 }
4866 fn two_sided_bound_rows(lower: f64, upper: f64, columns: usize) -> LinearInequalityConstraints {
4872 let mut a = Array2::<f64>::zeros((2, columns));
4873 a[[0, 0]] = 1.0;
4874 a[[1, 0]] = -1.0;
4875 LinearInequalityConstraints::new(a, array![lower, -upper])
4876 .expect("two-sided bound rows")
4877 }
4878
4879 fn quadrature_box_moments(mean: f64, variance: f64, upper: f64) -> (f64, f64) {
4883 let sd = variance.sqrt();
4884 let low = -mean / sd;
4885 let high = (upper - mean) / sd;
4886 let reference = if low <= 0.0 && 0.0 <= high {
4887 0.0
4888 } else if high < 0.0 {
4889 high
4890 } else {
4891 low
4892 };
4893 let panels = 400_000usize;
4894 let step = (high - low) / panels as f64;
4895 let (mut mass, mut first, mut second) = (0.0f64, 0.0f64, 0.0f64);
4896 for index in 0..=panels {
4897 let z = low + step * index as f64;
4898 let simpson = if index == 0 || index == panels {
4899 1.0
4900 } else if index % 2 == 1 {
4901 4.0
4902 } else {
4903 2.0
4904 };
4905 let density = (-(z * z - reference * reference) / 2.0).exp();
4906 mass += simpson * density;
4907 first += simpson * density * z;
4908 second += simpson * density * z * z;
4909 }
4910 let m1 = first / mass;
4911 let m2 = second / mass;
4912 (mean + sd * m1, variance * (m2 - m1 * m1))
4913 }
4914
4915 #[test]
4924 fn two_sided_coefficient_bound_keeps_its_far_wall_2523() {
4925 let columns = 3;
4926 let covariance = Array2::<f64>::eye(columns);
4927 let centre = array![0.6, 0.0, 0.0];
4928 let constraints = two_sided_bound_rows(0.0, 2.0, columns);
4929 let correction = constrained_posterior_correction_from_covariance(
4930 &covariance,
4931 ¢re,
4932 &constraints,
4933 )
4934 .expect("two-sided correction")
4935 .expect("an active two-sided bound corrects the posterior");
4936
4937 assert_eq!(
4938 correction.rows.len(),
4939 1,
4940 "the anti-parallel row adds no direction, so exactly one is retained"
4941 );
4942 let limits = correction.upper_limits();
4943 assert_eq!(limits.len(), 1);
4944 assert!(
4945 (limits[0] - 2.0).abs() < 1e-12,
4946 "the far wall of [0, 2] must arrive as the coordinate's upper limit, got {}",
4947 limits[0]
4948 );
4949
4950 let (bounded_mean, bounded_variance) =
4953 quadrature_box_moments(0.6, 1.0, 2.0);
4954 let (half_line_mean, half_line_variance) = quadrature_truncated_moments(0.6, 1.0);
4955 let reported_mean = 0.6 + correction.normal_mean_shift[0];
4956 let reported_variance = 1.0 - correction.removed_normal_variance[[0, 0]];
4957 assert!(
4958 (reported_mean - bounded_mean).abs() < 1e-6,
4959 "reported mean {reported_mean} must be the [0,2] mean {bounded_mean}, \
4960 not the [0,inf) mean {half_line_mean}"
4961 );
4962 assert!(
4963 (reported_variance - bounded_variance).abs() < 1e-6,
4964 "reported variance {reported_variance} must be the [0,2] variance \
4965 {bounded_variance}, not the [0,inf) variance {half_line_variance}"
4966 );
4967 assert!(
4970 (bounded_mean - half_line_mean).abs() > 0.1
4971 && (bounded_variance - half_line_variance).abs() > 0.1,
4972 "the fixture must separate the two answers: means {bounded_mean} vs \
4973 {half_line_mean}, variances {bounded_variance} vs {half_line_variance}"
4974 );
4975 }
4976
4977 #[test]
4982 fn a_far_wall_beyond_the_horizon_restores_the_half_line_answer_exactly() {
4983 let columns = 3;
4984 let covariance = Array2::<f64>::eye(columns);
4985 let centre = array![0.6, 0.0, 0.0];
4986 let two_sided = constrained_posterior_correction_from_covariance(
4987 &covariance,
4988 ¢re,
4989 &two_sided_bound_rows(0.0, 40.0, columns),
4990 )
4991 .expect("wide two-sided correction")
4992 .expect("the lower wall is still active");
4993
4994 let mut lower_only = Array2::<f64>::zeros((1, columns));
4995 lower_only[[0, 0]] = 1.0;
4996 let one_sided = constrained_posterior_correction_from_covariance(
4997 &covariance,
4998 ¢re,
4999 &LinearInequalityConstraints::new(lower_only, array![0.0]).expect("lower wall"),
5000 )
5001 .expect("one-sided correction")
5002 .expect("an active lower bound corrects the posterior");
5003
5004 assert_eq!(two_sided.rows.len(), 1);
5005 assert_eq!(
5006 two_sided.upper_limits(),
5007 vec![f64::INFINITY],
5008 "a wall 39.4 standard deviations away is not a candidate at all"
5009 );
5010 assert_eq!(
5011 two_sided.normal_mean_shift[0], one_sided.normal_mean_shift[0],
5012 "no reachable upper limit must reproduce the half-line mean shift exactly"
5013 );
5014 assert_eq!(
5015 two_sided.removed_normal_variance[[0, 0]],
5016 one_sided.removed_normal_variance[[0, 0]],
5017 "no reachable upper limit must reproduce the half-line variance exactly"
5018 );
5019 }
5020
5021 #[test]
5035 fn a_half_line_upper_limit_survives_the_json_round_trip_2601() {
5036 for limits in [
5037 vec![f64::INFINITY; 3],
5038 vec![2.5, f64::INFINITY, 1e300],
5039 Vec::new(),
5040 ] {
5041 let q = limits.len().max(1);
5042 let correction = ConstrainedPosteriorCorrection {
5043 lift: Array2::<f64>::zeros((4, q)),
5044 removed_normal_variance: Array2::<f64>::eye(q),
5045 normal_mean_shift: Array1::<f64>::zeros(q),
5046 rows: (0..q).collect(),
5047 normal_upper_limits: limits.clone(),
5048 };
5049 let json = serde_json::to_string(&correction).expect("serialize correction");
5050 let back: ConstrainedPosteriorCorrection =
5051 serde_json::from_str(&json).unwrap_or_else(|e| {
5052 panic!("a correction with limits {limits:?} must reload: {e}\n{json}")
5053 });
5054 assert_eq!(
5055 back.normal_upper_limits, limits,
5056 "upper limits must round-trip bit for bit"
5057 );
5058 assert_eq!(back.upper_limits(), correction.upper_limits());
5059 }
5060 }
5061
5062 #[test]
5066 fn a_solver_produced_half_line_correction_reloads_2601() {
5067 let columns = 3;
5068 let covariance = Array2::<f64>::eye(columns);
5069 let centre = array![0.6, 0.0, 0.0];
5070 let mut lower_only = Array2::<f64>::zeros((1, columns));
5071 lower_only[[0, 0]] = 1.0;
5072 let correction = constrained_posterior_correction_from_covariance(
5073 &covariance,
5074 ¢re,
5075 &LinearInequalityConstraints::new(lower_only, array![0.0]).expect("lower wall"),
5076 )
5077 .expect("one-sided correction")
5078 .expect("an active lower bound corrects the posterior");
5079 assert_eq!(
5080 correction.normal_upper_limits,
5081 vec![f64::INFINITY],
5082 "precondition: a half-line coordinate carries an infinite upper limit"
5083 );
5084
5085 let json = serde_json::to_string(&correction).expect("serialize");
5086 let back: ConstrainedPosteriorCorrection =
5087 serde_json::from_str(&json).expect("a solver-produced correction must reload");
5088 assert_eq!(back.normal_upper_limits, vec![f64::INFINITY]);
5089
5090 assert!(
5093 gam_problem::ensure_serialized_floats_are_finite(&correction).is_ok(),
5094 "an unbounded upper limit is a value, not a non-finite defect"
5095 );
5096 }
5097
5098 #[test]
5103 fn a_box_the_unconstrained_centre_overshoots_stays_inside_itself() {
5104 let columns = 2;
5105 let covariance = Array2::<f64>::eye(columns);
5106 let centre = array![-3.0, 0.0];
5109 let correction = constrained_posterior_correction_from_covariance(
5110 &covariance,
5111 ¢re,
5112 &two_sided_bound_rows(-1.0, 1.0, columns),
5113 )
5114 .expect("overshooting correction")
5115 .expect("both walls bind");
5116
5117 let limits = correction.upper_limits();
5118 assert!(
5119 (limits[0] - 2.0).abs() < 1e-12,
5120 "the slab is two units wide, got {}",
5121 limits[0]
5122 );
5123 let reported_mean = -2.0 + correction.normal_mean_shift[0];
5124 let reported_variance = 1.0 - correction.removed_normal_variance[[0, 0]];
5125 assert!(
5126 reported_mean > 0.0 && reported_mean < limits[0],
5127 "the posterior mean of a law supported on [0, {}] cannot sit outside it, \
5128 got {reported_mean}",
5129 limits[0]
5130 );
5131 assert!(
5135 reported_variance > 0.0 && reported_variance <= limits[0] * limits[0] / 4.0,
5136 "variance {reported_variance} exceeds the width bound for [0, {}]",
5137 limits[0]
5138 );
5139 let (expected_mean, expected_variance) = quadrature_box_moments(-2.0, 1.0, 2.0);
5140 assert!(
5141 (reported_mean - expected_mean).abs() < 1e-6
5142 && (reported_variance - expected_variance).abs() < 1e-6,
5143 "deep-tail slab moments {reported_mean}/{reported_variance} against the \
5144 independent quadrature {expected_mean}/{expected_variance}"
5145 );
5146 }
5147
5148 #[test]
5152 fn the_two_sided_scalar_form_meets_the_mills_branch_at_a_distant_wall() {
5153 for &(mean, variance) in &[(0.6f64, 1.0f64), (-2.5, 1.0), (0.0, 4.0), (3.0, 0.25)] {
5154 let sd: f64 = variance.sqrt();
5155 let distant = mean + 40.0 * sd;
5156 let (bounded_mean, bounded_variance) =
5157 scalar_truncated_moments(mean, variance, distant).expect("bounded");
5158 let (open_mean, open_variance) =
5159 scalar_truncated_moments(mean, variance, f64::INFINITY).expect("half line");
5160 assert!(
5161 (bounded_mean[0] - open_mean[0]).abs() <= 1e-12 * open_mean[0].abs().max(1.0),
5162 "mean {} vs {} at mean={mean} variance={variance}",
5163 bounded_mean[0],
5164 open_mean[0]
5165 );
5166 assert!(
5167 (bounded_variance[[0, 0]] - open_variance[[0, 0]]).abs()
5168 <= 1e-12 * open_variance[[0, 0]].abs().max(1.0),
5169 "variance {} vs {} at mean={mean} variance={variance}",
5170 bounded_variance[[0, 0]],
5171 open_variance[[0, 0]]
5172 );
5173 }
5174 }
5175
5176 #[test]
5179 fn the_two_sided_scalar_form_matches_an_independent_quadrature() {
5180 for &(mean, variance, upper) in &[
5181 (0.6f64, 1.0f64, 2.0f64),
5182 (-1.5, 1.0, 0.5),
5183 (3.0, 0.25, 0.4),
5184 (-4.0, 1.0, 0.2),
5185 (0.05, 1.0, 0.1),
5186 (-2.0, 1.0, 2.0),
5187 (0.5, 9.0, 12.0),
5188 ] {
5189 let (moment_mean, moment_variance) =
5190 scalar_truncated_moments(mean, variance, upper).expect("two-sided moments");
5191 let (reference_mean, reference_variance) =
5192 quadrature_box_moments(mean, variance, upper);
5193 let scale = variance.sqrt();
5194 assert!(
5195 (moment_mean[0] - reference_mean).abs() < 1e-9 * scale,
5196 "mean {} vs {reference_mean} at mean={mean} variance={variance} upper={upper}",
5197 moment_mean[0]
5198 );
5199 assert!(
5200 (moment_variance[[0, 0]] - reference_variance).abs() < 1e-9 * variance,
5201 "variance {} vs {reference_variance} at mean={mean} variance={variance} \
5202 upper={upper}",
5203 moment_variance[[0, 0]]
5204 );
5205 assert!(
5206 moment_mean[0] > 0.0 && moment_mean[0] < upper,
5207 "the mean of a law on [0, {upper}] must lie inside it, got {}",
5208 moment_mean[0]
5209 );
5210 }
5211 }
5212
5213 #[test]
5218 fn the_box_cubature_reproduces_a_product_law_it_cannot_shortcut() {
5219 let mean = array![0.4, -1.2, 0.9];
5220 let covariance = Array2::from_diag(&array![1.0, 0.5, 2.0]);
5221 let upper = vec![1.5, 0.8, f64::INFINITY];
5222 let (cubature_mean, cubature_covariance) =
5223 box_truncated_moments(&mean, &upper, &covariance).expect("box moments");
5224 for i in 0..mean.len() {
5225 let (reference_mean, reference_variance) =
5226 scalar_truncated_moments(mean[i], covariance[[i, i]], upper[i])
5227 .expect("marginal closed form");
5228 let sd = covariance[[i, i]].sqrt();
5229 assert!(
5230 (cubature_mean[i] - reference_mean[0]).abs()
5231 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * sd,
5232 "coordinate {i} mean {} vs {}",
5233 cubature_mean[i],
5234 reference_mean[0]
5235 );
5236 assert!(
5237 (cubature_covariance[[i, i]] - reference_variance[[0, 0]]).abs()
5238 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * covariance[[i, i]],
5239 "coordinate {i} variance {} vs {}",
5240 cubature_covariance[[i, i]],
5241 reference_variance[[0, 0]]
5242 );
5243 }
5244 for i in 0..mean.len() {
5248 for j in 0..mean.len() {
5249 if i == j {
5250 continue;
5251 }
5252 let sd = (covariance[[i, i]] * covariance[[j, j]]).sqrt();
5253 assert!(
5254 cubature_covariance[[i, j]].abs() < ORTHANT_MOMENT_RELATIVE_TOLERANCE * sd,
5255 "a product law truncated to a box stays a product law: entry ({i},{j}) \
5256 is {}",
5257 cubature_covariance[[i, j]]
5258 );
5259 }
5260 }
5261 }
5262
5263 #[test]
5280 fn a_slab_twelve_deviations_below_the_mean_keeps_its_mass() {
5281 let depth = 12.0_f64;
5282 let mean = array![depth, depth];
5283 let covariance = Array2::<f64>::eye(2);
5284 let upper = vec![2.0, 2.0];
5285 let (cubature_mean, cubature_covariance) = box_truncated_moments(&mean, &upper, &covariance)
5286 .expect("a slab deep in a tail still carries mass");
5287 let (reference_mean, reference_variance) = quadrature_box_moments(depth, 1.0, 2.0);
5288 assert!(
5292 reference_mean > 1.85 && reference_mean < 2.0,
5293 "the fixture must place the mean near the far wall, got {reference_mean}"
5294 );
5295 for i in 0..2 {
5296 assert!(
5297 (cubature_mean[i] - reference_mean).abs() < 1e-3,
5298 "coordinate {i} mean {} against the Simpson reference {reference_mean}",
5299 cubature_mean[i]
5300 );
5301 assert!(
5302 (cubature_covariance[[i, i]] - reference_variance).abs() < 1e-3,
5303 "coordinate {i} variance {} against the Simpson reference {reference_variance}",
5304 cubature_covariance[[i, i]]
5305 );
5306 }
5307 }
5308
5309 fn quadrature_box_cdf(mean: f64, variance: f64, upper: f64, x: f64) -> f64 {
5313 let sd = variance.sqrt();
5314 let low = -mean / sd;
5315 let high = (upper - mean) / sd;
5316 let point = (x - mean) / sd;
5317 let reference = if low <= 0.0 && 0.0 <= high {
5318 0.0
5319 } else if high < 0.0 {
5320 high
5321 } else {
5322 low
5323 };
5324 let mass = |from: f64, to: f64| -> f64 {
5325 let panels = 200_000usize;
5326 let step = (to - from) / panels as f64;
5327 let mut total = 0.0f64;
5328 for index in 0..=panels {
5329 let z = from + step * index as f64;
5330 let simpson = if index == 0 || index == panels {
5331 1.0
5332 } else if index % 2 == 1 {
5333 4.0
5334 } else {
5335 2.0
5336 };
5337 total += simpson * (-(z * z - reference * reference) / 2.0).exp();
5338 }
5339 total * step
5340 };
5341 mass(low, point) / mass(low, high)
5342 }
5343
5344 #[test]
5360 fn the_deep_tail_quantile_round_trips_rather_than_collapsing_to_an_endpoint() {
5361 let mean = 12.0_f64;
5362 let variance = 1.0_f64;
5363 let upper = 2.0_f64;
5364 let fractions = [0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99];
5365 let mut previous = 0.0_f64;
5366 for &fraction in &fractions {
5367 let point = scalar_truncated_quantile(mean, variance, upper, fraction)
5368 .expect("a slab deep in a tail still has quantiles");
5369 assert!(
5370 point > 0.0 && point < upper,
5371 "the {fraction} quantile of a law on [0, {upper}] must be interior, got {point}"
5372 );
5373 assert!(
5374 point > previous,
5375 "quantiles must be strictly increasing in the probability; {fraction} gave \
5376 {point} against {previous} for the fraction before it, which is what a \
5377 collapse to one endpoint looks like"
5378 );
5379 previous = point;
5380 let recovered = quadrature_box_cdf(mean, variance, upper, point);
5381 assert!(
5382 (recovered - fraction).abs() < 1e-6,
5383 "round trip at {fraction}: the quantile returned {point}, whose independent \
5384 Simpson CDF is {recovered}"
5385 );
5386 }
5387 let first = scalar_truncated_quantile(mean, variance, upper, 0.01).expect("low");
5403 let last = scalar_truncated_quantile(mean, variance, upper, 0.99).expect("high");
5404 let span = last - first;
5405 let steepest = mean / variance;
5406 let shallowest = (mean - upper) / variance;
5407 let narrowest = 99.0_f64.ln() / steepest;
5408 let widest = 99.0_f64.ln() / shallowest;
5409 assert!(
5410 span >= narrowest && span <= widest,
5411 "the 1%-99% span {span} is outside the [{narrowest}, {widest}] the log-density's \
5412 own slopes allow across this slab"
5413 );
5414 }
5415
5416 #[test]
5420 fn coincident_two_sided_walls_are_refused_not_collapsed() {
5421 let columns = 2;
5422 let covariance = Array2::<f64>::eye(columns);
5423 let centre = array![0.5, 0.0];
5424 let error = constrained_posterior_correction_from_covariance(
5425 &covariance,
5426 ¢re,
5427 &two_sided_bound_rows(0.25, 0.25, columns),
5428 )
5429 .expect_err("an empty slab has no posterior to report");
5430 assert!(
5431 error.contains("no width between them"),
5432 "the refusal must name the geometry, got: {error}"
5433 );
5434 }
5435
5436 #[test]
5440 fn a_two_sided_projection_interval_stays_within_its_own_bounds() {
5441 let columns = 2;
5442 let covariance = Array2::<f64>::eye(columns);
5443 let centre = array![0.6, 0.0];
5444 let constraints = two_sided_bound_rows(0.0, 1.0, columns);
5445 let correction = constrained_posterior_correction_from_covariance(
5446 &covariance,
5447 ¢re,
5448 &constraints,
5449 )
5450 .expect("correction")
5451 .expect("active");
5452 let geometry = ConstrainedPosteriorGeometry {
5453 constraints,
5454 mode: array![0.6, 0.0],
5455 unconstrained_center: Some(centre),
5456 correction: Some(correction),
5457 moment_status: ConstrainedPosteriorMomentStatus::Available,
5458 };
5459 let (low, high) = constrained_projection_equal_tailed_interval(
5460 &covariance,
5461 &geometry,
5462 &array![1.0, 0.0],
5463 0.95,
5464 )
5465 .expect("two-sided projection interval");
5466 assert!(
5467 low >= -1e-9 && high <= 1.0 + 1e-9,
5468 "a coefficient declared to lie in [0, 1] cannot be reported in [{low}, {high}]"
5469 );
5470 assert!(low < high, "the interval must be non-degenerate");
5471 }
5472}
5473
5474#[cfg(test)]
5497mod orthant_tilt_2601_tests {
5498 use super::tests_orthant_rule_support::moment_relative_change;
5499 use super::*;
5500
5501 pub(super) fn refusing_face() -> (Array1<f64>, Array2<f64>) {
5513 let mean = Array1::from_vec(vec![
5514 -1.73263658148929162e-01, -1.61028415044015161e-01, -1.53745491056003519e-01,
5515 -1.14020228922959738e-01, -1.13372022294778579e-01, -5.68386260921473555e-02,
5516 -2.01787006225858517e-02, 7.79317061445884696e-04, 1.73520774327455551e-03,
5517 2.00473386863282976e-02, 4.22790949294645502e-02,
5518 ]);
5519 let w = Array2::from_shape_vec(
5520 (11, 11),
5521 vec![
5522 4.28613165678045412e-03, -6.40620724624387243e-04, -6.68752057617351208e-04,
5523 -5.83957865274831135e-04, -5.55233848052857893e-04, -7.90532734874588739e-04,
5524 -8.09846363272953844e-04, -2.35978352506513071e-04, -4.59953354398140966e-04,
5525 -2.40276816847910670e-04, -4.66860409610777736e-04, -6.40620724624387243e-04,
5526 4.30519348418363125e-03, -5.77958088890881253e-04, -7.08255560103122385e-04,
5527 -6.52329255706248488e-04, -4.23821703180157501e-04, -7.53554384768477959e-04,
5528 -1.29045376459765944e-04, -2.55856263721782311e-04, -3.25781213177120355e-04,
5529 -6.88688636712164021e-04, -6.68752057617351208e-04, -5.77958088890881253e-04,
5530 4.33564940679504254e-03, -6.74857893211149419e-04, -6.22390211789637811e-04,
5531 -7.39567533429216599e-04, -4.59996677084055332e-04, -3.33480282492368946e-04,
5532 -7.09611827680745955e-04, -1.43809374573677527e-04, -2.85910172307334801e-04,
5533 -5.83957865274831135e-04, -7.08255560103122385e-04, -6.74857893211149419e-04,
5534 4.23561983569142528e-03, -3.68170739599590739e-04, -2.42878795544258373e-04,
5535 -6.65817316820568449e-04, -7.99047220448408411e-05, -1.55911037671571136e-04,
5536 -1.76197943321878327e-04, -2.51754204620259080e-04, -5.55233848052857893e-04,
5537 -6.52329255706248488e-04, -6.22390211789637811e-04, -3.68170739599590739e-04,
5538 4.29588162752489629e-03, -7.57152955247313302e-04, -2.80188801979815898e-04,
5539 -2.28980327675770300e-04, -3.66373527157145380e-04, -9.83175770979363879e-05,
5540 -1.94759571987295659e-04, -7.90532734874588739e-04, -4.23821703180157501e-04,
5541 -7.39567533429216599e-04, -2.42878795544258373e-04, -7.57152955247313302e-04,
5542 4.82590971142383366e-03, -2.30789443520484135e-04, 5.11092628516201207e-04,
5543 4.74012818803116673e-04, -1.01494286876908989e-04, -2.04446376075374456e-04,
5544 -8.09846363272953844e-04, -7.53554384768477959e-04, -4.59996677084055332e-04,
5545 -6.65817316820568449e-04, -2.80188801979815898e-04, -2.30789443520484135e-04,
5546 4.97201788205004890e-03, -9.74102815703460092e-05, -1.93235956110176968e-04,
5547 5.67517929096417986e-04, 5.90297796785945318e-04, -2.35978352506513071e-04,
5548 -1.29045376459765944e-04, -3.33480282492368946e-04, -7.99047220448408411e-05,
5549 -2.28980327675770300e-04, 5.11092628516201207e-04, -9.74102815703460092e-05,
5550 1.15689169020833748e-03, 1.48276767553545967e-03, -4.51194193048010442e-05,
5551 -9.11302898497036765e-05, -4.59953354398140966e-04, -2.55856263721782311e-04,
5552 -7.09611827680745955e-04, -1.55911037671571136e-04, -3.66373527157145380e-04,
5553 4.74012818803116673e-04, -1.93235956110176968e-04, 1.48276767553545967e-03,
5554 4.05500634945223787e-03, -8.98171912603273025e-05, -1.81408583551147815e-04,
5555 -2.40276816847910670e-04, -3.25781213177120355e-04, -1.43809374573677527e-04,
5556 -1.76197943321878327e-04, -9.83175770979363879e-05, -1.01494286876908989e-04,
5557 5.67517929096417986e-04, -4.51194193048010442e-05, -8.98171912603273025e-05,
5558 1.17935427809862667e-03, 1.52706063180176217e-03, -4.66860409610777736e-04,
5559 -6.88688636712164021e-04, -2.85910172307334801e-04, -2.51754204620259080e-04,
5560 -1.94759571987295659e-04, -2.04446376075374456e-04, 5.90297796785945318e-04,
5561 -9.11302898497036765e-05, -1.81408583551147815e-04, 1.52706063180176217e-03,
5562 4.14230573743777988e-03,
5563 ],
5564 )
5565 .expect("11x11 captured constraint-normal covariance");
5566 (mean, w)
5567 }
5568
5569 pub(super) fn weight_efficiency(rule: &OrthantRule, nodes: usize) -> (f64, f64) {
5574 struct WeightSpy {
5575 inner: OrthantAccumulator,
5576 log_weights: Vec<f64>,
5577 }
5578 impl OrthantNodeSink for WeightSpy {
5579 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
5580 self.log_weights.push(log_weight);
5581 self.inner.push(log_weight, point);
5582 }
5583 }
5584 let mut spy = WeightSpy {
5585 inner: OrthantAccumulator::new(rule.dimension()),
5586 log_weights: Vec::new(),
5587 };
5588 rule.accumulate(&mut spy, 0, 0, nodes).expect("orthant nodes");
5589 let finite: Vec<f64> = spy
5590 .log_weights
5591 .iter()
5592 .copied()
5593 .filter(|v| v.is_finite())
5594 .collect();
5595 let hi = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
5596 let lo = finite.iter().copied().fold(f64::INFINITY, f64::min);
5597 let sum: f64 = finite.iter().map(|v| (v - hi).exp()).sum();
5598 let sum_sq: f64 = finite.iter().map(|v| (2.0 * (v - hi)).exp()).sum();
5599 (
5600 (sum * sum / sum_sq) / nodes as f64,
5601 (hi - lo) / std::f64::consts::LN_10,
5602 )
5603 }
5604
5605 #[test]
5615 fn the_tilt_turns_a_monte_carlo_draw_back_into_a_cubature_2601() {
5616 let (mean, w) = refusing_face();
5617 let q = mean.len();
5618 let upper = vec![f64::INFINITY; q];
5619
5620 let previous = OrthantRule::in_given_order_untilted(&mean, &upper, &w).expect("factor");
5621 let (untilted_ess, untilted_decades) = weight_efficiency(&previous, 1 << 16);
5622 let rule = OrthantRule::new(&mean, &upper, &w, 0).expect("rule");
5623 assert!(
5624 matches!(rule.tilt_status, TiltStatus::Converged { .. }),
5625 "this face is tilted: {}",
5626 rule.tilt_status
5627 );
5628 let (tilted_ess, tilted_decades) = weight_efficiency(&rule, 1 << 16);
5629
5630 println!(
5631 "MEASURE2601 caller-order untilted ess={:.4}% over {:.1} decades; \
5632 ordered tilted ess={:.4}% over {:.1} decades ({})",
5633 100.0 * untilted_ess,
5634 untilted_decades,
5635 100.0 * tilted_ess,
5636 tilted_decades,
5637 rule.tilt_status
5638 );
5639 assert!(
5640 untilted_ess < 0.05,
5641 "precondition: the untilted proposal wastes the node budget on this \
5642 face (ess {:.4}% of nodes)",
5643 100.0 * untilted_ess
5644 );
5645 assert!(
5646 tilted_ess > 0.5,
5647 "the tilt must make most nodes count; got ess {:.4}% of nodes over \
5648 {tilted_decades:.1} decades of weight",
5649 100.0 * tilted_ess
5650 );
5651 assert!(
5652 tilted_decades < 5.0,
5653 "the tilted weights must be nearly flat; got {tilted_decades:.1} decades"
5654 );
5655 }
5656
5657 #[test]
5667 fn the_face_that_refuses_2601_produces_the_right_moments() {
5668 let (mean, w) = refusing_face();
5669 let q = mean.len();
5670 let upper = vec![f64::INFINITY; q];
5671
5672 let sd: Vec<f64> = (0..q).map(|i| f64::sqrt(w[[i, i]])).collect();
5675 let depth: Vec<f64> = (0..q).map(|i| -mean[i] / sd[i]).collect();
5676 let depth_min = depth.iter().copied().fold(f64::INFINITY, f64::min);
5677 let depth_max = depth.iter().copied().fold(f64::NEG_INFINITY, f64::max);
5678 let mut corr_max = 0.0f64;
5679 for i in 0..q {
5680 for j in 0..i {
5681 corr_max = corr_max.max(f64::abs(w[[i, j]] / (sd[i] * sd[j])));
5682 }
5683 }
5684 assert!(
5685 depth_max < 3.0 && depth_min < 0.0,
5686 "the refusing face is MILD in depth ({depth_min:.2}..{depth_max:.2} sd), \
5687 which is what rules depth out as the cause"
5688 );
5689 assert!(
5690 corr_max > 0.6,
5691 "the refusing face is strongly correlated (max |corr| = {corr_max:.3}), \
5692 which is the regime that fails"
5693 );
5694
5695 let (produced_mean, produced_cov) =
5696 box_truncated_moments(&mean, &upper, &w).expect("the face #2601 reports must produce moments");
5697
5698 let previous = OrthantRule::in_given_order_untilted(&mean, &upper, &w).expect("factor");
5699 let mut reference = OrthantAccumulator::new(q);
5700 previous
5701 .accumulate(&mut reference, 0, 0, 1 << 20)
5702 .expect("untilted reference nodes");
5703 let truth = reference.moments().expect("reference moments");
5704 let gap = moment_relative_change(&(produced_mean, produced_cov), &truth, &w);
5705 println!("MEASURE2601 gap vs caller-order untilted 2^20 reference: {gap:.3e}");
5706 assert!(
5707 gap < 3.0e-2,
5708 "the certified rule must agree with an INDEPENDENT untilted reference; \
5709 gap {gap:.3e} (the reference's own error at 2^20 is ~1.3e-2)"
5710 );
5711 }
5712
5713 #[test]
5723 fn the_tilt_resolves_every_correlated_face_the_sweep_could_not() {
5724 for &q in &[4usize, 8, 11] {
5725 for &c in &[0.5_f64, 1.0, 2.0, 4.0] {
5726 for &corr in &[0.0_f64, 0.6, 0.9] {
5727 let mut w = Array2::<f64>::zeros((q, q));
5728 for i in 0..q {
5729 for j in 0..q {
5730 w[[i, j]] = corr.powi((i as i32 - j as i32).abs());
5731 }
5732 }
5733 let mean = Array1::<f64>::from_elem(q, -c);
5734 let upper = vec![f64::INFINITY; q];
5735 let previous =
5736 OrthantRule::in_given_order_untilted(&mean, &upper, &w).expect("factor");
5737 let (ess, decades) = weight_efficiency(&previous, 1 << 14);
5738 let rule = OrthantRule::new(&mean, &upper, &w, 0).expect("rule");
5739 let (tilted_ess, tilted_decades) = weight_efficiency(&rule, 1 << 14);
5740 let outcome = box_truncated_moments(&mean, &upper, &w);
5741 println!(
5742 "MEASURE2601 q={q} depth={c} corr={corr} \
5743 ess {:.2}%->{:.2}% decades {decades:.1}->{tilted_decades:.1} {} ({})",
5744 100.0 * ess,
5745 100.0 * tilted_ess,
5746 if outcome.is_ok() { "converged" } else { "REFUSED" },
5747 rule.tilt_status
5748 );
5749 assert!(
5750 outcome.is_ok(),
5751 "q={q} depth={c} corr={corr} must converge: {:?}",
5752 outcome.err()
5753 );
5754 assert!(
5755 tilted_ess >= ess * 0.9,
5756 "the tilt must never make a face WORSE: q={q} depth={c} \
5757 corr={corr} ess {:.4}% -> {:.4}%",
5758 100.0 * ess,
5759 100.0 * tilted_ess
5760 );
5761 }
5762 }
5763 }
5764 }
5765}
5766
5767#[cfg(test)]
5768mod coverage_gate_tests {
5769 use super::*;
5770 use gam_linalg::triangular::{CholeskyGuard, cholesky_factor_in_place, cholesky_solve_vector};
5771
5772 struct SplitMix64 {
5775 state: u64,
5776 }
5777
5778 impl SplitMix64 {
5779 fn new(seed: u64) -> Self {
5780 Self { state: seed }
5781 }
5782 fn next_u64(&mut self) -> u64 {
5783 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
5784 let mut z = self.state;
5785 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
5786 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
5787 z ^ (z >> 31)
5788 }
5789 fn unit(&mut self) -> f64 {
5790 ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
5791 }
5792 fn normal(&mut self) -> f64 {
5793 let (u1, u2) = (self.unit().max(1.0e-12), self.unit());
5794 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
5795 }
5796 }
5797
5798 struct CoverageTally {
5800 covered: usize,
5801 replicates: usize,
5802 total_half_width: f64,
5803 }
5804
5805 impl CoverageTally {
5806 fn new() -> Self {
5807 Self {
5808 covered: 0,
5809 replicates: 0,
5810 total_half_width: 0.0,
5811 }
5812 }
5813 fn record(&mut self, center: f64, half_width: f64, truth: f64) {
5814 self.replicates += 1;
5815 self.total_half_width += half_width;
5816 if (truth - center).abs() <= half_width {
5817 self.covered += 1;
5818 }
5819 }
5820 fn coverage(&self) -> f64 {
5821 self.covered as f64 / self.replicates as f64
5822 }
5823 fn mean_half_width(&self) -> f64 {
5824 self.total_half_width / self.replicates as f64
5825 }
5826 }
5827
5828 struct CellResult {
5830 full_space: CoverageTally,
5831 active_face: CoverageTally,
5832 truncated: CoverageTally,
5833 truncated_mean_centred: CoverageTally,
5834 pinned_fraction: f64,
5835 }
5836
5837 const NOMINAL_HALF_WIDTH_MULTIPLIER: f64 = 1.959_963_984_540_054;
5839 const NOMINAL_COVERAGE: f64 = 0.95;
5840
5841 fn gaussian_posterior_covariance(gram: &Array2<f64>, noise_variance: f64) -> Array2<f64> {
5843 let p = gram.nrows();
5844 let factor = cholesky_factor_in_place(gram.view(), CholeskyGuard::FiniteStrict)
5845 .expect("simulation design is full rank");
5846 let mut covariance = Array2::<f64>::zeros((p, p));
5847 for j in 0..p {
5848 let mut unit = Array1::<f64>::zeros(p);
5849 unit[j] = 1.0;
5850 let column = cholesky_solve_vector(&factor, &unit);
5851 for i in 0..p {
5852 covariance[[i, j]] = noise_variance * column[i];
5853 }
5854 }
5855 covariance
5856 }
5857
5858 fn tight_rows_at(constraints: &LinearInequalityConstraints, beta: &Array1<f64>) -> Vec<usize> {
5861 let mut tight = Vec::new();
5862 for row_index in 0..constraints.a.nrows() {
5863 let row = constraints.a.row(row_index).to_owned();
5864 let norm = row.dot(&row).sqrt();
5865 if norm > 0.0
5866 && (row.dot(beta) - constraints.b[row_index]) / norm
5867 <= crate::active_set::ACTIVE_SET_WORKING_FACE_TOL
5868 {
5869 tight.push(row_index);
5870 }
5871 }
5872 tight
5873 }
5874
5875 fn active_face_variance(
5880 covariance: &Array2<f64>,
5881 constraints: &LinearInequalityConstraints,
5882 tight: &[usize],
5883 index: usize,
5884 ) -> f64 {
5885 if tight.is_empty() {
5886 return covariance[[index, index]];
5887 }
5888 let q = tight.len();
5889 let mut sigma_at = Array2::<f64>::zeros((covariance.nrows(), q));
5890 for (position, &row_index) in tight.iter().enumerate() {
5891 let column = covariance.dot(&constraints.a.row(row_index).to_owned());
5892 sigma_at.column_mut(position).assign(&column);
5893 }
5894 let mut normal = Array2::<f64>::zeros((q, q));
5895 for (i, &row_i) in tight.iter().enumerate() {
5896 for j in 0..q {
5897 normal[[i, j]] = constraints
5898 .a
5899 .row(row_i)
5900 .to_owned()
5901 .dot(&sigma_at.column(j).to_owned());
5902 }
5903 }
5904 let Some(factor) = cholesky_factor_in_place(normal.view(), CholeskyGuard::FiniteStrict)
5905 else {
5906 return 0.0;
5909 };
5910 let row = sigma_at.row(index).to_owned();
5911 let solved = cholesky_solve_vector(&factor, &row);
5912 covariance[[index, index]] - row.dot(&solved)
5913 }
5914
5915 fn run_cell(
5920 design: &Array2<f64>,
5921 truth: &Array1<f64>,
5922 constraints: &LinearInequalityConstraints,
5923 reported_index: usize,
5924 noise_sd: f64,
5925 replicates: usize,
5926 seed: u64,
5927 ) -> CellResult {
5928 let n = design.nrows();
5929 let p = design.ncols();
5930 let gram = design.t().dot(design);
5931 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
5932 let mut rng = SplitMix64::new(seed);
5933 let mut result = CellResult {
5934 full_space: CoverageTally::new(),
5935 active_face: CoverageTally::new(),
5936 truncated: CoverageTally::new(),
5937 truncated_mean_centred: CoverageTally::new(),
5938 pinned_fraction: 0.0,
5939 };
5940 let mean_response = design.dot(truth);
5941 let mut pinned = 0usize;
5942
5943 for _ in 0..replicates {
5944 let mut response = Array1::<f64>::zeros(n);
5945 for i in 0..n {
5946 response[i] = mean_response[i] + noise_sd * rng.normal();
5947 }
5948 let rhs = design.t().dot(&response);
5949 let start = crate::active_set::feasible_point_for_linear_constraints(constraints, p)
5950 .expect("the simulation cone has an interior");
5951 let (beta_hat, _) = crate::active_set::solve_quadratic_with_linear_constraints(
5952 &gram,
5953 &rhs,
5954 &start,
5955 constraints,
5956 None,
5957 )
5958 .expect("constrained quadratic solve");
5959
5960 let full_half_width =
5961 NOMINAL_HALF_WIDTH_MULTIPLIER * covariance[[reported_index, reported_index]].sqrt();
5962 result.full_space.record(
5963 beta_hat[reported_index],
5964 full_half_width,
5965 truth[reported_index],
5966 );
5967
5968 let tight = tight_rows_at(constraints, &beta_hat);
5969 if !tight.is_empty() {
5970 pinned += 1;
5971 }
5972 let face_variance =
5973 active_face_variance(&covariance, constraints, &tight, reported_index);
5974 result.active_face.record(
5975 beta_hat[reported_index],
5976 NOMINAL_HALF_WIDTH_MULTIPLIER * face_variance.max(0.0).sqrt(),
5977 truth[reported_index],
5978 );
5979
5980 let penalized_gradient = gram.dot(&beta_hat) - &rhs;
5984 let center = &beta_hat
5985 - &(covariance.dot(&penalized_gradient) / (noise_sd * noise_sd));
5986 let correction =
5987 constrained_posterior_correction_from_covariance(&covariance, ¢er, constraints)
5988 .expect("truncated correction");
5989 let (truncated_half_width, truncated_center) = match correction {
5990 None => (full_half_width, beta_hat[reported_index]),
5991 Some(ref correction) => {
5992 let variance = covariance[[reported_index, reported_index]]
5993 - correction.removed_variance_diagonal()[reported_index];
5994 (
5995 NOMINAL_HALF_WIDTH_MULTIPLIER * variance.max(0.0).sqrt(),
5996 correction.posterior_mean(¢er)[reported_index],
5997 )
5998 }
5999 };
6000 result.truncated.record(
6001 beta_hat[reported_index],
6002 truncated_half_width,
6003 truth[reported_index],
6004 );
6005 result.truncated_mean_centred.record(
6006 truncated_center,
6007 truncated_half_width,
6008 truth[reported_index],
6009 );
6010 }
6011 result.pinned_fraction = pinned as f64 / replicates as f64;
6012 result
6013 }
6014
6015 fn report_cell(label: &str, cell: &CellResult) {
6016 eprintln!(
6017 "[#2417 coverage] {label}: nominal {NOMINAL_COVERAGE:.2}, {} replicates, mode pinned \
6018 in {:.1}% of them",
6019 cell.full_space.replicates,
6020 100.0 * cell.pinned_fraction
6021 );
6022 for (name, tally) in [
6023 ("full space ", &cell.full_space),
6024 ("active face ", &cell.active_face),
6025 ("truncated ", &cell.truncated),
6026 ("truncated+mean shift", &cell.truncated_mean_centred),
6027 ] {
6028 eprintln!(
6029 "[#2417 coverage] {name} coverage {:.4} mean half-width {:.5}",
6030 tally.coverage(),
6031 tally.mean_half_width()
6032 );
6033 }
6034 }
6035
6036 #[test]
6041 fn box_bound_at_half_a_standard_error_separates_the_three_covariances() {
6042 let n = 60;
6043 let mut rng = SplitMix64::new(20_417);
6044 let mut design = Array2::<f64>::zeros((n, 2));
6045 for i in 0..n {
6046 design[[i, 0]] = 1.0;
6047 design[[i, 1]] = rng.normal();
6048 }
6049 let gram = design.t().dot(&design);
6050 let noise_sd = 1.0;
6051 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
6052 let standard_error = covariance[[1, 1]].sqrt();
6053 let truth = Array1::from_vec(vec![0.3, 0.5 * standard_error]);
6054 let constraints =
6055 LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
6056 .expect("nonnegativity bound");
6057
6058 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 91_137);
6059 report_cell("box bound, truth 0.5 se", &cell);
6060
6061 assert!(
6064 cell.pinned_fraction > 0.2,
6065 "the cell must actually exercise the boundary, pinned fraction {:.3}",
6066 cell.pinned_fraction
6067 );
6068 assert!(
6069 cell.active_face.coverage() < 0.80,
6070 "the active-face covariance must under-cover catastrophically here — it reports a \
6071 zero-width interval whenever the mode pins — but coverage was {:.4}",
6072 cell.active_face.coverage()
6073 );
6074 assert!(
6075 cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.01,
6076 "the truncated covariance must reach nominal coverage, got {:.4}",
6077 cell.truncated.coverage()
6078 );
6079 assert!(
6080 cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
6081 "and it must still reach it once the centre moves to the truncated posterior \
6082 mean, got {:.4}",
6083 cell.truncated_mean_centred.coverage()
6084 );
6085 assert!(
6086 cell.truncated.mean_half_width() < 0.85 * cell.full_space.mean_half_width(),
6087 "the truncated covariance must buy its coverage with materially SHORTER intervals \
6088 than the full-space answer: {:.5} vs {:.5}",
6089 cell.truncated.mean_half_width(),
6090 cell.full_space.mean_half_width()
6091 );
6092 assert!(
6093 cell.full_space.coverage() >= NOMINAL_COVERAGE,
6094 "the full-space covariance over-covers by construction, got {:.4}",
6095 cell.full_space.coverage()
6096 );
6097 }
6098
6099 #[test]
6113 fn narrowing_the_covariance_without_moving_the_mean_is_a_regression() {
6114 let n = 60;
6115 let mut rng = SplitMix64::new(31_417);
6116 let mut design = Array2::<f64>::zeros((n, 2));
6117 for i in 0..n {
6118 design[[i, 0]] = 1.0;
6119 design[[i, 1]] = rng.normal();
6120 }
6121 let gram = design.t().dot(&design);
6122 let noise_sd = 1.0;
6123 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
6124 let standard_error = covariance[[1, 1]].sqrt();
6125 let truth = Array1::from_vec(vec![-0.2, 1.5 * standard_error]);
6126 let constraints =
6127 LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
6128 .expect("nonnegativity bound");
6129
6130 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 47_903);
6131 report_cell("box bound, truth 1.5 se", &cell);
6132
6133 assert!(
6134 cell.truncated.coverage() < NOMINAL_COVERAGE - 0.02,
6135 "this cell exists BECAUSE the mode-centred truncated interval under-covers here; \
6136 if it stopped doing so the counterexample would no longer be testing anything, \
6137 got {:.4}",
6138 cell.truncated.coverage()
6139 );
6140 assert!(
6141 cell.truncated.coverage() < cell.active_face.coverage(),
6142 "the point of the cell: narrowing the covariance while leaving the interval \
6143 centred on the mode is worse than the active-face answer it replaces, {:.4} vs \
6144 {:.4}",
6145 cell.truncated.coverage(),
6146 cell.active_face.coverage()
6147 );
6148 assert!(
6149 cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
6150 "moving the centre to the truncated posterior mean recovers nominal coverage with \
6151 the same covariance, got {:.4}",
6152 cell.truncated_mean_centred.coverage()
6153 );
6154 assert!(
6155 cell.truncated_mean_centred.mean_half_width() < cell.full_space.mean_half_width(),
6156 "and it does so with shorter intervals than the full-space answer: {:.5} vs {:.5}",
6157 cell.truncated_mean_centred.mean_half_width(),
6158 cell.full_space.mean_half_width()
6159 );
6160 }
6161
6162 #[test]
6165 fn two_coupled_bounds_exercise_the_orthant_cubature() {
6166 let n = 80;
6167 let mut rng = SplitMix64::new(74_211);
6168 let mut design = Array2::<f64>::zeros((n, 3));
6169 for i in 0..n {
6170 design[[i, 0]] = 1.0;
6171 let shared = rng.normal();
6172 design[[i, 1]] = shared;
6173 design[[i, 2]] = 0.7 * shared + 0.7 * rng.normal();
6176 }
6177 let gram = design.t().dot(&design);
6178 let noise_sd = 1.0;
6179 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
6180 let truth = Array1::from_vec(vec![
6181 0.25,
6182 0.5 * covariance[[1, 1]].sqrt(),
6183 0.5 * covariance[[2, 2]].sqrt(),
6184 ]);
6185 let constraints = LinearInequalityConstraints::new(
6186 ndarray::array![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
6187 ndarray::array![0.0, 0.0],
6188 )
6189 .expect("two nonnegativity bounds");
6190
6191 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 600, 55_301);
6192 report_cell("two coupled bounds, truth 0.5 se", &cell);
6193
6194 assert!(
6195 cell.active_face.coverage() < 0.85,
6196 "the active-face covariance must under-cover here too, got {:.4}",
6197 cell.active_face.coverage()
6198 );
6199 assert!(
6200 cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.03,
6201 "the truncated covariance must reach nominal coverage through the orthant \
6202 cubature, got {:.4}",
6203 cell.truncated.coverage()
6204 );
6205 assert!(
6206 cell.truncated.mean_half_width() < cell.full_space.mean_half_width(),
6207 "shorter intervals at nominal coverage: {:.5} vs {:.5}",
6208 cell.truncated.mean_half_width(),
6209 cell.full_space.mean_half_width()
6210 );
6211 }
6212
6213}
6214
6215#[cfg(test)]
6216mod affine_ceiling_tests {
6217 use super::*;
6218 use ndarray::array;
6219
6220 fn log_mass(
6227 mean: &Array1<f64>,
6228 sd: &Array1<f64>,
6229 wall: Option<(&Array1<f64>, f64)>,
6230 upper: &[f64],
6231 nodes: usize,
6232 ) -> Option<f64> {
6233 let q = mean.len();
6234 let mut covariance = Array2::<f64>::zeros((q, q));
6235 for i in 0..q {
6236 covariance[[i, i]] = sd[i] * sd[i];
6237 }
6238 let mut rule =
6239 OrthantRule::in_given_order_untilted(mean, upper, &covariance).expect("rule");
6240 if let Some((normal, bound)) = wall {
6241 rule = rule.with_affine_ceiling(normal, bound).expect("ceiling");
6242 }
6243 let mut accumulator = OrthantAccumulator::new(q);
6244 rule.accumulate(&mut accumulator, 0, 0, nodes).expect("cubature");
6245 if !(accumulator.weight_sum.is_finite() && accumulator.weight_sum > 0.0) {
6249 return None;
6250 }
6251 Some(accumulator.log_scale + accumulator.weight_sum.ln() - (nodes as f64).ln())
6252 }
6253
6254 fn diagonal_factor(sd: &Array1<f64>) -> Array2<f64> {
6255 let q = sd.len();
6256 let mut factor = Array2::<f64>::zeros((q, q));
6257 for i in 0..q {
6258 factor[[i, i]] = sd[i];
6259 }
6260 factor
6261 }
6262
6263 #[test]
6264 fn a_coordinate_normal_reproduces_the_box_it_is_the_degenerate_case_of() {
6265 let mean = array![0.35, -0.20];
6271 let sd = array![1.0, 0.8];
6272 let factor = diagonal_factor(&sd);
6273 let width = 1.4;
6274
6275 let boxed = log_mass(&mean, &sd, None, &[width, f64::INFINITY], 1 << 14)
6276 .expect("box mass");
6277 let normal = array![1.0, 0.0];
6278 let wall = StandardizedCeiling::new(&normal, width, &mean, factor.view())
6279 .expect("coordinate wall");
6280 let affine = log_mass(
6281 &mean,
6282 &sd,
6283 Some((&normal, width)),
6284 &[f64::INFINITY, f64::INFINITY],
6285 1 << 14,
6286 )
6287 .expect("affine mass");
6288 assert_eq!(wall.pivot, 0, "a normal touching only coordinate 0 pivots there");
6289 assert!(
6290 (boxed - affine).abs() < 1e-12,
6291 "box {boxed:.15} and affine {affine:.15} describe the same region"
6292 );
6293 }
6294
6295 #[test]
6296 fn an_affine_ceiling_removes_the_mass_it_should() {
6297 let mean = array![0.4, -0.3];
6302 let sd = array![0.9, 1.1];
6303 let factor = diagonal_factor(&sd);
6304 let bound = 1.6;
6305 let normal = array![1.0, 1.0];
6306 let wall = StandardizedCeiling::new(&normal, bound, &mean, factor.view())
6307 .expect("sum wall");
6308 assert_eq!(wall.pivot, 1, "a wall touching both coordinates pivots on the last");
6309
6310 let got = log_mass(
6311 &mean,
6312 &sd,
6313 Some((&normal, bound)),
6314 &[f64::INFINITY, f64::INFINITY],
6315 1 << 16,
6316 )
6317 .expect("triangle mass");
6318
6319 let panels = 4000usize;
6321 let step = bound / panels as f64;
6322 let density = |x: f64| {
6323 let z = (x - mean[0]) / sd[0];
6324 (-0.5 * z * z).exp() / (sd[0] * (2.0 * std::f64::consts::PI).sqrt())
6325 };
6326 let inner = |x: f64| {
6327 let hi = (bound - x - mean[1]) / sd[1];
6328 let lo = -mean[1] / sd[1];
6329 if hi <= lo {
6330 0.0
6331 } else {
6332 normal_cdf(hi) - normal_cdf(lo)
6333 }
6334 };
6335 let integrand = |x: f64| density(x) * inner(x);
6336 let mut total = integrand(0.0) + integrand(bound);
6337 for k in 1..panels {
6338 let x = k as f64 * step;
6339 total += integrand(x) * if k % 2 == 0 { 2.0 } else { 4.0 };
6340 }
6341 let reference = (total * step / 3.0).ln();
6342 assert!(
6343 (got - reference).abs() < 5e-4,
6344 "cubature {got:.12} against the Simpson reference {reference:.12}"
6345 );
6346
6347 let unbounded = log_mass(
6351 &mean,
6352 &sd,
6353 None,
6354 &[f64::INFINITY, f64::INFINITY],
6355 1 << 16,
6356 )
6357 .expect("unbounded mass");
6358 assert!(
6359 unbounded > got + 0.05,
6360 "the wall removed {:.4} nats, which is not enough to call it active",
6361 unbounded - got
6362 );
6363 }
6364
6365 #[test]
6366 fn a_wall_that_crosses_the_orthant_leaves_no_mass() {
6367 let mean = array![0.2, 0.1];
6371 let sd = array![1.0, 1.0];
6372 let normal = array![1.0, 1.0];
6373 assert!(
6374 log_mass(&mean, &sd, Some((&normal, -1.0)), &[f64::INFINITY, f64::INFINITY], 1 << 10)
6375 .is_none(),
6376 "an empty region reports no mass"
6377 );
6378 }
6379
6380 #[test]
6381 fn a_vanishing_normal_is_refused_rather_than_pivoted_arbitrarily() {
6382 let mean = array![0.0, 0.0];
6383 let factor = diagonal_factor(&array![1.0, 1.0]);
6384 let message = StandardizedCeiling::new(&array![0.0, 0.0], 1.0, &mean, factor.view())
6385 .expect_err("a zero normal constrains nothing");
6386 assert!(
6387 message.contains("vanished"),
6388 "the refusal must say the normal vanished, got: {message}"
6389 );
6390 let mismatched = StandardizedCeiling::new(&array![1.0], 1.0, &mean, factor.view())
6391 .expect_err("a normal of the wrong length is refused");
6392 assert!(mismatched.contains("length"), "got: {mismatched}");
6393 }
6394
6395 #[test]
6396 fn the_pivot_follows_the_factor_not_just_the_normal() {
6397 let mean = array![0.0, 0.0, 0.0];
6402 let mut factor = Array2::<f64>::zeros((3, 3));
6403 factor[[0, 0]] = 1.0;
6404 factor[[1, 0]] = 0.7;
6405 factor[[1, 1]] = 1.0;
6406 factor[[2, 0]] = 0.3;
6407 factor[[2, 1]] = 0.4;
6408 factor[[2, 2]] = 1.0;
6409 let wall = StandardizedCeiling::new(&array![0.0, 0.0, 1.0], 2.0, &mean, factor.view())
6410 .expect("last-coordinate normal");
6411 assert_eq!(wall.pivot, 2);
6412 let early = StandardizedCeiling::new(&array![1.0, 0.0, 0.0], 2.0, &mean, factor.view())
6413 .expect("first-coordinate normal");
6414 assert_eq!(
6415 early.pivot, 0,
6416 "a normal on coordinate 0 cannot reach a later coordinate through a lower-triangular factor"
6417 );
6418 }
6419}
6420
6421#[cfg(test)]
6422mod projection_law_2446_tests {
6423 use super::*;
6424 use ndarray::array;
6425
6426 fn integrand(w: f64) -> f64 {
6431 1.0 / (1.0 + (-0.5 + w).exp())
6432 }
6433
6434 fn simpson<F: Fn(f64) -> f64>(lower: f64, upper: f64, points: usize, f: F) -> f64 {
6436 assert!(points % 2 == 1, "Simpson needs an odd point count");
6437 let h = (upper - lower) / ((points - 1) as f64);
6438 let mut total = 0.0;
6439 for index in 0..points {
6440 let weight = if index == 0 || index == points - 1 {
6441 1.0
6442 } else if index % 2 == 1 {
6443 4.0
6444 } else {
6445 2.0
6446 };
6447 total += weight * f(lower + h * (index as f64));
6448 }
6449 total * h / 3.0
6450 }
6451
6452 fn exact_orthant_expectation_of<F: Fn(f64) -> f64>(
6454 center: &Array1<f64>,
6455 covariance: &Array2<f64>,
6456 contrast: &Array1<f64>,
6457 functional: F,
6458 ) -> f64 {
6459 let det = covariance[[0, 0]] * covariance[[1, 1]] - covariance[[0, 1]] * covariance[[1, 0]];
6460 let inverse = array![
6461 [covariance[[1, 1]] / det, -covariance[[0, 1]] / det],
6462 [-covariance[[1, 0]] / det, covariance[[0, 0]] / det]
6463 ];
6464 let density = |b0: f64, b1: f64| -> f64 {
6465 let d0 = b0 - center[0];
6466 let d1 = b1 - center[1];
6467 let quadratic = inverse[[0, 0]] * d0 * d0
6468 + 2.0 * inverse[[0, 1]] * d0 * d1
6469 + inverse[[1, 1]] * d1 * d1;
6470 (-0.5 * quadratic).exp()
6471 };
6472 let upper0 = center[0].max(0.0) + 12.0 * covariance[[0, 0]].sqrt();
6476 let upper1 = center[1].max(0.0) + 12.0 * covariance[[1, 1]].sqrt();
6477 let points = 2001;
6478 let mass = simpson(0.0, upper0, points, |b0| {
6479 simpson(0.0, upper1, points, |b1| density(b0, b1))
6480 });
6481 let weighted = simpson(0.0, upper0, points, |b0| {
6482 simpson(0.0, upper1, points, |b1| {
6483 density(b0, b1) * functional(contrast[0] * b0 + contrast[1] * b1)
6484 })
6485 });
6486 weighted / mass
6487 }
6488
6489 fn normal_expectation(mean: f64, variance: f64) -> f64 {
6493 let sd = variance.sqrt();
6494 let points = 4001;
6495 simpson(mean - 12.0 * sd, mean + 12.0 * sd, points, |w| {
6496 let z = (w - mean) / sd;
6497 (-0.5 * z * z).exp() * integrand(w)
6498 }) / (sd * (2.0 * std::f64::consts::PI).sqrt())
6499 }
6500
6501 #[test]
6526 fn joint_cubature_carries_the_tangent_block_at_a_bounded_point_count_2679() {
6527 let ambient = array![[0.40, 0.24], [0.24, 0.36]];
6528 let center = array![0.05, -0.10];
6529 let contrast = array![0.70, 0.30];
6530 let tangent_sd = 0.6_f64;
6533 let constraints =
6534 LinearInequalityConstraints::new(array![[1.0, 0.0], [0.0, 1.0]], array![0.0, 0.0])
6535 .expect("build the two-row non-negativity cone");
6536 let correction =
6537 constrained_posterior_correction_from_covariance(&ambient, ¢er, &constraints)
6538 .expect("the correction is computable on this face")
6539 .expect("a centre straddling both walls must retain the face");
6540 let mut retained = correction.rows.clone();
6541 retained.sort_unstable();
6542 assert_eq!(
6543 retained,
6544 vec![0, 1],
6545 "the fixture must retain BOTH rows or the pushforward is the closed-form case"
6546 );
6547
6548 let upper_limits = correction.upper_limits();
6551 let normal_center = Array1::from_vec(
6552 correction
6553 .rows
6554 .iter()
6555 .map(|&row| center[row])
6556 .collect::<Vec<_>>(),
6557 );
6558 let normal_covariance = {
6559 let mut out = Array2::<f64>::zeros((2, 2));
6560 for (i, &row_i) in correction.rows.iter().enumerate() {
6561 for (j, &row_j) in correction.rows.iter().enumerate() {
6562 out[[i, j]] = ambient[[row_i, row_j]];
6563 }
6564 }
6565 out
6566 };
6567 let lift_contrast = Array1::from_vec(
6568 correction
6569 .rows
6570 .iter()
6571 .map(|&row| contrast[row])
6572 .collect::<Vec<_>>(),
6573 );
6574
6575 const POINTS: usize = 1 << 13;
6576 let joint = constrained_posterior_joint_cubature(
6577 &normal_center,
6578 &normal_covariance,
6579 &upper_limits,
6580 1,
6581 POINTS,
6582 )
6583 .expect("joint cubature on a retained two-row face");
6584 assert_eq!(
6585 joint.len(),
6586 POINTS,
6587 "the joint rule's cost is the point count it was asked for and nothing else"
6588 );
6589
6590 let infeasible_points = joint
6593 .iter()
6594 .filter(|point| point.normal_coordinates.iter().any(|&value| value < 0.0))
6595 .count();
6596 assert_eq!(
6597 infeasible_points, 0,
6598 "every joint point must lie in the retained cone; {infeasible_points} of {POINTS} did \
6599 not"
6600 );
6601
6602 let weight_sum = joint.iter().map(|point| point.weight).sum::<f64>();
6603 assert!(
6604 (weight_sum - 1.0).abs() < 1e-9,
6605 "joint weights must be normalized, got {weight_sum:.12e}"
6606 );
6607
6608 let tangent_mean = joint
6610 .iter()
6611 .map(|point| point.weight * point.tangent[0])
6612 .sum::<f64>();
6613 let tangent_second = joint
6614 .iter()
6615 .map(|point| point.weight * point.tangent[0] * point.tangent[0])
6616 .sum::<f64>();
6617 eprintln!(
6618 "[2679] points={POINTS} tangent_mean={tangent_mean:.6e} \
6619 tangent_second={tangent_second:.6e}"
6620 );
6621 assert!(
6622 tangent_mean.abs() < 2.0e-2,
6623 "the tangent block must integrate to a zero mean under the SOV weights, got \
6624 {tangent_mean:.6e}"
6625 );
6626 assert!(
6627 (tangent_second - 1.0).abs() < 5.0e-2,
6628 "the tangent block must integrate to unit variance under the SOV weights, got \
6629 {tangent_second:.6e}"
6630 );
6631
6632 let joint_value = joint
6633 .iter()
6634 .map(|point| {
6635 let normal_part = lift_contrast.dot(&point.normal_coordinates);
6636 point.weight * integrand(normal_part + tangent_sd * point.tangent[0])
6637 })
6638 .sum::<f64>();
6639
6640 let convolved = |x: f64| -> f64 {
6642 simpson(
6643 x - 12.0 * tangent_sd,
6644 x + 12.0 * tangent_sd,
6645 4001,
6646 |value| {
6647 let z = (value - x) / tangent_sd;
6648 (-0.5 * z * z).exp() * integrand(value)
6649 },
6650 ) / (tangent_sd * (2.0 * std::f64::consts::PI).sqrt())
6651 };
6652 let reference = exact_orthant_expectation_of(¢er, &ambient, &contrast, convolved);
6653
6654 let posterior_mean = contrast.dot(&correction.posterior_mean(¢er));
6658 let corrected = correction.apply_to_covariance(&ambient);
6659 let posterior_variance =
6660 contrast.dot(&corrected.dot(&contrast)) + tangent_sd * tangent_sd;
6661 let normal_value = normal_expectation(posterior_mean, posterior_variance);
6662
6663 let joint_error = (joint_value - reference).abs();
6664 let normal_error = (normal_value - reference).abs();
6665 eprintln!(
6666 "[2679] reference={reference:.12e} joint={joint_value:.12e} (err {joint_error:.3e}) \
6667 normal={normal_value:.12e} (err {normal_error:.3e})"
6668 );
6669 assert!(
6670 normal_error > 1.0e-4,
6671 "the fixture must leave the moment-matched normal measurably wrong, or the \
6672 comparison below is vacuous; got {normal_error:.3e}"
6673 );
6674 assert!(
6675 joint_error < 0.2 * normal_error,
6676 "the joint rule must be decisively closer to the exact pushforward than the \
6677 moment-matched normal: joint error {joint_error:.3e} vs normal error \
6678 {normal_error:.3e} against reference {reference:.12e}"
6679 );
6680 }
6681}
6682
6683
6684#[cfg(test)]
6685mod orthant_rule_979_tests {
6686 use super::orthant_tilt_2601_tests::weight_efficiency;
6687 use super::tests_orthant_rule_support::moment_relative_change;
6688 use super::*;
6689 use gam_math::probability::{normal_cdf, normal_pdf};
6690 use ndarray::array;
6691
6692 #[derive(serde::Deserialize)]
6693 struct FaceReference {
6694 mean: Vec<f64>,
6695 variance: Vec<f64>,
6696 mean_standard_error: Vec<f64>,
6697 }
6698
6699 #[derive(serde::Deserialize)]
6700 struct FaceFixture {
6701 mean: Vec<f64>,
6702 covariance: Vec<Vec<f64>>,
6703 reference: FaceReference,
6704 }
6705
6706 fn face_979() -> (Array1<f64>, Array2<f64>, FaceReference) {
6718 let fixture: FaceFixture =
6719 serde_json::from_str(include_str!("constrained_posterior_face_979.json"))
6720 .expect("the #979 face fixture parses");
6721 let q = fixture.mean.len();
6722 let mean = Array1::from_vec(fixture.mean);
6723 let mut covariance = Array2::<f64>::zeros((q, q));
6724 for (i, row) in fixture.covariance.iter().enumerate() {
6725 for (j, value) in row.iter().enumerate() {
6726 covariance[[i, j]] = *value;
6727 }
6728 }
6729 (mean, covariance, fixture.reference)
6730 }
6731
6732 fn face_geometry(mean: &Array1<f64>, w: &Array2<f64>) -> (Vec<f64>, f64, f64, f64) {
6733 let q = mean.len();
6734 let sd: Vec<f64> = (0..q).map(|i| w[[i, i]].sqrt()).collect();
6735 let depth: Vec<f64> = (0..q).map(|i| -mean[i] / sd[i]).collect();
6736 let depth_min = depth.iter().copied().fold(f64::INFINITY, f64::min);
6737 let depth_max = depth.iter().copied().fold(f64::NEG_INFINITY, f64::max);
6738 let mut corr_max = 0.0f64;
6739 for i in 0..q {
6740 for j in 0..i {
6741 corr_max = corr_max.max((w[[i, j]] / (sd[i] * sd[j])).abs());
6742 }
6743 }
6744 (sd, depth_min, depth_max, corr_max)
6745 }
6746
6747 #[test]
6753 fn the_979_face_is_certified_and_matches_an_independent_reference() {
6754 let (mean, w, reference) = face_979();
6755 let q = mean.len();
6756 assert_eq!(q, 120, "the captured face has 120 retained rows");
6757 let upper = vec![f64::INFINITY; q];
6758 let (sd, depth_min, depth_max, corr_max) = face_geometry(&mean, &w);
6759 assert!(
6760 depth_min < -0.5 && depth_max > 3.0 && corr_max > 0.9,
6761 "the fixture must be the deep, correlated face it was captured as \
6762 (depth {depth_min:.2}..{depth_max:.2}, max |corr| {corr_max:.3})"
6763 );
6764
6765 let previous = OrthantRule::in_given_order_untilted(&mean, &upper, &w).expect("factor");
6766 let (previous_ess, previous_decades) = weight_efficiency(&previous, 1 << 14);
6767 let rule = OrthantRule::new(&mean, &upper, &w, 0).expect("rule");
6768 let (ess, decades) = weight_efficiency(&rule, 1 << 14);
6769 println!(
6770 "MEASURE979 caller-order untilted ess={:.4}% over {previous_decades:.0} decades; \
6771 ordered tilted ess={:.2}% over {decades:.0} decades; tilt {}",
6772 100.0 * previous_ess,
6773 100.0 * ess,
6774 rule.tilt_status
6775 );
6776 assert!(
6777 previous_ess < 1e-3,
6778 "precondition: the shipped rule collapses on this face (ess {:.4}%)",
6779 100.0 * previous_ess
6780 );
6781 assert!(
6782 matches!(rule.tilt_status, TiltStatus::Converged { .. }),
6783 "the saddle point must be reached on this face: {}",
6784 rule.tilt_status
6785 );
6786 assert!(
6787 ess > 0.10,
6788 "the ordered tilted rule must keep at least a tenth of its nodes; got {:.3}%",
6789 100.0 * ess
6790 );
6791
6792 let (moments_mean, moments_cov) =
6793 box_truncated_moments(&mean, &upper, &w).expect("the #979 face certifies");
6794 let mut worst_mean_gap = 0.0f64;
6795 let mut worst_variance_gap = 0.0f64;
6796 for i in 0..q {
6797 let band = 3.0 * (reference.mean_standard_error[i] + ORTHANT_MOMENT_RELATIVE_TOLERANCE * sd[i]);
6801 let gap = (moments_mean[i] - reference.mean[i]).abs();
6802 worst_mean_gap = worst_mean_gap.max(gap / sd[i]);
6803 assert!(
6804 gap <= band.max(0.01 * sd[i]),
6805 "coordinate {i}: certified mean {} against the HMC reference {} (gap {:.3e} sd, \
6806 band {:.3e} sd)",
6807 moments_mean[i],
6808 reference.mean[i],
6809 gap / sd[i],
6810 band / sd[i]
6811 );
6812 assert!(
6813 moments_mean[i] > 0.0,
6814 "coordinate {i}: the truncated mean is interior, got {}",
6815 moments_mean[i]
6816 );
6817 let variance_gap = (moments_cov[[i, i]] - reference.variance[i]).abs() / reference.variance[i];
6818 worst_variance_gap = worst_variance_gap.max(variance_gap);
6819 assert!(
6820 variance_gap < 0.05,
6821 "coordinate {i}: certified variance {} against the HMC reference {} \
6822 (relative gap {variance_gap:.3e})",
6823 moments_cov[[i, i]],
6824 reference.variance[i]
6825 );
6826 }
6827 println!(
6828 "MEASURE979 worst mean gap vs HMC {worst_mean_gap:.3e} sd, worst variance gap \
6829 {worst_variance_gap:.3e} relative"
6830 );
6831 }
6832
6833 #[test]
6836 fn the_saddle_point_is_stationary_on_the_captured_faces() {
6837 let (mean, w, _) = face_979();
6838 let upper = vec![f64::INFINITY; mean.len()];
6839 let rule = OrthantRule::new(&mean, &upper, &w, 0).expect("rule");
6840 match rule.tilt_status {
6841 TiltStatus::Converged {
6842 iterations,
6843 residual,
6844 } => {
6845 println!("MEASURE979 saddle: {iterations} Newton steps, residual {residual:.3e}");
6846 assert!(residual < 1e-8, "residual {residual:.3e}");
6847 assert!(iterations < 200, "{iterations} Newton steps");
6848 }
6849 TiltStatus::Untilted { reason } => panic!("the #979 face must be tilted: {reason}"),
6850 }
6851 let (mean, w) = super::orthant_tilt_2601_tests::refusing_face();
6852 let upper = vec![f64::INFINITY; mean.len()];
6853 let rule = OrthantRule::new(&mean, &upper, &w, 0).expect("rule");
6854 assert!(
6855 matches!(rule.tilt_status, TiltStatus::Converged { residual, .. } if residual < 1e-8),
6856 "the #2601 face must be tilted: {}",
6857 rule.tilt_status
6858 );
6859 }
6860
6861 #[test]
6864 fn a_box_face_is_tilted() {
6865 let mean = array![-1.5, -0.8, 0.3];
6866 let w = array![[1.0, 0.7, 0.4], [0.7, 1.0, 0.6], [0.4, 0.6, 1.0]];
6867 let upper = vec![1.0, f64::INFINITY, 2.5];
6868 let rule = OrthantRule::new(&mean, &upper, &w, 0).expect("rule");
6869 assert!(
6870 matches!(rule.tilt_status, TiltStatus::Converged { residual, .. } if residual < 1e-8),
6871 "a box face reaches its saddle point: {}",
6872 rule.tilt_status
6873 );
6874 let tilt = rule.tilt.as_ref().expect("tilted");
6875 assert!(
6876 tilt.iter().any(|value| value.abs() > 1e-3),
6877 "a face with infeasible ambient centre carries a nonzero tilt, got {tilt:?}"
6878 );
6879 let untilted = OrthantRule::in_given_order_untilted(&mean, &upper, &w).expect("factor");
6880 let (plain, _) = weight_efficiency(&untilted, 1 << 14);
6881 let (tilted, _) = weight_efficiency(&rule, 1 << 14);
6882 assert!(
6883 tilted >= plain * 0.9,
6884 "the tilt must not make the box face worse: {plain:.4} -> {tilted:.4}"
6885 );
6886 }
6887
6888 #[test]
6892 fn the_truncated_standard_normal_matches_its_closed_forms() {
6893 let direct = |low: f64, high: f64| -> (f64, f64) {
6894 let mass = normal_cdf(high) - normal_cdf(low);
6895 ((normal_pdf(low) - normal_pdf(high)) / mass, mass.ln())
6896 };
6897 for &(low, high) in &[
6898 (1.5, f64::INFINITY),
6899 (-0.5, 1.2),
6900 (-3.0, -1.0),
6901 (0.2, 0.9),
6902 (-2.0, f64::INFINITY),
6903 ] {
6904 let law = truncated_standard_normal(low, high).expect("representable");
6905 let (mean, log_mass) = if high.is_finite() {
6906 direct(low, high)
6907 } else {
6908 (normal_pdf(low) / (1.0 - normal_cdf(low)), (1.0 - normal_cdf(low)).ln())
6909 };
6910 assert!(
6911 (law.mean - mean).abs() < 1e-12 * (1.0 + mean.abs()),
6912 "[{low}, {high}] mean {} vs direct {mean}",
6913 law.mean
6914 );
6915 assert!(
6916 (law.log_mass - log_mass).abs() < 1e-12 * (1.0 + log_mass.abs()),
6917 "[{low}, {high}] log mass {} vs direct {log_mass}",
6918 law.log_mass
6919 );
6920 let h = 1e-5;
6921 let up = truncated_standard_normal(low + h, high + h).expect("shifted");
6922 let down = truncated_standard_normal(low - h, high - h).expect("shifted");
6923 let difference = (up.mean - down.mean) / (2.0 * h);
6924 assert!(
6925 (law.mean_wall_derivative - difference).abs() < 1e-7,
6926 "[{low}, {high}] wall derivative {} vs central difference {difference}",
6927 law.mean_wall_derivative
6928 );
6929 }
6930 let left = truncated_standard_normal(-3.0, -1.0).expect("left slab");
6932 let right = truncated_standard_normal(1.0, 3.0).expect("right slab");
6933 assert!((left.mean + right.mean).abs() < 1e-14);
6934 assert!((left.log_mass - right.log_mass).abs() < 1e-14);
6935 assert!((left.mean_wall_derivative - right.mean_wall_derivative).abs() < 1e-12);
6936 let deep = truncated_standard_normal(30.0, f64::INFINITY).expect("deep half-line");
6939 assert!(deep.log_mass.is_finite() && deep.log_mass < -400.0, "{}", deep.log_mass);
6940 assert!((deep.mean - 30.033).abs() < 1e-3, "{}", deep.mean);
6941 assert!(deep.mean_wall_derivative > 0.99 && deep.mean_wall_derivative < 1.0);
6942 let none = truncated_standard_normal(f64::NEG_INFINITY, f64::INFINITY).expect("whole line");
6944 assert_eq!(none.log_mass, 0.0);
6945 assert_eq!(none.mean, 0.0);
6946 assert_eq!(none.mean_wall_derivative, 0.0);
6947 assert!(truncated_standard_normal(1.0, 0.5).is_none());
6949 }
6950
6951 #[test]
6954 fn the_ordering_integrates_the_most_constraining_coordinate_first() {
6955 let mean = array![2.0, -1.0, 0.5];
6956 let w = array![[1.0, 0.5, 0.25], [0.5, 1.0, 0.5], [0.25, 0.5, 1.0]];
6957 let face = ordered_face(&mean, &[f64::INFINITY; 3], &w).expect("ordered");
6958 assert_eq!(
6959 face.order[0], 1,
6960 "the coordinate with the least marginal mass (mean −1) goes first, got {:?}",
6961 face.order
6962 );
6963 for i in 0..3 {
6964 for j in 0..3 {
6965 let mut product = 0.0;
6966 for k in 0..3 {
6967 product += face.factor[[i, k]] * face.factor[[j, k]];
6968 }
6969 let expected = w[[face.order[i], face.order[j]]];
6970 assert!(
6971 (product - expected).abs() < 1e-12,
6972 "L Lᵀ at ({i},{j}) = {product} against the reordered covariance {expected}"
6973 );
6974 }
6975 assert_eq!(face.mean[i], mean[face.order[i]]);
6976 }
6977 let diagonal = Array2::from_diag(&array![1.0, 4.0, 0.25]);
6981 let mean = array![-0.5, 1.0, -2.0];
6982 let upper = vec![f64::INFINITY; 3];
6983 let rule = OrthantRule::new(&mean, &upper, &diagonal, 0).expect("rule");
6984 assert_ne!(rule.original_index(0), 0, "the diagonal face is reordered");
6985 let (moments_mean, _) = box_truncated_moments(&mean, &upper, &diagonal).expect("moments");
6986 for i in 0..3 {
6987 let (exact, _) =
6988 scalar_truncated_moments(mean[i], diagonal[[i, i]], f64::INFINITY).expect("scalar");
6989 assert!(
6990 (moments_mean[i] - exact[0]).abs() < 2.0 * ORTHANT_MOMENT_RELATIVE_TOLERANCE * diagonal[[i, i]].sqrt(),
6991 "coordinate {i}: {} vs exact {}",
6992 moments_mean[i],
6993 exact[0]
6994 );
6995 }
6996 }
6997
6998 #[test]
7000 fn pooled_accumulators_are_the_union_of_their_nodes() {
7001 let (mean, w) = super::orthant_tilt_2601_tests::refusing_face();
7002 let q = mean.len();
7003 let upper = vec![f64::INFINITY; q];
7004 let rule = OrthantRule::new(&mean, &upper, &w, 0).expect("rule");
7005 let mut single = OrthantAccumulator::new(q);
7006 let mut parts: Vec<OrthantAccumulator> = (0..4).map(|_| OrthantAccumulator::new(q)).collect();
7007 for (replicate, part) in parts.iter_mut().enumerate() {
7008 rule.accumulate(part, replicate, 0, 1 << 10).expect("part");
7009 rule.accumulate(&mut single, replicate, 0, 1 << 10).expect("single");
7010 }
7011 let views: Vec<&OrthantAccumulator> = parts.iter().collect();
7012 let pooled = OrthantAccumulator::pooled(&views).expect("pooled");
7013 let (pooled_mean, pooled_cov) = pooled.moments().expect("pooled moments");
7014 let (single_mean, single_cov) = single.moments().expect("single moments");
7015 assert!(moment_relative_change(&(pooled_mean, pooled_cov), &(single_mean, single_cov), &w) < 1e-12);
7016 assert!(
7017 (pooled.effective_sample_size() - single.effective_sample_size()).abs()
7018 < 1e-9 * single.effective_sample_size()
7019 );
7020 }
7021
7022 #[test]
7025 fn the_dense_solve_recovers_a_known_solution() {
7026 let n = 4;
7027 let a = vec![
7028 0.0, 2.0, 1.0, -1.0, 3.0, 1.0, -2.0, 0.5, 1.0, -1.0, 4.0, 2.0, -2.0, 0.5, 1.0, 3.0,
7032 ];
7033 let x = [1.5, -2.0, 0.25, 3.0];
7034 let mut b = vec![0.0; n];
7035 for i in 0..n {
7036 for j in 0..n {
7037 b[i] += a[i * n + j] * x[j];
7038 }
7039 }
7040 let solved = solve_dense_square(a, n, b).expect("nonsingular");
7041 for i in 0..n {
7042 assert!((solved[i] - x[i]).abs() < 1e-12, "{solved:?} vs {x:?}");
7043 }
7044 assert!(solve_dense_square(vec![1.0, 2.0, 2.0, 4.0], 2, vec![1.0, 2.0]).is_none());
7045 }
7046}