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;
139use ndarray::{Array1, Array2, ArrayView2};
140use serde::{Deserialize, Serialize};
141
142const ORTHANT_MOMENT_RELATIVE_TOLERANCE: f64 = 1e-3;
158
159const ORTHANT_MOMENT_INITIAL_POINTS: usize = 1 << 11;
165
166const ORTHANT_MOMENT_MAXIMUM_POINTS: usize = 1 << 20;
171
172#[derive(Clone, Debug, Serialize, Deserialize)]
180pub struct ConstrainedPosteriorCorrection {
181 pub lift: Array2<f64>,
183 pub removed_normal_variance: Array2<f64>,
186 pub normal_mean_shift: Array1<f64>,
192 pub rows: Vec<usize>,
194 #[serde(default, with = "gam_problem::serde_extended_real::vec_f64")]
217 pub normal_upper_limits: Vec<f64>,
218}
219
220impl ConstrainedPosteriorCorrection {
221 pub fn apply_to_covariance_in_place(&self, covariance: &mut Array2<f64>) {
224 let scaled = self.lift.dot(&self.removed_normal_variance);
225 let p = covariance.nrows();
226 for i in 0..p {
227 for j in 0..=i {
228 let removed = scaled.row(i).dot(&self.lift.row(j));
229 covariance[[i, j]] -= removed;
230 if i != j {
231 covariance[[j, i]] = covariance[[i, j]];
232 }
233 }
234 }
235 }
236
237 pub fn apply_to_covariance(&self, covariance: &Array2<f64>) -> Array2<f64> {
239 let mut corrected = covariance.clone();
240 self.apply_to_covariance_in_place(&mut corrected);
241 corrected
242 }
243
244 pub fn truncated_covariance_psd(
282 &self,
283 covariance: &Array2<f64>,
284 constraints: &LinearInequalityConstraints,
285 ) -> Result<Array2<f64>, String> {
286 use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh};
287
288 let p = covariance.nrows();
289 if covariance.ncols() != p {
290 return Err(format!(
291 "truncated covariance needs a square Σ, got {}x{}",
292 covariance.nrows(),
293 covariance.ncols()
294 ));
295 }
296 if self.lift.nrows() != p {
297 return Err(format!(
298 "truncated covariance: the lift has {} rows against a {p}x{p} Σ",
299 self.lift.nrows()
300 ));
301 }
302 if constraints.a.ncols() != p {
303 return Err(format!(
304 "truncated covariance: the constraint system has {} columns against a {p}x{p} Σ",
305 constraints.a.ncols()
306 ));
307 }
308 let q = self.rows.len();
309 if self.lift.ncols() != q || self.removed_normal_variance.dim() != (q, q) {
310 return Err(format!(
311 "truncated covariance: {q} retained row(s) against a lift of {} column(s) and a \
312 removed-variance block of {:?}",
313 self.lift.ncols(),
314 self.removed_normal_variance.dim()
315 ));
316 }
317 let mut retained = Array2::<f64>::zeros((q, p));
318 for (position, &row) in self.rows.iter().enumerate() {
319 if row >= constraints.a.nrows() {
320 return Err(format!(
321 "truncated covariance: retained row {row} is outside the {}-row constraint \
322 system it indexes",
323 constraints.a.nrows()
324 ));
325 }
326 retained.row_mut(position).assign(&constraints.a.row(row));
327 }
328
329 let sigma_at = covariance.dot(&retained.t());
332 let mut w = retained.dot(&sigma_at);
333 gam_linalg::matrix::symmetrize_in_place(&mut w);
334 let mut truncated_normal = &w - &self.removed_normal_variance;
335 gam_linalg::matrix::symmetrize_in_place(&mut truncated_normal);
336
337 let (eigenvalues, eigenvectors) = truncated_normal
338 .eigh(faer::Side::Lower)
339 .map_err(|error| format!("truncated constraint-normal covariance eigendecomposition: {error:?}"))?;
340 let pre_truncation_scale = (0..q).fold(0.0_f64, |worst, index| worst.max(w[[index, index]]));
346 let negative_floor = -ORTHANT_MOMENT_RELATIVE_TOLERANCE * (q as f64) * pre_truncation_scale;
347 let mut normal_factor = Array2::<f64>::zeros((q, q));
348 for index in 0..q {
349 let eigenvalue = eigenvalues[index];
350 if !eigenvalue.is_finite() {
351 return Err(format!(
352 "truncated constraint-normal covariance has a non-finite eigenvalue at {index}"
353 ));
354 }
355 if eigenvalue < negative_floor {
356 return Err(format!(
357 "the truncated constraint-normal covariance is materially indefinite: \
358 eigenvalue {eigenvalue:.6e} at {index} is below the cubature's own \
359 resolution {negative_floor:.6e} (pre-truncation scale \
360 {pre_truncation_scale:.6e} over {q} retained row(s))"
361 ));
362 }
363 let scale = eigenvalue.max(0.0).sqrt();
364 for row in 0..q {
365 normal_factor[[row, index]] = eigenvectors[[row, index]] * scale;
366 }
367 }
368
369 let sigma_factor = covariance
370 .cholesky(faer::Side::Lower)
371 .map_err(|error| {
372 format!("truncated covariance requires an SPD Σ to factor: {error:?}")
373 })?
374 .lower_triangular();
375 let projected_factor = &sigma_factor - &self.lift.dot(&retained.dot(&sigma_factor));
378 let normal_lift = self.lift.dot(&normal_factor);
379
380 let mut truncated = projected_factor.dot(&projected_factor.t());
381 truncated += &normal_lift.dot(&normal_lift.t());
382 gam_linalg::matrix::symmetrize_in_place(&mut truncated);
383 for index in 0..p {
387 let projected_row = projected_factor.row(index);
388 let normal_row = normal_lift.row(index);
389 truncated[[index, index]] =
390 projected_row.dot(&projected_row) + normal_row.dot(&normal_row);
391 }
392 Ok(truncated)
393 }
394
395 pub fn removed_variance_diagonal(&self) -> Array1<f64> {
398 let scaled = self.lift.dot(&self.removed_normal_variance);
399 let p = self.lift.nrows();
400 let mut diagonal = Array1::<f64>::zeros(p);
401 for i in 0..p {
402 diagonal[i] = scaled.row(i).dot(&self.lift.row(i));
403 }
404 diagonal
405 }
406
407 pub fn diagonal_uncertainty(&self) -> Array1<f64> {
425 self.removed_variance_diagonal() * ORTHANT_MOMENT_RELATIVE_TOLERANCE
426 }
427
428 pub fn posterior_mean(&self, unconstrained_center: &Array1<f64>) -> Array1<f64> {
430 unconstrained_center + &self.lift.dot(&self.normal_mean_shift)
431 }
432
433 pub fn upper_limits(&self) -> Vec<f64> {
436 if self.normal_upper_limits.is_empty() {
437 vec![f64::INFINITY; self.rows.len()]
438 } else {
439 self.normal_upper_limits.clone()
440 }
441 }
442}
443
444#[derive(Clone, Debug, Serialize, Deserialize)]
446pub enum ConePropernessEvidence {
447 Certificate(crate::cone_reduction::ConeProperness),
448 CertificationFailed { reason: String },
449}
450
451impl ConePropernessEvidence {
452 pub fn is_proper(&self) -> Option<bool> {
453 match self {
454 Self::Certificate(certificate) => certificate.is_proper(),
455 Self::CertificationFailed { .. } => None,
456 }
457 }
458
459 pub fn summary(&self) -> String {
460 match self {
461 Self::Certificate(certificate) => certificate.summary(),
462 Self::CertificationFailed { reason } => format!(
463 "cone-truncated posterior properness could not be certified: {reason}"
464 ),
465 }
466 }
467
468 fn validate(&self, ambient_dimension: usize, constraint_count: usize) -> Result<(), String> {
469 match self {
470 Self::CertificationFailed { reason } => {
471 if reason.trim().is_empty() {
472 return Err(
473 "cone properness certification failure has an empty reason".to_string(),
474 );
475 }
476 }
477 Self::Certificate(certificate) => {
478 if certificate.reduced.dim() != (constraint_count, constraint_count) {
479 return Err(format!(
480 "cone properness reduced precision has shape {:?}, expected ({constraint_count}, {constraint_count})",
481 certificate.reduced.dim(),
482 ));
483 }
484 if certificate.reduced.iter().any(|value| !value.is_finite())
485 || certificate
486 .copositive_minimum
487 .is_some_and(|value| !value.is_finite())
488 {
489 return Err(
490 "cone properness certificate contains a non-finite value".to_string(),
491 );
492 }
493 let total = |inertia: crate::cone_reduction::Inertia| {
494 inertia.positive + inertia.zero + inertia.negative
495 };
496 if total(certificate.ambient_inertia) != ambient_dimension
497 || total(certificate.reduced_inertia) != constraint_count
498 || total(certificate.lineality_inertia)
499 != ambient_dimension.saturating_sub(constraint_count)
500 {
501 return Err(format!(
502 "cone properness inertia dimensions disagree with ambient p={ambient_dimension} and face q={constraint_count}"
503 ));
504 }
505 if certificate.ambient_inertia.positive
506 != certificate.reduced_inertia.positive
507 + certificate.lineality_inertia.positive
508 || certificate.ambient_inertia.zero
509 != certificate.reduced_inertia.zero
510 + certificate.lineality_inertia.zero
511 || certificate.ambient_inertia.negative
512 != certificate.reduced_inertia.negative
513 + certificate.lineality_inertia.negative
514 {
515 return Err(
516 "cone properness certificate violates Haynsworth inertia additivity"
517 .to_string(),
518 );
519 }
520 if certificate.is_proper() == Some(false) {
521 return Err(
522 "a proved-improper cone posterior cannot be stored as a moment decline"
523 .to_string(),
524 );
525 }
526 }
527 }
528 Ok(())
529 }
530}
531
532#[derive(Clone, Debug, Serialize, Deserialize)]
534pub struct ConePosteriorMomentDecline {
535 pub ambient_precision_failure: String,
536 pub properness: ConePropernessEvidence,
537}
538
539impl ConePosteriorMomentDecline {
540 pub fn summary(&self) -> String {
541 format!(
542 "ambient covariance route declined ({}); {}",
543 self.ambient_precision_failure,
544 self.properness.summary(),
545 )
546 }
547}
548
549#[derive(Clone, Debug, Serialize, Deserialize)]
551pub enum ConstrainedPosteriorMomentStatus {
552 Available,
553 Declined(ConePosteriorMomentDecline),
554}
555
556#[derive(Clone, Debug, Serialize, Deserialize)]
563pub struct ConstrainedPosteriorGeometry {
564 pub constraints: LinearInequalityConstraints,
567 pub mode: Array1<f64>,
569 unconstrained_center: Option<Array1<f64>>,
570 correction: Option<ConstrainedPosteriorCorrection>,
573 pub moment_status: ConstrainedPosteriorMomentStatus,
575}
576
577impl ConstrainedPosteriorGeometry {
578 pub fn with_moments(
579 constraints: LinearInequalityConstraints,
580 mode: Array1<f64>,
581 unconstrained_center: Array1<f64>,
582 correction: Option<ConstrainedPosteriorCorrection>,
583 ) -> Self {
584 Self {
585 constraints,
586 mode,
587 unconstrained_center: Some(unconstrained_center),
588 correction,
589 moment_status: ConstrainedPosteriorMomentStatus::Available,
590 }
591 }
592
593 pub fn with_decline(
594 constraints: LinearInequalityConstraints,
595 mode: Array1<f64>,
596 decline: ConePosteriorMomentDecline,
597 ) -> Self {
598 Self {
599 constraints,
600 mode,
601 unconstrained_center: None,
602 correction: None,
603 moment_status: ConstrainedPosteriorMomentStatus::Declined(decline),
604 }
605 }
606
607 pub fn decline(&self) -> Option<&ConePosteriorMomentDecline> {
608 match &self.moment_status {
609 ConstrainedPosteriorMomentStatus::Available => None,
610 ConstrainedPosteriorMomentStatus::Declined(decline) => Some(decline),
611 }
612 }
613
614 pub fn unconstrained_center(&self) -> Result<&Array1<f64>, String> {
615 match &self.moment_status {
616 ConstrainedPosteriorMomentStatus::Available => self
617 .unconstrained_center
618 .as_ref()
619 .ok_or_else(|| {
620 "available constrained posterior is missing its ambient centre".to_string()
621 }),
622 ConstrainedPosteriorMomentStatus::Declined(decline) => Err(format!(
623 "constrained posterior has no ambient centre because its moments were declined: {}",
624 decline.summary(),
625 )),
626 }
627 }
628
629 pub fn correction(&self) -> Result<Option<&ConstrainedPosteriorCorrection>, String> {
630 match &self.moment_status {
631 ConstrainedPosteriorMomentStatus::Available => Ok(self.correction.as_ref()),
632 ConstrainedPosteriorMomentStatus::Declined(decline) => Err(format!(
633 "constrained posterior has no moment correction because its moments were declined: {}",
634 decline.summary(),
635 )),
636 }
637 }
638
639 pub fn available_parts_mut(
640 &mut self,
641 ) -> Option<(&mut Array1<f64>, Option<&mut ConstrainedPosteriorCorrection>)> {
642 match &self.moment_status {
643 ConstrainedPosteriorMomentStatus::Available => Some((
644 self.unconstrained_center.as_mut()?,
645 self.correction.as_mut(),
646 )),
647 ConstrainedPosteriorMomentStatus::Declined(_) => None,
648 }
649 }
650
651 pub fn posterior_mean(&self) -> Result<Array1<f64>, String> {
652 let center = self.unconstrained_center()?;
653 Ok(self
654 .correction()?
655 .map(|correction| correction.posterior_mean(center))
656 .unwrap_or_else(|| center.clone()))
657 }
658
659 pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), String> {
660 if self.constraints.a.ncols() != dimension
661 || self.constraints.a.nrows() != self.constraints.b.len()
662 {
663 return Err(format!(
664 "constrained posterior inequalities have shape {}x{} with {} bounds, expected {dimension} columns",
665 self.constraints.a.nrows(),
666 self.constraints.a.ncols(),
667 self.constraints.b.len()
668 ));
669 }
670 if self.mode.len() != dimension {
671 return Err(format!(
672 "constrained posterior mode has length {}, expected {dimension}",
673 self.mode.len(),
674 ));
675 }
676 if self
677 .mode
678 .iter()
679 .chain(self.unconstrained_center.iter().flat_map(|center| center.iter()))
680 .chain(self.constraints.a.iter())
681 .chain(self.constraints.b.iter())
682 .any(|value| !value.is_finite())
683 {
684 return Err("constrained posterior geometry contains a non-finite value".to_string());
685 }
686 match &self.moment_status {
687 ConstrainedPosteriorMomentStatus::Available => {
688 if self
689 .unconstrained_center
690 .as_ref()
691 .is_none_or(|center| center.len() != dimension)
692 {
693 return Err(format!(
694 "available constrained posterior centre has length {:?}, expected {dimension}",
695 self.unconstrained_center.as_ref().map(Array1::len),
696 ));
697 }
698 }
699 ConstrainedPosteriorMomentStatus::Declined(decline) => {
700 if self.unconstrained_center.is_some() || self.correction.is_some() {
701 return Err(
702 "declined constrained posterior must not carry fabricated ambient moments"
703 .to_string(),
704 );
705 }
706 if decline.ambient_precision_failure.trim().is_empty() {
707 return Err(
708 "constrained posterior moment decline has an empty ambient-precision reason"
709 .to_string(),
710 );
711 }
712 decline.properness.validate(dimension, self.constraints.a.nrows())?;
713 }
714 }
715 if let Some(correction) = self.correction.as_ref() {
716 let q = correction.lift.ncols();
717 if correction.lift.nrows() != dimension {
718 return Err(format!(
719 "constrained posterior lift has {} rows, expected {dimension}",
720 correction.lift.nrows()
721 ));
722 }
723 if correction.removed_normal_variance.dim() != (q, q)
724 || correction.normal_mean_shift.len() != q
725 || correction.rows.len() != q
726 {
727 return Err(format!(
728 "constrained posterior normal geometry is inconsistent: lift={}x{q}, removed={:?}, mean={}, rows={}",
729 correction.lift.nrows(),
730 correction.removed_normal_variance.dim(),
731 correction.normal_mean_shift.len(),
732 correction.rows.len()
733 ));
734 }
735 let mut unique_rows = correction.rows.clone();
736 unique_rows.sort_unstable();
737 unique_rows.dedup();
738 if unique_rows.len() != q
739 || unique_rows
740 .iter()
741 .any(|&row| row >= self.constraints.a.nrows())
742 {
743 return Err(format!(
744 "constrained posterior retained rows {:?} are not unique valid indices for {} inequalities",
745 correction.rows,
746 self.constraints.a.nrows()
747 ));
748 }
749 if correction
750 .lift
751 .iter()
752 .chain(correction.removed_normal_variance.iter())
753 .chain(correction.normal_mean_shift.iter())
754 .any(|value| !value.is_finite())
755 {
756 return Err(
757 "constrained posterior correction contains a non-finite value".to_string()
758 );
759 }
760 if !correction.normal_upper_limits.is_empty()
761 && correction.normal_upper_limits.len() != q
762 {
763 return Err(format!(
764 "constrained posterior carries {} upper limits for {q} retained rows",
765 correction.normal_upper_limits.len()
766 ));
767 }
768 if correction
771 .normal_upper_limits
772 .iter()
773 .any(|limit| !(*limit > 0.0))
774 {
775 return Err(format!(
776 "constrained posterior upper limits must be positive, got {:?}",
777 correction.normal_upper_limits
778 ));
779 }
780 }
781 Ok(())
782 }
783}
784
785struct TruncatedProjection {
810 posterior_mean: f64,
812 normal_center: Array1<f64>,
814 normal_covariance: Array2<f64>,
815 upper_limits: Vec<f64>,
816 projection_lift: Array1<f64>,
818 residual_variance: f64,
822}
823
824struct ProjectionDecomposition {
825 ambient_mean: f64,
826 ambient_variance: f64,
827 truncated: Option<TruncatedProjection>,
830}
831
832fn decompose_projection(
833 ambient_covariance: &Array2<f64>,
834 geometry: &ConstrainedPosteriorGeometry,
835 contrast: &Array1<f64>,
836) -> Result<ProjectionDecomposition, String> {
837 let p = contrast.len();
838 geometry.validate_for_dimension(p)?;
839 if ambient_covariance.dim() != (p, p) {
840 return Err(format!(
841 "constrained projection needs a {p}x{p} ambient covariance, got {:?}",
842 ambient_covariance.dim()
843 ));
844 }
845 if ambient_covariance.iter().any(|value| !value.is_finite())
846 || contrast.iter().any(|value| !value.is_finite())
847 {
848 return Err(
849 "constrained projection received a non-finite covariance or contrast".to_string(),
850 );
851 }
852
853 let ambient_mean = contrast.dot(geometry.unconstrained_center()?);
854 let sigma_c = ambient_covariance.dot(contrast);
855 let ambient_variance = contrast.dot(&sigma_c);
856 let covariance_scale = ambient_covariance
857 .diag()
858 .iter()
859 .map(|value| value.abs())
860 .fold(f64::MIN_POSITIVE, f64::max);
861 let contrast_scale = contrast.dot(contrast).max(f64::MIN_POSITIVE);
862 let variance_floor = (p.max(1) as f64) * f64::EPSILON * covariance_scale * contrast_scale;
863 if ambient_variance < -variance_floor || !ambient_variance.is_finite() {
864 return Err(format!(
865 "constrained projection has invalid ambient variance {ambient_variance:.6e}"
866 ));
867 }
868 let ambient_variance = ambient_variance.max(0.0);
869
870 let Some(correction) = geometry.correction()? else {
871 return Ok(ProjectionDecomposition {
872 ambient_mean,
873 ambient_variance,
874 truncated: None,
875 });
876 };
877
878 let q = correction.rows.len();
879 let mut normal_center = Array1::<f64>::zeros(q);
880 let mut normal_covariance = Array2::<f64>::zeros((q, q));
881 let mut sigma_a = Array2::<f64>::zeros((p, q));
882 for (position, &row) in correction.rows.iter().enumerate() {
883 let a = geometry.constraints.a.row(row);
884 normal_center[position] =
885 a.dot(geometry.unconstrained_center()?) - geometry.constraints.b[row];
886 sigma_a
887 .column_mut(position)
888 .assign(&ambient_covariance.dot(&a));
889 }
890 for i in 0..q {
891 let ai = geometry.constraints.a.row(correction.rows[i]);
892 for j in 0..=i {
893 let value = ai.dot(&sigma_a.column(j));
894 normal_covariance[[i, j]] = value;
895 normal_covariance[[j, i]] = value;
896 }
897 }
898
899 let projection_lift = correction.lift.t().dot(contrast);
900 let normal_component_variance = projection_lift.dot(&normal_covariance.dot(&projection_lift));
901 let residual_variance = ambient_variance - normal_component_variance;
902 let residual_floor = (p.max(q).max(1) as f64)
903 * f64::EPSILON
904 * ambient_variance
905 .max(normal_component_variance)
906 .max(f64::MIN_POSITIVE);
907 if residual_variance < -residual_floor || !residual_variance.is_finite() {
908 return Err(format!(
909 "constrained projection decomposition produced residual variance \
910 {residual_variance:.6e} from ambient {ambient_variance:.6e}"
911 ));
912 }
913 let residual_variance = residual_variance.max(0.0);
914 let posterior_mean = ambient_mean + projection_lift.dot(&correction.normal_mean_shift);
915 let upper_limits = correction.upper_limits();
916 if upper_limits.len() != q {
917 return Err(format!(
918 "constrained projection: {q} retained rows carry {} upper limits",
919 upper_limits.len()
920 ));
921 }
922 Ok(ProjectionDecomposition {
923 ambient_mean,
924 ambient_variance,
925 truncated: Some(TruncatedProjection {
926 posterior_mean,
927 normal_center,
928 normal_covariance,
929 upper_limits,
930 projection_lift,
931 residual_variance,
932 }),
933 })
934}
935
936pub struct ConstrainedProjectionLaw {
964 pub nodes: Vec<(f64, f64)>,
968 pub residual_variance: f64,
972}
973
974impl ConstrainedProjectionLaw {
975 pub fn mean(&self) -> f64 {
977 self.nodes
978 .iter()
979 .map(|(location, weight)| location * weight)
980 .sum()
981 }
982
983 pub fn variance(&self) -> f64 {
985 let mean = self.mean();
986 let spread = self
987 .nodes
988 .iter()
989 .map(|(location, weight)| weight * (location - mean) * (location - mean))
990 .sum::<f64>();
991 spread + self.residual_variance
992 }
993}
994
995pub fn constrained_projection_law(
999 ambient_covariance: &Array2<f64>,
1000 geometry: &ConstrainedPosteriorGeometry,
1001 contrast: &Array1<f64>,
1002) -> Result<ConstrainedProjectionLaw, String> {
1003 let decomposition = decompose_projection(ambient_covariance, geometry, contrast)?;
1004 let Some(truncated) = decomposition.truncated else {
1005 return Ok(ConstrainedProjectionLaw {
1006 nodes: vec![(decomposition.ambient_mean, 1.0)],
1007 residual_variance: decomposition.ambient_variance,
1008 });
1009 };
1010 let nodes = converged_projection_nodes(
1011 &truncated.normal_center,
1012 &truncated.normal_covariance,
1013 &truncated.upper_limits,
1014 &truncated.projection_lift,
1015 decomposition.ambient_mean,
1016 )?;
1017 Ok(ConstrainedProjectionLaw {
1018 nodes: nodes
1019 .into_iter()
1020 .map(|node| (node.conditional_mean, node.weight))
1021 .collect(),
1022 residual_variance: truncated.residual_variance,
1023 })
1024}
1025
1026#[derive(Clone, Debug)]
1030pub struct ConstrainedPosteriorJointPoint {
1031 pub normal_coordinates: Array1<f64>,
1036 pub tangent: Array1<f64>,
1042 pub weight: f64,
1044}
1045
1046pub fn constrained_posterior_joint_cubature(
1074 normal_center: &Array1<f64>,
1075 normal_covariance: &Array2<f64>,
1076 upper_limits: &[f64],
1077 tangent_dimension: usize,
1078 points: usize,
1079) -> Result<Vec<ConstrainedPosteriorJointPoint>, String> {
1080 let q = normal_center.len();
1081 if q == 0 {
1082 return Err("joint constrained cubature needs at least one constraint normal".to_string());
1083 }
1084 if normal_covariance.dim() != (q, q) || upper_limits.len() != q {
1085 return Err(format!(
1086 "joint constrained cubature geometry mismatch: centre={q}, covariance={:?}, \
1087 upper limits={}",
1088 normal_covariance.dim(),
1089 upper_limits.len()
1090 ));
1091 }
1092 if points == 0 {
1093 return Err("joint constrained cubature needs a positive point count".to_string());
1094 }
1095 if upper_limits.iter().any(|limit| !(*limit > 0.0)) {
1096 return Err(format!(
1097 "joint constrained cubature: every upper limit must sit strictly above its wall, \
1098 got {upper_limits:?}"
1099 ));
1100 }
1101 let factor = gam_linalg::triangular::cholesky_factor_in_place(
1102 normal_covariance.view(),
1103 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
1104 )
1105 .ok_or_else(|| {
1106 "joint constrained cubature: the constraint-normal covariance is not numerically \
1107 positive definite"
1108 .to_string()
1109 })?;
1110 let generator = kronecker_generator(q + tangent_dimension);
1111 let tilt = minimax_tilt(normal_center, upper_limits, factor.view());
1112 let mut accumulator = JointCubatureAccumulator {
1113 points: Vec::with_capacity(points),
1114 };
1115 accumulate_orthant_nodes(
1116 &mut accumulator,
1117 normal_center,
1118 upper_limits,
1119 None,
1120 factor.view(),
1121 &generator,
1122 tilt.as_ref(),
1123 tangent_dimension,
1124 0,
1125 points,
1126 )?;
1127 accumulator.normalized()
1128}
1129
1130struct JointCubatureAccumulator {
1132 points: Vec<ConstrainedPosteriorJointPoint>,
1136}
1137
1138impl JointCubatureAccumulator {
1139 fn normalized(self) -> Result<Vec<ConstrainedPosteriorJointPoint>, String> {
1140 let max_log_weight = self
1141 .points
1142 .iter()
1143 .map(|point| point.weight)
1144 .fold(f64::NEG_INFINITY, f64::max);
1145 if !max_log_weight.is_finite() {
1146 return Err("joint constrained cubature accumulated no finite node weight".to_string());
1147 }
1148 let weight_sum = self
1149 .points
1150 .iter()
1151 .map(|point| (point.weight - max_log_weight).exp())
1152 .sum::<f64>();
1153 if !(weight_sum.is_finite() && weight_sum > 0.0) {
1154 return Err(format!(
1155 "joint constrained cubature has invalid normalized weight sum {weight_sum:?}"
1156 ));
1157 }
1158 let mut points = self.points;
1159 for point in points.iter_mut() {
1160 point.weight = (point.weight - max_log_weight).exp() / weight_sum;
1161 }
1162 Ok(points)
1163 }
1164}
1165
1166impl OrthantNodeSink for JointCubatureAccumulator {
1167 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
1168 self.push_joint(log_weight, point, &[]);
1169 }
1170
1171 fn push_joint(&mut self, log_weight: f64, point: &Array1<f64>, tangent: &[f64]) {
1172 self.points.push(ConstrainedPosteriorJointPoint {
1173 normal_coordinates: point.clone(),
1174 tangent: Array1::from_vec(tangent.to_vec()),
1175 weight: log_weight,
1176 });
1177 }
1178}
1179
1180pub fn constrained_projection_equal_tailed_interval(
1191 ambient_covariance: &Array2<f64>,
1192 geometry: &ConstrainedPosteriorGeometry,
1193 contrast: &Array1<f64>,
1194 level: f64,
1195) -> Result<(f64, f64), String> {
1196 if !(level.is_finite() && level > 0.0 && level < 1.0) {
1197 return Err(format!(
1198 "constrained projection interval level must lie in (0, 1), got {level}"
1199 ));
1200 }
1201 let decomposition = decompose_projection(ambient_covariance, geometry, contrast)?;
1202 let ambient_mean = decomposition.ambient_mean;
1203 let ambient_variance = decomposition.ambient_variance;
1204 let alpha = 0.5 * (1.0 - level);
1205
1206 let Some(truncated) = decomposition.truncated else {
1207 let sd = ambient_variance.sqrt();
1208 if sd == 0.0 {
1209 return Ok((ambient_mean, ambient_mean));
1210 }
1211 let z = standard_normal_quantile(1.0 - alpha)
1212 .map_err(|error| format!("constrained projection normal quantile: {error}"))?;
1213 return Ok((ambient_mean - z * sd, ambient_mean + z * sd));
1214 };
1215
1216 let TruncatedProjection {
1217 posterior_mean,
1218 normal_center,
1219 normal_covariance,
1220 upper_limits,
1221 projection_lift,
1222 residual_variance,
1223 } = truncated;
1224 let q = normal_center.len();
1225 if q == 1 && residual_variance == 0.0 && projection_lift[0] != 0.0 {
1226 let scalar_quantile = |probability: f64| -> Result<f64, String> {
1227 let normal_probability = if projection_lift[0] > 0.0 {
1228 probability
1229 } else {
1230 1.0 - probability
1231 };
1232 let value = scalar_truncated_quantile(
1233 normal_center[0],
1234 normal_covariance[[0, 0]],
1235 upper_limits[0],
1236 normal_probability,
1237 )?;
1238 Ok(ambient_mean + projection_lift[0] * (value - normal_center[0]))
1239 };
1240 return Ok((scalar_quantile(alpha)?, scalar_quantile(1.0 - alpha)?));
1241 }
1242 let nodes = converged_projection_nodes(
1243 &normal_center,
1244 &normal_covariance,
1245 &upper_limits,
1246 &projection_lift,
1247 ambient_mean,
1248 )?;
1249 let lower = projection_quantile(
1250 &nodes,
1251 residual_variance,
1252 alpha,
1253 posterior_mean,
1254 ambient_variance.sqrt(),
1255 )?;
1256 let upper = projection_quantile(
1257 &nodes,
1258 residual_variance,
1259 1.0 - alpha,
1260 posterior_mean,
1261 ambient_variance.sqrt(),
1262 )?;
1263 Ok((lower, upper))
1264}
1265
1266fn scalar_truncated_quantile(
1268 mean: f64,
1269 variance: f64,
1270 upper: f64,
1271 probability: f64,
1272) -> Result<f64, String> {
1273 if !(variance.is_finite() && variance > 0.0) {
1274 return Err(format!(
1275 "scalar truncated quantile needs positive finite variance, got {variance:?}"
1276 ));
1277 }
1278 if !(probability.is_finite() && probability > 0.0 && probability < 1.0) {
1279 return Err(format!(
1280 "scalar truncated quantile probability must lie in (0, 1), got {probability}"
1281 ));
1282 }
1283 if !(upper > 0.0) {
1284 return Err(format!(
1285 "scalar truncated quantile needs the upper limit above the wall, got {upper:?}"
1286 ));
1287 }
1288 let sd = variance.sqrt();
1289 let alpha = -mean / sd;
1290 if !upper.is_finite() {
1291 let log_tail = (1.0 - probability).ln() + normal_logsf(alpha);
1294 let z = -standard_normal_quantile_from_log_cdf(log_tail)
1295 .map_err(|error| format!("scalar truncated quantile: {error}"))?;
1296 return Ok(mean + sd * z);
1297 }
1298 let beta = (upper - mean) / sd;
1299 let reflect = alpha + beta < 0.0;
1303 let (low, high, probability) = if reflect {
1304 (-beta, -alpha, 1.0 - probability)
1305 } else {
1306 (alpha, beta, probability)
1307 };
1308 let log_tail_low = normal_logsf(low);
1309 let removed = normal_logsf(high) - log_tail_low;
1310 let log_tail = log_tail_low + (-probability * -removed.exp_m1()).ln_1p();
1312 let z = -standard_normal_quantile_from_log_cdf(log_tail)
1313 .map_err(|error| format!("scalar truncated quantile: {error}"))?;
1314 let z = z.clamp(low, high);
1315 Ok(if reflect {
1317 mean - sd * z
1318 } else {
1319 mean + sd * z
1320 })
1321}
1322
1323pub fn constrained_posterior_correction_from_covariance(
1338 covariance: &Array2<f64>,
1339 unconstrained_center: &Array1<f64>,
1340 constraints: &LinearInequalityConstraints,
1341) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
1342 let p = covariance.nrows();
1343 if covariance.ncols() != p {
1344 return Err(format!(
1345 "constrained posterior correction needs a square covariance, got {}x{}",
1346 covariance.nrows(),
1347 covariance.ncols()
1348 ));
1349 }
1350 if constraints.a.ncols() != p {
1351 return Err(format!(
1352 "constrained posterior correction: covariance is {p}x{p} but the constraint \
1353 system has {} columns",
1354 constraints.a.ncols()
1355 ));
1356 }
1357 let sigma_times_at = covariance.dot(&constraints.a.t());
1358 constrained_posterior_correction(sigma_times_at.view(), unconstrained_center, constraints)
1359}
1360
1361pub fn constrained_posterior_correction(
1368 sigma_times_constraint_transpose: ArrayView2<'_, f64>,
1369 unconstrained_center: &Array1<f64>,
1370 constraints: &LinearInequalityConstraints,
1371) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
1372 let p = sigma_times_constraint_transpose.nrows();
1373 if sigma_times_constraint_transpose.ncols() != constraints.a.nrows() {
1374 return Err(format!(
1375 "constrained posterior correction: the constraint system has {} rows but \
1376 Sigma·Aᵀ has {} columns",
1377 constraints.a.nrows(),
1378 sigma_times_constraint_transpose.ncols()
1379 ));
1380 }
1381 if unconstrained_center.len() != p {
1382 return Err(format!(
1383 "constrained posterior correction: Sigma·Aᵀ has {p} rows but the centre has \
1384 length {}",
1385 unconstrained_center.len()
1386 ));
1387 }
1388 if constraints.a.ncols() != p {
1389 return Err(format!(
1390 "constrained posterior correction: Sigma·Aᵀ has {p} rows but the constraint \
1391 system has {} columns",
1392 constraints.a.ncols()
1393 ));
1394 }
1395
1396 let candidates = constraint_face_candidates(
1397 sigma_times_constraint_transpose,
1398 unconstrained_center,
1399 constraints,
1400 )?;
1401 if candidates.is_empty() {
1402 return Ok(None);
1403 }
1404
1405 let demanded_accuracy = ORTHANT_MOMENT_RELATIVE_TOLERANCE;
1461 let mut first_pass = true;
1462 let mut faces_tried = 0usize;
1463 let mut ladder: Vec<LadderRung> = Vec::new();
1464 let mut excluded: Vec<usize> = Vec::new();
1465 let mut last_refused: Option<RefusedFace> = None;
1466 while excluded.len() <= candidates.len() {
1467 let Some(face) = assemble_retained_face(
1468 &candidates,
1469 demanded_accuracy,
1470 constraints,
1471 unconstrained_center,
1472 &excluded,
1473 )?
1474 else {
1475 if first_pass {
1476 return Ok(None);
1477 }
1478 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1479 return Err(format!(
1480 "no constraint face survives the accuracy its own lift must deliver: excluding \
1481 {} of {} candidate row(s) at the retention floor {demanded_accuracy:.3e} left \
1482 no retained row after {faces_tried} face(s){}",
1483 excluded.len(),
1484 candidates.len(),
1485 render_ladder(&ladder, candidates.len())
1486 ));
1487 };
1488 first_pass = false;
1489 faces_tried += 1;
1490 let lift = cholesky_solve_right(&face.factor, &face.sigma_at)?;
1493 let departure = lift_identity_departure(&lift, constraints, &face.rows)?;
1494 ladder.push(LadderRung {
1495 excluded: excluded.len(),
1496 retained: face.rows.len(),
1497 departure,
1498 });
1499 if departure > ORTHANT_MOMENT_RELATIVE_TOLERANCE {
1500 last_refused = Some(RefusedFace {
1501 rows: face.rows.clone(),
1502 w: face.w.clone(),
1503 });
1504 if face.rows.len() == 1 {
1505 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1506 return Err(format!(
1507 "a single retained constraint row still misses the identity that defines \
1508 its lift: max|A G - I| = {departure:.6e} exceeds \
1509 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e}, which one row cannot be \
1510 ill-conditioned enough to cause{}",
1511 render_ladder(&ladder, candidates.len())
1512 ));
1513 }
1514 if face.least_independent_direction.is_empty() {
1519 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1520 return Err(format!(
1521 "the constraint face misses the identity that defines its lift \
1522 (max|A G - I| = {departure:.6e} against \
1523 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e}) over {} retained row(s), and \
1524 names no least-independent direction to drop, after {faces_tried} \
1525 face(s){}",
1526 face.rows.len(),
1527 render_ladder(&ladder, candidates.len())
1528 ));
1529 }
1530 excluded.extend_from_slice(&face.least_independent_direction);
1531 continue;
1532 }
1533
1534 let q = face.rows.len();
1535 let mut normal_center = Array1::<f64>::zeros(q);
1536 for (position, &row_index) in face.rows.iter().enumerate() {
1537 normal_center[position] =
1538 constraints.a.row(row_index).dot(unconstrained_center) - constraints.b[row_index];
1539 }
1540
1541 let (normal_mean, normal_covariance) = box_truncated_moments(
1542 &normal_center,
1543 &face.upper,
1544 &face.w,
1545 face.factor.view(),
1546 )?;
1547
1548 let mut removed = &face.w - &normal_covariance;
1549 gam_linalg::matrix::symmetrize_in_place(&mut removed);
1550 certify_removed_variance(&removed, &face.w)?;
1551
1552 if !excluded.is_empty() {
1553 log::info!(
1559 "[CONSTRAINED-FACE] {} of {} candidate constraint row(s) retained after \
1560 dropping {} nearly dependent direction(s) over {faces_tried} face(s); the \
1561 retained lift satisfies its identity to {departure:.3e}",
1562 face.rows.len(),
1563 candidates.len(),
1564 excluded.len()
1565 );
1566 }
1567
1568 return Ok(Some(ConstrainedPosteriorCorrection {
1569 lift,
1570 removed_normal_variance: removed,
1571 normal_mean_shift: normal_mean - normal_center,
1572 rows: face.rows,
1573 normal_upper_limits: face.upper,
1574 }));
1575 }
1576 report_terminal_refusal(last_refused.as_ref(), &ladder, excluded.len());
1577 Err(format!(
1578 "the constraint-normal lift never reached the accuracy it is certified to: \
1579 {faces_tried} constraint face(s) were tried and every candidate row was excluded. \
1580 The walk drops one accepted direction per pass and a single-row face's lift is \
1581 exact, so this says the single-row face was never reached — which it must be.{}",
1582 render_ladder(&ladder, candidates.len())
1583 ))
1584}
1585
1586fn report_terminal_refusal(
1589 last_refused: Option<&RefusedFace>,
1590 ladder: &[LadderRung],
1591 excluded: usize,
1592) {
1593 let Some(face) = last_refused else {
1594 return;
1595 };
1596 let departure = ladder.last().map_or(f64::NAN, |rung| rung.departure);
1597 log_refused_face(face, departure, excluded);
1598}
1599
1600struct LadderRung {
1611 excluded: usize,
1612 retained: usize,
1613 departure: f64,
1614}
1615
1616fn render_ladder(ladder: &[LadderRung], candidates: usize) -> String {
1627 const SHOWN_AT_EACH_END: usize = 4;
1628 let mut rendered = format!(" [walk over {candidates} candidate row(s):");
1629 let render_rung = |rung: &LadderRung, into: &mut String| {
1630 into.push_str(&format!(
1631 " (excluded={} retained={} departure={:.3e})",
1632 rung.excluded, rung.retained, rung.departure
1633 ));
1634 };
1635 if ladder.len() <= 2 * SHOWN_AT_EACH_END + 1 {
1636 for rung in ladder {
1637 render_rung(rung, &mut rendered);
1638 }
1639 } else {
1640 for rung in &ladder[..SHOWN_AT_EACH_END] {
1641 render_rung(rung, &mut rendered);
1642 }
1643 rendered.push_str(&format!(
1644 " ... {} further rung(s) ...",
1645 ladder.len() - 2 * SHOWN_AT_EACH_END
1646 ));
1647 for rung in &ladder[ladder.len() - SHOWN_AT_EACH_END..] {
1648 render_rung(rung, &mut rendered);
1649 }
1650 }
1651 rendered.push(']');
1652 rendered
1653}
1654
1655fn log_refused_face(face: &RefusedFace, departure: f64, excluded: usize) {
1665 if !log::log_enabled!(log::Level::Warn) {
1666 return;
1667 }
1668 let q = face.rows.len();
1669 let mut rendered = String::new();
1670 for i in 0..q {
1671 for j in 0..q {
1672 rendered.push_str(&format!("{:.17e},", face.w[[i, j]]));
1673 }
1674 }
1675 log::warn!(
1676 "[CONSTRAINED-FACE] refused excluded={excluded} departure={departure:.6e} \
1677 q={q} rows={:?} w=[{rendered}]",
1678 face.rows
1679 );
1680}
1681
1682struct RefusedFace {
1689 rows: Vec<usize>,
1690 w: Array2<f64>,
1691}
1692
1693fn constraint_face_candidates(
1712 sigma_times_constraint_transpose: ArrayView2<'_, f64>,
1713 unconstrained_center: &Array1<f64>,
1714 constraints: &LinearInequalityConstraints,
1715) -> Result<Vec<(usize, f64, Array1<f64>)>, String> {
1716 let slack_horizon = -standard_normal_quantile(f64::EPSILON)
1717 .map_err(|error| format!("resolution horizon for the constraint slack: {error}"))?;
1718 let mut candidates: Vec<(usize, f64, Array1<f64>)> = Vec::new();
1719 for row_index in 0..constraints.a.nrows() {
1720 let row = constraints.a.row(row_index).to_owned();
1721 let sigma_row = sigma_times_constraint_transpose
1722 .column(row_index)
1723 .to_owned();
1724 let variance = row.dot(&sigma_row);
1725 if !(variance.is_finite() && variance > 0.0) {
1726 continue;
1729 }
1730 let slack = (row.dot(unconstrained_center) - constraints.b[row_index]) / variance.sqrt();
1731 if !slack.is_finite() {
1732 return Err(format!(
1733 "constraint row {row_index} produced a non-finite standardized slack"
1734 ));
1735 }
1736 if slack < slack_horizon {
1737 candidates.push((row_index, slack, sigma_row));
1738 }
1739 }
1740 candidates.sort_by(|left, right| {
1741 left.1
1742 .partial_cmp(&right.1)
1743 .unwrap_or(std::cmp::Ordering::Equal)
1744 .then_with(|| left.0.cmp(&right.0))
1745 });
1746 Ok(candidates)
1747}
1748
1749struct RetainedFace {
1753 rows: Vec<usize>,
1754 factor: Array2<f64>,
1755 w: Array2<f64>,
1756 sigma_at: Array2<f64>,
1757 upper: Vec<f64>,
1759 least_independent_direction: Vec<usize>,
1789}
1790
1791fn assemble_retained_face(
1805 candidates: &[(usize, f64, Array1<f64>)],
1806 demanded_accuracy: f64,
1807 constraints: &LinearInequalityConstraints,
1808 unconstrained_center: &Array1<f64>,
1809 excluded: &[usize],
1810) -> Result<Option<RetainedFace>, String> {
1811 let columns = constraints.a.ncols();
1812 let antiparallel_tolerance = 4.0 * (columns as f64 + 1.0) * f64::EPSILON;
1819 let mut rows: Vec<usize> = Vec::new();
1820 let mut least_independent: Option<(usize, f64)> = None;
1821 let mut sigma_a_columns: Vec<Array1<f64>> = Vec::new();
1822 let mut upper: Vec<f64> = Vec::new();
1823 let mut folded: Vec<Vec<usize>> = Vec::new();
1826 let mut w_accepted = Array2::<f64>::zeros((0, 0));
1827 let mut factor = Array2::<f64>::zeros((0, 0));
1828 for (row_index, _, sigma_row) in candidates {
1829 if excluded.contains(row_index) {
1830 continue;
1831 }
1832 let row = constraints.a.row(*row_index);
1833 let accepted = rows.len();
1834 let diagonal = row.dot(sigma_row);
1835 let mut cross = Array1::<f64>::zeros(accepted);
1836 for (position, column) in sigma_a_columns.iter().enumerate() {
1837 cross[position] = row.dot(column);
1838 }
1839 let mut new_column = Array1::<f64>::zeros(accepted);
1841 for i in 0..accepted {
1842 let mut sum = cross[i];
1843 for k in 0..i {
1844 sum -= factor[[i, k]] * new_column[k];
1845 }
1846 new_column[i] = sum / factor[[i, i]];
1847 }
1848 let pivot = diagonal - new_column.dot(&new_column);
1849 let rank_floor = (accepted + 1) as f64 * f64::EPSILON * diagonal / demanded_accuracy;
1872 if !(pivot.is_finite() && pivot > rank_floor) {
1873 if let Some(position) = record_opposed_face_limit(
1887 *row_index,
1888 &cross,
1889 diagonal,
1890 &AcceptedFace {
1891 w_accepted: &w_accepted,
1892 rows: &rows,
1893 constraints,
1894 unconstrained_center,
1895 antiparallel_tolerance,
1896 },
1897 &mut upper,
1898 )? {
1899 folded[position].push(*row_index);
1900 }
1901 continue;
1902 }
1903 let mut grown = Array2::<f64>::zeros((accepted + 1, accepted + 1));
1904 grown
1905 .slice_mut(ndarray::s![..accepted, ..accepted])
1906 .assign(&factor);
1907 for i in 0..accepted {
1908 grown[[accepted, i]] = new_column[i];
1909 }
1910 grown[[accepted, accepted]] = pivot.sqrt();
1911 factor = grown;
1912
1913 let mut grown_w = Array2::<f64>::zeros((accepted + 1, accepted + 1));
1914 grown_w
1915 .slice_mut(ndarray::s![..accepted, ..accepted])
1916 .assign(&w_accepted);
1917 for i in 0..accepted {
1918 grown_w[[accepted, i]] = cross[i];
1919 grown_w[[i, accepted]] = cross[i];
1920 }
1921 grown_w[[accepted, accepted]] = diagonal;
1922 w_accepted = grown_w;
1923
1924 rows.push(*row_index);
1925 sigma_a_columns.push(sigma_row.clone());
1926 upper.push(f64::INFINITY);
1927 folded.push(Vec::new());
1928 let independence = pivot / diagonal;
1941 if least_independent.is_none_or(|(_, best)| independence <= best) {
1942 least_independent = Some((rows.len() - 1, independence));
1943 }
1944 }
1945 if rows.is_empty() {
1946 return Ok(None);
1947 }
1948
1949 let q = rows.len();
1950 let p = sigma_a_columns[0].len();
1951 let mut sigma_at = Array2::<f64>::zeros((p, q));
1952 for (position, column) in sigma_a_columns.iter().enumerate() {
1953 sigma_at.column_mut(position).assign(column);
1954 }
1955 let least_independent_direction = match least_independent {
1956 Some((position, _)) => {
1957 let mut direction = vec![rows[position]];
1958 direction.extend_from_slice(&folded[position]);
1959 direction
1960 }
1961 None => Vec::new(),
1962 };
1963 Ok(Some(RetainedFace {
1964 rows,
1965 factor,
1966 w: w_accepted,
1967 sigma_at,
1968 upper,
1969 least_independent_direction,
1970 }))
1971}
1972
1973struct AcceptedFace<'a> {
1981 w_accepted: &'a Array2<f64>,
1982 rows: &'a [usize],
1983 constraints: &'a LinearInequalityConstraints,
1984 unconstrained_center: &'a Array1<f64>,
1985 antiparallel_tolerance: f64,
1986}
1987
1988fn record_opposed_face_limit(
2021 row_index: usize,
2022 cross: &Array1<f64>,
2023 diagonal: f64,
2024 face: &AcceptedFace<'_>,
2025 upper: &mut [f64],
2026) -> Result<Option<usize>, String> {
2027 let mut opposed: Option<(usize, f64, f64)> = None;
2028 for position in 0..face.rows.len() {
2029 let w_kk = face.w_accepted[[position, position]];
2030 let scale = (w_kk * diagonal).sqrt();
2031 if !(scale.is_finite() && scale > 0.0) {
2032 continue;
2033 }
2034 let correlation = cross[position] / scale;
2035 if correlation + 1.0 > face.antiparallel_tolerance {
2036 continue;
2037 }
2038 let gamma = -cross[position] / w_kk;
2039 if !(gamma.is_finite() && gamma > 0.0) {
2040 continue;
2041 }
2042 if opposed.is_none_or(|(_, best, _)| correlation < best) {
2046 opposed = Some((position, correlation, gamma));
2047 }
2048 }
2049 let Some((position, _, gamma)) = opposed else {
2050 return Ok(None);
2051 };
2052 let accepted_row = face.rows[position];
2053 let delta = (face.constraints.a.row(row_index).dot(face.unconstrained_center)
2054 - face.constraints.b[row_index])
2055 + gamma
2056 * (face.constraints.a.row(accepted_row).dot(face.unconstrained_center)
2057 - face.constraints.b[accepted_row]);
2058 let limit = delta / gamma;
2059 if !(limit.is_finite() && limit > 0.0) {
2060 return Err(format!(
2061 "constraint rows {accepted_row} and {row_index} bound the same coefficient \
2062 direction from opposite sides with no width between them (upper limit \
2063 {limit:.6e} above the lower wall): the retained region is empty or a single \
2064 point, which is an equality constraint and not a posterior this module can \
2065 report moments for"
2066 ));
2067 }
2068 if limit < upper[position] {
2069 upper[position] = limit;
2070 }
2071 Ok(Some(position))
2072}
2073
2074fn lift_identity_departure(
2084 lift: &Array2<f64>,
2085 constraints: &LinearInequalityConstraints,
2086 rows: &[usize],
2087) -> Result<f64, String> {
2088 let q = rows.len();
2089 let mut departure = 0.0_f64;
2090 for (i, &row_index) in rows.iter().enumerate() {
2091 let row = constraints.a.row(row_index);
2092 for j in 0..q {
2093 let entry = row.dot(&lift.column(j));
2094 let target = if i == j { 1.0 } else { 0.0 };
2095 let deviation = (entry - target).abs();
2096 if !deviation.is_finite() {
2097 return Err(format!(
2098 "the constraint-normal lift is not finite at retained row {row_index}, \
2099 constraint-normal coordinate {j}"
2100 ));
2101 }
2102 departure = departure.max(deviation);
2103 }
2104 }
2105 Ok(departure)
2106}
2107
2108fn cholesky_solve_right(factor: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>, String> {
2111 let q = factor.nrows();
2112 if b.ncols() != q {
2113 return Err(format!(
2114 "constraint-normal solve: factor is {q}x{q} but the right-hand side has {} columns",
2115 b.ncols()
2116 ));
2117 }
2118 let rows = b.nrows();
2119 let mut out = Array2::<f64>::zeros((rows, q));
2120 let mut work = Array1::<f64>::zeros(q);
2121 for r in 0..rows {
2122 for i in 0..q {
2123 let mut sum = b[[r, i]];
2124 for k in 0..i {
2125 sum -= factor[[i, k]] * work[k];
2126 }
2127 work[i] = sum / factor[[i, i]];
2128 }
2129 for i in (0..q).rev() {
2130 let mut sum = work[i];
2131 for k in (i + 1)..q {
2132 sum -= factor[[k, i]] * out[[r, k]];
2133 }
2134 out[[r, i]] = sum / factor[[i, i]];
2135 }
2136 }
2137 Ok(out)
2138}
2139
2140fn certify_removed_variance(removed: &Array2<f64>, w: &Array2<f64>) -> Result<(), String> {
2147 let q = removed.nrows();
2148 let slack = ORTHANT_MOMENT_RELATIVE_TOLERANCE * (q as f64);
2151 for i in 0..q {
2152 let scale = w[[i, i]];
2153 if removed[[i, i]] < -slack * scale {
2154 return Err(format!(
2155 "truncated orthant moments inflated the constraint-normal variance at index {i} \
2156 (removed {:.6e} against scale {scale:.6e}); truncation cannot increase a \
2157 Gaussian covariance",
2158 removed[[i, i]]
2159 ));
2160 }
2161 if removed[[i, i]] > (1.0 + slack) * scale {
2162 return Err(format!(
2163 "truncated orthant moments removed more variance than exists at index {i} \
2164 (removed {:.6e} against scale {scale:.6e})",
2165 removed[[i, i]]
2166 ));
2167 }
2168 for j in 0..q {
2169 if !removed[[i, j]].is_finite() {
2170 return Err(format!(
2171 "truncated orthant moments produced a non-finite entry at ({i},{j})"
2172 ));
2173 }
2174 }
2175 }
2176 Ok(())
2177}
2178
2179fn box_truncated_moments(
2192 mean: &Array1<f64>,
2193 upper: &[f64],
2194 covariance: &Array2<f64>,
2195 factor: ArrayView2<'_, f64>,
2196) -> Result<(Array1<f64>, Array2<f64>), String> {
2197 let q = mean.len();
2198 if covariance.nrows() != q || covariance.ncols() != q {
2199 return Err(format!(
2200 "truncated moments: mean has length {q} but the covariance is {}x{}",
2201 covariance.nrows(),
2202 covariance.ncols()
2203 ));
2204 }
2205 if upper.len() != q {
2206 return Err(format!(
2207 "truncated moments: mean has length {q} but {} upper limits were supplied",
2208 upper.len()
2209 ));
2210 }
2211 if upper.iter().any(|limit| !(*limit > 0.0)) {
2212 return Err(format!(
2213 "truncated moments: every upper limit must sit strictly above its wall, got {upper:?}"
2214 ));
2215 }
2216 if q == 1 {
2217 return scalar_truncated_moments(mean[0], covariance[[0, 0]], upper[0]);
2218 }
2219 if factor.nrows() != q || factor.ncols() != q {
2220 return Err(format!(
2221 "orthant moments: the constraint-normal covariance is {q}x{q} but the Cholesky \
2222 factor supplied with it is {}x{}",
2223 factor.nrows(),
2224 factor.ncols()
2225 ));
2226 }
2227
2228 let generator = kronecker_generator(q);
2229 let tilt = minimax_tilt(mean, upper, factor);
2232 let mut accumulator = OrthantAccumulator::new(q);
2233 let mut evaluated = 0usize;
2234 let mut previous: Option<(Array1<f64>, Array2<f64>)> = None;
2235 loop {
2236 let target = if evaluated == 0 {
2237 ORTHANT_MOMENT_INITIAL_POINTS
2238 } else {
2239 evaluated * 2
2240 };
2241 accumulate_orthant_nodes(
2242 &mut accumulator,
2243 mean,
2244 upper,
2245 None,
2246 factor,
2247 &generator,
2248 tilt.as_ref(),
2249 0,
2250 evaluated,
2251 target,
2252 )?;
2253 evaluated = target;
2254 let current = accumulator.moments()?;
2255 if let Some(ref last) = previous
2256 && moment_relative_change(last, ¤t, covariance)
2257 <= ORTHANT_MOMENT_RELATIVE_TOLERANCE
2258 {
2259 return Ok(current);
2260 }
2261 if evaluated >= ORTHANT_MOMENT_MAXIMUM_POINTS {
2262 let change = previous
2263 .as_ref()
2264 .map(|last| moment_relative_change(last, ¤t, covariance))
2265 .unwrap_or(f64::INFINITY);
2266 let depth: Vec<f64> = (0..q)
2276 .map(|i| -mean[i] / covariance[[i, i]].sqrt())
2277 .collect();
2278 let depth_min = depth.iter().copied().fold(f64::INFINITY, f64::min);
2279 let depth_max = depth.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2280 let mut corr_max: f64 = 0.0;
2281 for i in 0..q {
2282 for j in 0..i {
2283 let denominator = (covariance[[i, i]] * covariance[[j, j]]).sqrt();
2284 if denominator > 0.0 {
2285 corr_max = corr_max.max((covariance[[i, j]] / denominator).abs());
2286 }
2287 }
2288 }
2289 log::debug!(
2290 "[orthant-face] q={q} mean={:?} covariance={:?}",
2291 mean.as_slice().map(<[f64]>::to_vec),
2292 covariance.as_slice().map(<[f64]>::to_vec),
2293 );
2294 return Err(format!(
2295 "truncated moments for a {q}-dimensional constraint face did not converge: \
2296 relative moment change {change:.3e} still exceeds \
2297 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e} at {evaluated} cubature nodes \
2298 (wall depth {depth_min:.2}..{depth_max:.2} sd, \
2299 max |correlation| between constraint normals {corr_max:.3})"
2300 ));
2301 }
2302 previous = Some(current);
2303 }
2304}
2305
2306struct OrthantAccumulator {
2314 log_scale: f64,
2315 weight_sum: f64,
2316 weighted_mean: Array1<f64>,
2317 weighted_second: Array2<f64>,
2318}
2319
2320trait OrthantNodeSink {
2321 fn push(&mut self, log_weight: f64, point: &Array1<f64>);
2322
2323 fn push_joint(&mut self, log_weight: f64, point: &Array1<f64>, tangent: &[f64]) {
2334 assert!(
2339 tangent.is_empty(),
2340 "a sink with no joint tangent block was handed {} tangent coordinates",
2341 tangent.len()
2342 );
2343 self.push(log_weight, point);
2344 }
2345}
2346
2347impl OrthantAccumulator {
2348 fn new(q: usize) -> Self {
2349 Self {
2350 log_scale: f64::NEG_INFINITY,
2351 weight_sum: 0.0,
2352 weighted_mean: Array1::zeros(q),
2353 weighted_second: Array2::zeros((q, q)),
2354 }
2355 }
2356
2357 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
2358 let q = point.len();
2359 if log_weight > self.log_scale {
2360 let rescale = (self.log_scale - log_weight).exp();
2361 self.weight_sum *= rescale;
2362 self.weighted_mean *= rescale;
2363 self.weighted_second *= rescale;
2364 self.log_scale = log_weight;
2365 }
2366 let weight = (log_weight - self.log_scale).exp();
2367 self.weight_sum += weight;
2368 for i in 0..q {
2369 self.weighted_mean[i] += weight * point[i];
2370 for j in 0..=i {
2371 self.weighted_second[[i, j]] += weight * point[i] * point[j];
2372 }
2373 }
2374 }
2375
2376 fn moments(&self) -> Result<(Array1<f64>, Array2<f64>), String> {
2377 if !(self.weight_sum.is_finite() && self.weight_sum > 0.0) {
2378 return Err(format!(
2379 "orthant cubature accumulated no feasible mass (weight sum {:?}); the \
2380 constraint face has no representable interior",
2381 self.weight_sum
2382 ));
2383 }
2384 let q = self.weighted_mean.len();
2385 let mean = &self.weighted_mean / self.weight_sum;
2386 let mut covariance = Array2::<f64>::zeros((q, q));
2387 for i in 0..q {
2388 for j in 0..=i {
2389 let centered = self.weighted_second[[i, j]] / self.weight_sum - mean[i] * mean[j];
2390 covariance[[i, j]] = centered;
2391 covariance[[j, i]] = centered;
2392 }
2393 }
2394 Ok((mean, covariance))
2395 }
2396}
2397
2398impl OrthantNodeSink for OrthantAccumulator {
2399 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
2400 OrthantAccumulator::push(self, log_weight, point);
2401 }
2402}
2403
2404#[derive(Clone, Debug)]
2418pub struct StandardizedCeiling {
2419 coefficients: Array1<f64>,
2421 bound: f64,
2423 pivot: usize,
2424}
2425
2426impl StandardizedCeiling {
2427 pub fn new(
2434 normal: &Array1<f64>,
2435 bound: f64,
2436 mean: &Array1<f64>,
2437 factor: ArrayView2<'_, f64>,
2438 ) -> Result<Self, String> {
2439 let q = mean.len();
2440 if normal.len() != q {
2441 return Err(format!(
2442 "affine ceiling: the normal has length {} but the law has {q} coordinates",
2443 normal.len()
2444 ));
2445 }
2446 let mut coefficients = Array1::<f64>::zeros(q);
2447 for j in 0..q {
2448 let mut total = 0.0;
2449 for k in j..q {
2450 total += factor[[k, j]] * normal[k];
2451 }
2452 coefficients[j] = total;
2453 }
2454 let scale = coefficients
2455 .iter()
2456 .fold(0.0f64, |worst, value| worst.max(value.abs()));
2457 if !(scale.is_finite() && scale > 0.0) {
2458 return Err(format!(
2459 "affine ceiling: the standardized normal vanished (scale {scale:?}); the wall constrains no cubature coordinate"
2460 ));
2461 }
2462 let floor = 8.0 * f64::EPSILON * scale;
2463 let pivot = (0..q)
2464 .rev()
2465 .find(|j| coefficients[*j].abs() > floor)
2466 .ok_or_else(|| "affine ceiling: no coordinate clears the pivot floor".to_string())?;
2467 let offset = normal.dot(mean);
2468 if !(bound - offset).is_finite() {
2469 return Err(format!(
2470 "affine ceiling: the standardized bound is not finite (bound {bound:?}, offset {offset:?})"
2471 ));
2472 }
2473 Ok(Self {
2474 coefficients,
2475 bound: bound - offset,
2476 pivot,
2477 })
2478 }
2479
2480 fn limit(&self, z: &Array1<f64>) -> (f64, f64) {
2484 let mut remaining = self.bound;
2485 for j in 0..self.pivot {
2486 remaining -= self.coefficients[j] * z[j];
2487 }
2488 let coefficient = self.coefficients[self.pivot];
2489 let limit = remaining / coefficient;
2490 if coefficient > 0.0 {
2491 (f64::NEG_INFINITY, limit)
2492 } else {
2493 (limit, f64::INFINITY)
2494 }
2495 }
2496}
2497
2498fn minimax_tilt(
2567 mean: &Array1<f64>,
2568 upper: &[f64],
2569 factor: ArrayView2<'_, f64>,
2570) -> Option<Array1<f64>> {
2571 let q = mean.len();
2572 if q == 0 || factor.nrows() != q || factor.ncols() != q || upper.len() != q {
2573 return None;
2574 }
2575 if upper.iter().any(|limit| limit.is_finite()) {
2576 return None;
2577 }
2578 if mean.iter().all(|&m| m >= 0.0) {
2579 return None;
2580 }
2581 for i in 0..q {
2582 if !(factor[[i, i]] > 0.0) {
2583 return None;
2584 }
2585 }
2586
2587 let hazard = |t: f64| -> f64 {
2591 let log_density = -0.5 * t * t - 0.5 * (std::f64::consts::TAU).ln();
2592 let log_tail = normal_logsf(t);
2593 if !log_tail.is_finite() {
2594 return t.max(0.0);
2597 }
2598 (log_density - log_tail).exp()
2599 };
2600
2601 const MAX_PASSES: usize = 200;
2602 const DAMPING: f64 = 0.5;
2610 let mut mu = Array1::<f64>::zeros(q);
2611 let mut z = Array1::<f64>::zeros(q);
2612 let mut rho = Array1::<f64>::zeros(q);
2613 for _ in 0..MAX_PASSES {
2614 for i in 0..q {
2617 let mut bound = -mean[i];
2618 for j in 0..i {
2619 bound -= factor[[i, j]] * z[j];
2620 }
2621 let wall = bound / factor[[i, i]];
2622 let value = hazard(wall - mu[i]);
2623 if !value.is_finite() {
2624 return None;
2625 }
2626 rho[i] = value;
2627 z[i] = mu[i] + value;
2628 }
2629 let mut moved = 0.0f64;
2631 for k in 0..q {
2632 let mut sum = 0.0;
2633 for i in (k + 1)..q {
2634 sum += rho[i] * factor[[i, k]] / factor[[i, i]];
2635 }
2636 let target = sum;
2637 let updated = mu[k] + DAMPING * (target - mu[k]);
2638 moved = moved.max((updated - mu[k]).abs());
2639 mu[k] = updated;
2640 }
2641 if moved <= 1e-12 {
2642 break;
2643 }
2644 }
2645 if mu.iter().any(|value| !value.is_finite()) {
2646 return None;
2647 }
2648 Some(mu)
2649}
2650
2651fn accumulate_orthant_nodes<S: OrthantNodeSink>(
2652 accumulator: &mut S,
2653 mean: &Array1<f64>,
2654 upper: &[f64],
2655 ceiling: Option<&StandardizedCeiling>,
2656 factor: ArrayView2<'_, f64>,
2657 generator: &[f64],
2658 tilt: Option<&Array1<f64>>,
2663 tangent_dimension: usize,
2670 first: usize,
2671 last: usize,
2672) -> Result<(), String> {
2673 let q = mean.len();
2674 if generator.len() < q + tangent_dimension {
2675 return Err(format!(
2676 "orthant cubature needs a {}-dimensional generator for {q} constraint normals and \
2677 {tangent_dimension} tangent coordinates, got {}",
2678 q + tangent_dimension,
2679 generator.len()
2680 ));
2681 }
2682 let mut z = Array1::<f64>::zeros(q);
2683 let mut point = Array1::<f64>::zeros(q);
2684 let mut tangent = vec![0.0f64; tangent_dimension];
2685 for node in first..last {
2686 let offset = node as f64 + 0.5;
2687 let mut log_weight = 0.0f64;
2688 for i in 0..q {
2689 let mu = tilt.map(|t| t[i]).unwrap_or(0.0);
2693 let mut bound = -mean[i];
2694 for j in 0..i {
2695 bound -= factor[[i, j]] * z[j];
2696 }
2697 let mut wall = bound / factor[[i, i]] - mu;
2698 let mut affine_ceiling = f64::INFINITY;
2704 if let Some(wall_rule) = ceiling
2705 && wall_rule.pivot == i
2706 {
2707 let (raised, capped) = wall_rule.limit(&z);
2708 if raised - mu > wall {
2709 wall = raised - mu;
2710 }
2711 affine_ceiling = capped - mu;
2712 }
2713 let lattice = {
2718 let raw = offset * generator[i];
2719 let fractional = raw - raw.floor();
2720 1.0 - (2.0 * fractional - 1.0).abs()
2721 };
2722 if !upper[i].is_finite() && !affine_ceiling.is_finite() {
2723 let log_tail = normal_logsf(wall);
2724 if !log_tail.is_finite() {
2725 log_weight = f64::NEG_INFINITY;
2730 break;
2731 }
2732 log_weight += log_tail;
2733 let log_fraction = (1.0 - lattice).max(f64::MIN_POSITIVE).ln();
2741 let log_upper_tail = log_fraction + log_tail;
2742 let resolved = if log_upper_tail < 0.0 {
2743 log_upper_tail
2744 } else {
2745 -f64::MIN_POSITIVE
2746 };
2747 let shifted = -standard_normal_quantile_from_log_cdf(resolved)
2748 .map_err(|error| format!("orthant cubature coordinate {i}: {error}"))?;
2749 z[i] = shifted + mu;
2750 log_weight += 0.5 * mu * mu - mu * z[i];
2753 continue;
2754 }
2755
2756 let boxed = if upper[i].is_finite() {
2762 wall + upper[i] / factor[[i, i]]
2763 } else {
2764 f64::INFINITY
2765 };
2766 let ceiling = boxed.min(affine_ceiling);
2767 if !(ceiling > wall) {
2768 log_weight = f64::NEG_INFINITY;
2771 break;
2772 }
2773 let reflect = wall + ceiling < 0.0;
2782 let (low, high) = if reflect {
2783 (-ceiling, -wall)
2784 } else {
2785 (wall, ceiling)
2786 };
2787 let log_tail_low = normal_logsf(low);
2788 let log_tail_high = normal_logsf(high);
2789 if !log_tail_low.is_finite() {
2790 log_weight = f64::NEG_INFINITY;
2791 break;
2792 }
2793 let removed = log_tail_high - log_tail_low;
2796 let log_mass = log_tail_low + log1mexp(removed);
2797 if !log_mass.is_finite() {
2798 log_weight = f64::NEG_INFINITY;
2801 break;
2802 }
2803 log_weight += log_mass;
2804 let retained = (-lattice * (-removed.exp_m1())).ln_1p();
2808 let log_upper_tail = log_tail_low + retained;
2809 let resolved = if log_upper_tail < 0.0 {
2810 log_upper_tail
2811 } else {
2812 -f64::MIN_POSITIVE
2813 };
2814 let sampled = -standard_normal_quantile_from_log_cdf(resolved)
2815 .map_err(|error| format!("truncated cubature coordinate {i}: {error}"))?;
2816 let clamped = sampled.clamp(low, high);
2820 z[i] = if reflect { -clamped } else { clamped } + mu;
2821 log_weight += 0.5 * mu * mu - mu * z[i];
2822 }
2823 if !log_weight.is_finite() {
2824 continue;
2825 }
2826 for i in 0..q {
2827 let mut value = mean[i];
2828 for j in 0..=i {
2829 value += factor[[i, j]] * z[j];
2830 }
2831 point[i] = value;
2832 }
2833 for (slot, tangent_value) in tangent.iter_mut().enumerate() {
2840 let raw = offset * generator[q + slot];
2841 let fractional = raw - raw.floor();
2842 let upper_side = (2.0 * fractional - 1.0).abs();
2843 let lattice = 1.0 - upper_side;
2844 *tangent_value = if lattice <= 0.5 {
2845 standard_normal_quantile_from_log_cdf(lattice.max(f64::MIN_POSITIVE).ln())
2846 .map_err(|error| format!("joint cubature tangent coordinate {slot}: {error}"))?
2847 } else {
2848 -standard_normal_quantile_from_log_cdf(upper_side.max(f64::MIN_POSITIVE).ln())
2849 .map_err(|error| format!("joint cubature tangent coordinate {slot}: {error}"))?
2850 };
2851 }
2852 accumulator.push_joint(log_weight, &point, &tangent);
2853 }
2854 Ok(())
2855}
2856
2857#[derive(Clone, Copy)]
2858struct WeightedProjectionNode {
2859 conditional_mean: f64,
2860 weight: f64,
2861}
2862
2863struct ProjectionNodeAccumulator<'a> {
2864 moments: OrthantAccumulator,
2865 normal_center: &'a Array1<f64>,
2866 projection_lift: &'a Array1<f64>,
2867 ambient_mean: f64,
2868 nodes: Vec<(f64, f64)>,
2869}
2870
2871impl<'a> ProjectionNodeAccumulator<'a> {
2872 fn new(
2873 normal_center: &'a Array1<f64>,
2874 projection_lift: &'a Array1<f64>,
2875 ambient_mean: f64,
2876 ) -> Self {
2877 Self {
2878 moments: OrthantAccumulator::new(normal_center.len()),
2879 normal_center,
2880 projection_lift,
2881 ambient_mean,
2882 nodes: Vec::new(),
2883 }
2884 }
2885
2886 fn normalized_nodes(self) -> Result<Vec<WeightedProjectionNode>, String> {
2887 let max_log_weight = self
2888 .nodes
2889 .iter()
2890 .map(|(_, log_weight)| *log_weight)
2891 .fold(f64::NEG_INFINITY, f64::max);
2892 if !max_log_weight.is_finite() {
2893 return Err(
2894 "orthant projection cubature accumulated no finite node weight".to_string(),
2895 );
2896 }
2897 let weight_sum = self
2898 .nodes
2899 .iter()
2900 .map(|(_, log_weight)| (*log_weight - max_log_weight).exp())
2901 .sum::<f64>();
2902 if !(weight_sum.is_finite() && weight_sum > 0.0) {
2903 return Err(format!(
2904 "orthant projection cubature has invalid normalized weight sum {weight_sum:?}"
2905 ));
2906 }
2907 Ok(self
2908 .nodes
2909 .into_iter()
2910 .map(|(conditional_mean, log_weight)| WeightedProjectionNode {
2911 conditional_mean,
2912 weight: (log_weight - max_log_weight).exp() / weight_sum,
2913 })
2914 .collect())
2915 }
2916}
2917
2918impl OrthantNodeSink for ProjectionNodeAccumulator<'_> {
2919 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
2920 self.moments.push(log_weight, point);
2921 let conditional_mean = self.ambient_mean
2922 + self
2923 .projection_lift
2924 .iter()
2925 .zip(point.iter().zip(self.normal_center.iter()))
2926 .map(|(&lift, (&value, ¢er))| lift * (value - center))
2927 .sum::<f64>();
2928 self.nodes.push((conditional_mean, log_weight));
2929 }
2930}
2931
2932fn converged_projection_nodes(
2933 mean: &Array1<f64>,
2934 covariance: &Array2<f64>,
2935 upper: &[f64],
2936 projection_lift: &Array1<f64>,
2937 ambient_mean: f64,
2938) -> Result<Vec<WeightedProjectionNode>, String> {
2939 let q = mean.len();
2940 if covariance.dim() != (q, q) || projection_lift.len() != q || upper.len() != q {
2941 return Err(format!(
2942 "truncated projection geometry mismatch: mean={q}, covariance={:?}, lift={}, \
2943 upper limits={}",
2944 covariance.dim(),
2945 projection_lift.len(),
2946 upper.len()
2947 ));
2948 }
2949 let factor = gam_linalg::triangular::cholesky_factor_in_place(
2950 covariance.view(),
2951 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
2952 )
2953 .ok_or_else(|| {
2954 "orthant projection: the constraint-normal covariance is not numerically positive definite"
2955 .to_string()
2956 })?;
2957 let generator = kronecker_generator(q);
2958 let tilt = minimax_tilt(mean, upper, factor.view());
2959 let mut accumulator = ProjectionNodeAccumulator::new(mean, projection_lift, ambient_mean);
2960 let mut evaluated = 0usize;
2961 let mut previous: Option<(Array1<f64>, Array2<f64>)> = None;
2962 loop {
2963 let target = if evaluated == 0 {
2964 ORTHANT_MOMENT_INITIAL_POINTS
2965 } else {
2966 evaluated * 2
2967 };
2968 accumulate_orthant_nodes(
2969 &mut accumulator,
2970 mean,
2971 upper,
2972 None,
2973 factor.view(),
2974 &generator,
2975 tilt.as_ref(),
2976 0,
2977 evaluated,
2978 target,
2979 )?;
2980 evaluated = target;
2981 let current = accumulator.moments.moments()?;
2982 if let Some(ref last) = previous
2983 && moment_relative_change(last, ¤t, covariance)
2984 <= ORTHANT_MOMENT_RELATIVE_TOLERANCE
2985 {
2986 return accumulator.normalized_nodes();
2987 }
2988 if evaluated >= ORTHANT_MOMENT_MAXIMUM_POINTS {
2989 let change = previous
2990 .as_ref()
2991 .map(|last| moment_relative_change(last, ¤t, covariance))
2992 .unwrap_or(f64::INFINITY);
2993 return Err(format!(
2994 "orthant projection for a {q}-dimensional constraint face did not converge: \
2995 relative moment change {change:.3e} still exceeds \
2996 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e} at {evaluated} cubature nodes"
2997 ));
2998 }
2999 previous = Some(current);
3000 }
3001}
3002
3003fn projection_quantile(
3004 nodes: &[WeightedProjectionNode],
3005 residual_variance: f64,
3006 probability: f64,
3007 posterior_mean: f64,
3008 ambient_sd: f64,
3009) -> Result<f64, String> {
3010 if nodes.is_empty() {
3011 return Err("orthant projection quantile received no cubature nodes".to_string());
3012 }
3013 if residual_variance == 0.0 {
3014 let mut ordered = nodes.to_vec();
3015 ordered.sort_by(|left, right| left.conditional_mean.total_cmp(&right.conditional_mean));
3016 let mut cumulative = 0.0;
3017 for node in &ordered {
3018 cumulative += node.weight;
3019 if cumulative >= probability {
3020 return Ok(node.conditional_mean);
3021 }
3022 }
3023 return Ok(ordered
3024 .last()
3025 .expect("non-empty projection node set")
3026 .conditional_mean);
3027 }
3028
3029 let residual_sd = residual_variance.sqrt();
3030 let cdf = |value: f64| {
3031 nodes
3032 .iter()
3033 .map(|node| {
3034 node.weight * normal_cdf((value - node.conditional_mean) / residual_sd)
3035 })
3036 .sum::<f64>()
3037 };
3038 let mut step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
3039 let mut lower = posterior_mean - step;
3040 let mut upper = posterior_mean + step;
3041 while cdf(lower) > probability {
3042 step *= 2.0;
3043 lower = posterior_mean - step;
3044 if !lower.is_finite() {
3045 return Err(format!(
3046 "orthant projection quantile could not bracket lower probability {probability}"
3047 ));
3048 }
3049 }
3050 step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
3051 while cdf(upper) < probability {
3052 step *= 2.0;
3053 upper = posterior_mean + step;
3054 if !upper.is_finite() {
3055 return Err(format!(
3056 "orthant projection quantile could not bracket upper probability {probability}"
3057 ));
3058 }
3059 }
3060
3061 let resolution = f64::EPSILON.sqrt() * ambient_sd.max(residual_sd);
3062 loop {
3063 let midpoint = lower + 0.5 * (upper - lower);
3064 if midpoint == lower || midpoint == upper || upper - lower <= resolution {
3065 return Ok(midpoint);
3066 }
3067 if cdf(midpoint) < probability {
3068 lower = midpoint;
3069 } else {
3070 upper = midpoint;
3071 }
3072 }
3073}
3074
3075fn log1mexp(d: f64) -> f64 {
3083 if d == f64::NEG_INFINITY {
3084 return 0.0;
3085 }
3086 if d >= 0.0 {
3087 return f64::NEG_INFINITY;
3088 }
3089 if d > -std::f64::consts::LN_2 {
3090 (-d.exp_m1()).ln()
3091 } else {
3092 (-d.exp()).ln_1p()
3093 }
3094}
3095
3096fn scalar_truncated_moments(
3102 mean: f64,
3103 variance: f64,
3104 upper: f64,
3105) -> Result<(Array1<f64>, Array2<f64>), String> {
3106 if !(variance.is_finite() && variance > 0.0) {
3107 return Err(format!(
3108 "scalar truncated moments need a positive finite variance, got {variance:?}"
3109 ));
3110 }
3111 let sd = variance.sqrt();
3112 let alpha = -mean / sd;
3116 if !upper.is_finite() {
3117 let mills = signed_probit_logcdf_and_mills_ratio(-alpha).1;
3118 if !(mills.is_finite() && mills >= 0.0) {
3119 return Err(format!(
3120 "scalar truncated moments: inverse Mills ratio at {alpha} is {mills:?}"
3121 ));
3122 }
3123 let truncated_mean = mean + sd * mills;
3124 let truncated_variance = variance * (1.0 + alpha * mills - mills * mills);
3125 if !(truncated_variance.is_finite() && truncated_variance >= 0.0) {
3126 return Err(format!(
3127 "scalar truncated moments produced variance {truncated_variance:?} at \
3128 standardized truncation point {alpha}"
3129 ));
3130 }
3131 return Ok((
3132 Array1::from_elem(1, truncated_mean),
3133 Array2::from_elem((1, 1), truncated_variance),
3134 ));
3135 }
3136 if !(upper > 0.0) {
3137 return Err(format!(
3138 "scalar truncated moments need the upper limit above the wall, got {upper:?}"
3139 ));
3140 }
3141 let beta = (upper - mean) / sd;
3142 let reflect = alpha + beta < 0.0;
3146 let (low, high, centre) = if reflect {
3147 (-beta, -alpha, -mean)
3148 } else {
3149 (alpha, beta, mean)
3150 };
3151 let log_tail_low = normal_logsf(low);
3152 let log_tail_high = normal_logsf(high);
3153 let log_mass = log_tail_low + log1mexp(log_tail_high - log_tail_low);
3154 if !log_mass.is_finite() {
3155 return Err(format!(
3156 "scalar truncated moments: the interval [0, {upper:.6e}] around mean {mean:.6e} \
3157 with standard deviation {sd:.6e} carries no representable mass"
3158 ));
3159 }
3160 let log_density_ratio = 0.5 * (low - high) * (low + high);
3164 let density_ratio = log_density_ratio.exp();
3165 let scale = (-0.5 * low * low - 0.5 * (2.0 * std::f64::consts::PI).ln() - log_mass).exp();
3166 let first = scale * -log_density_ratio.exp_m1();
3167 let second = scale * (low - high * density_ratio);
3168 let truncated_mean = centre + sd * first;
3169 let truncated_variance = variance * (1.0 + second - first * first);
3170 if !(truncated_variance.is_finite() && truncated_variance >= 0.0) {
3171 return Err(format!(
3172 "scalar truncated moments produced variance {truncated_variance:?} on the \
3173 standardized interval [{low}, {high}]"
3174 ));
3175 }
3176 Ok((
3177 Array1::from_elem(1, if reflect { -truncated_mean } else { truncated_mean }),
3178 Array2::from_elem((1, 1), truncated_variance),
3179 ))
3180}
3181
3182fn moment_relative_change(
3186 previous: &(Array1<f64>, Array2<f64>),
3187 current: &(Array1<f64>, Array2<f64>),
3188 w: &Array2<f64>,
3189) -> f64 {
3190 let q = current.0.len();
3191 let mut worst = 0.0f64;
3192 for i in 0..q {
3193 let sd_i = w[[i, i]].sqrt();
3194 worst = worst.max((current.0[i] - previous.0[i]).abs() / sd_i);
3195 for j in 0..q {
3196 let sd_j = w[[j, j]].sqrt();
3197 worst =
3198 worst.max((current.1[[i, j]] - previous.1[[i, j]]).abs() / (sd_i * sd_j));
3199 }
3200 }
3201 worst
3202}
3203
3204fn kronecker_generator(dimension: usize) -> Vec<f64> {
3209 let mut generator = Vec::with_capacity(dimension);
3210 let mut candidate = 2u64;
3211 while generator.len() < dimension {
3212 if is_prime(candidate) {
3213 let root = (candidate as f64).sqrt();
3214 generator.push(root - root.floor());
3215 }
3216 candidate += 1;
3217 }
3218 generator
3219}
3220
3221fn is_prime(value: u64) -> bool {
3222 if value < 2 {
3223 return false;
3224 }
3225 let mut divisor = 2u64;
3226 while divisor * divisor <= value {
3227 if value % divisor == 0 {
3228 return false;
3229 }
3230 divisor += 1;
3231 }
3232 true
3233}
3234
3235#[cfg(test)]
3236mod tests {
3237 use super::*;
3238 use ndarray::array;
3239
3240
3241 fn quadrature_truncated_moments(mean: f64, variance: f64) -> (f64, f64) {
3245 let sd = variance.sqrt();
3246 let alpha = -mean / sd;
3247 let panels = 400_000usize;
3248 let upper = alpha + 60.0;
3249 let step = (upper - alpha) / panels as f64;
3250 let mut mass = 0.0f64;
3251 let mut first = 0.0f64;
3252 let mut second = 0.0f64;
3253 for index in 0..=panels {
3254 let z = alpha + step * index as f64;
3255 let simpson = if index == 0 || index == panels {
3256 1.0
3257 } else if index % 2 == 1 {
3258 4.0
3259 } else {
3260 2.0
3261 };
3262 let density = (-(z * z - alpha * alpha) / 2.0).exp();
3263 mass += simpson * density;
3264 first += simpson * density * z;
3265 second += simpson * density * z * z;
3266 }
3267 let m1 = first / mass;
3268 let m2 = second / mass;
3269 (mean + sd * m1, variance * (m2 - m1 * m1))
3270 }
3271
3272 #[test]
3275 fn scalar_truncated_moments_match_the_closed_form_at_every_regime() {
3276 let (mean, variance) = scalar_truncated_moments(0.0, 1.0, f64::INFINITY).expect("half normal");
3278 let expected_mean = (2.0 / std::f64::consts::PI).sqrt();
3279 assert!(
3280 (mean[0] - expected_mean).abs() < 1e-12,
3281 "half-normal mean {} vs {expected_mean}",
3282 mean[0]
3283 );
3284 let expected_variance = 1.0 - 2.0 / std::f64::consts::PI;
3285 assert!(
3286 (variance[[0, 0]] - expected_variance).abs() < 1e-12,
3287 "half-normal variance {} vs {expected_variance}",
3288 variance[[0, 0]]
3289 );
3290 assert!(
3291 variance[[0, 0]] > 0.36 && variance[[0, 0]] < 0.37,
3292 "a coefficient whose mode sits exactly on its bound keeps a THIRD of its \
3293 unconstrained variance, not zero: got {}",
3294 variance[[0, 0]]
3295 );
3296
3297 for center in [-2.0, -4.0, -8.0] {
3302 let (deep_mean, deep) = scalar_truncated_moments(center, 1.0, f64::INFINITY).expect("deep tail");
3303 let (reference_mean, reference_variance) = quadrature_truncated_moments(center, 1.0);
3304 assert!(
3305 (deep_mean[0] - reference_mean).abs() < 1e-9 * reference_mean.abs().max(1.0),
3306 "closed-form mean {} vs quadrature {reference_mean} at centre {center}",
3307 deep_mean[0]
3308 );
3309 assert!(
3310 (deep[[0, 0]] / reference_variance - 1.0).abs() < 1e-8,
3311 "closed-form variance {} vs quadrature {reference_variance} at centre {center}",
3312 deep[[0, 0]]
3313 );
3314 assert!(
3315 deep[[0, 0]] > 0.0,
3316 "a finite multiplier never gives zero variance, got {} at centre {center}",
3317 deep[[0, 0]]
3318 );
3319 }
3320 let (_, at_eight) = scalar_truncated_moments(-8.0, 1.0, f64::INFINITY).expect("deep tail");
3323 assert!(
3324 at_eight[[0, 0]] * 64.0 > 0.9 && at_eight[[0, 0]] * 64.0 < 1.0,
3325 "variance times alpha^2 should approach one from below, got {}",
3326 at_eight[[0, 0]] * 64.0
3327 );
3328
3329 let (far_mean, far_variance) = scalar_truncated_moments(10.0, 4.0, f64::INFINITY).expect("inactive");
3335 let (reference_mean, reference_variance) = quadrature_truncated_moments(10.0, 4.0);
3336 assert!(
3337 (far_mean[0] - reference_mean).abs() < 1e-9,
3338 "inactive-bound mean {} vs quadrature {reference_mean}",
3339 far_mean[0]
3340 );
3341 assert!(
3342 (far_variance[[0, 0]] - reference_variance).abs() < 1e-9,
3343 "inactive-bound variance {} vs quadrature {reference_variance}",
3344 far_variance[[0, 0]]
3345 );
3346 assert!(
3347 (far_mean[0] - 10.0).abs() < 1e-5 && far_mean[0] > 10.0,
3348 "a bound five sd away moves the mean by the tail mass and no more, got {}",
3349 far_mean[0]
3350 );
3351 assert!(
3352 (far_variance[[0, 0]] - 4.0).abs() < 1e-4 && far_variance[[0, 0]] < 4.0,
3353 "a bound five sd away shrinks the variance by the tail mass and no more, got {}",
3354 far_variance[[0, 0]]
3355 );
3356 }
3357
3358 #[test]
3359 fn equal_tailed_projection_interval_is_asymmetric_for_a_half_normal() {
3360 let covariance = array![[1.0]];
3361 let center = array![0.0];
3362 let constraints =
3363 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
3364 let correction =
3365 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
3366 .expect("correction")
3367 .expect("active half-space");
3368 let geometry = ConstrainedPosteriorGeometry {
3369 constraints,
3370 mode: array![0.0],
3371 unconstrained_center: Some(center),
3372 correction: Some(correction),
3373 moment_status: ConstrainedPosteriorMomentStatus::Available,
3374 };
3375 let (lower, upper) = constrained_projection_equal_tailed_interval(
3376 &covariance,
3377 &geometry,
3378 &array![1.0],
3379 0.95,
3380 )
3381 .expect("equal-tailed interval");
3382
3383 let expected_lower = standard_normal_quantile(0.5125).expect("lower quantile");
3386 let expected_upper = standard_normal_quantile(0.9875).expect("upper quantile");
3387 assert!(
3388 (lower - expected_lower).abs() < 2e-3,
3389 "half-normal lower endpoint {lower} vs {expected_lower}"
3390 );
3391 assert!(
3392 (upper - expected_upper).abs() < 2e-3,
3393 "half-normal upper endpoint {upper} vs {expected_upper}"
3394 );
3395 let posterior_mean = (2.0 / std::f64::consts::PI).sqrt();
3396 assert!(
3397 (posterior_mean - lower) < (upper - posterior_mean),
3398 "the exact skew interval must not collapse back to mean +/- z*sd"
3399 );
3400 }
3401
3402 #[test]
3403 fn equal_tailed_projection_sweep_has_exact_mass_and_repairs_the_short_symmetric_band() {
3404 let covariance = array![[1.0]];
3405 let constraints =
3406 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
3407 let alpha = 0.025;
3408 let ambient_width =
3409 2.0 * standard_normal_quantile(1.0 - alpha).expect("ambient quantile");
3410 let mut saw_repaired_short_symmetric_band = false;
3411
3412 for center_value in [0.0, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 5.0] {
3413 let center = array![center_value];
3414 let correction = constrained_posterior_correction_from_covariance(
3415 &covariance,
3416 ¢er,
3417 &constraints,
3418 )
3419 .expect("correction")
3420 .expect("finite lower truncation");
3421 let posterior_variance =
3422 1.0 - correction.removed_variance_diagonal()[0];
3423 let geometry = ConstrainedPosteriorGeometry {
3424 constraints: constraints.clone(),
3425 mode: array![center_value.max(0.0)],
3426 unconstrained_center: Some(center),
3427 correction: Some(correction),
3428 moment_status: ConstrainedPosteriorMomentStatus::Available,
3429 };
3430 let (lower, upper) = constrained_projection_equal_tailed_interval(
3431 &covariance,
3432 &geometry,
3433 &array![1.0],
3434 0.95,
3435 )
3436 .expect("equal-tailed interval");
3437
3438 let mass_below_bound = normal_cdf(-center_value);
3439 let retained_mass = 1.0 - mass_below_bound;
3440 let truncated_cdf = |value: f64| {
3441 (normal_cdf(value - center_value) - mass_below_bound) / retained_mass
3442 };
3443 assert!(
3444 (truncated_cdf(lower) - alpha).abs() < 2e-8
3445 && (truncated_cdf(upper) - (1.0 - alpha)).abs() < 2e-8,
3446 "centre {center_value}: endpoints [{lower}, {upper}] do not enclose exact \
3447 posterior mass 0.95"
3448 );
3449 assert!(
3450 lower >= 0.0,
3451 "centre {center_value}: lower endpoint {lower} escaped the saved cone"
3452 );
3453 assert!(
3454 upper - lower <= ambient_width + 1e-10,
3455 "centre {center_value}: truncation widened [{lower}, {upper}] beyond the \
3456 ambient Gaussian interval"
3457 );
3458
3459 if center_value == 3.0 {
3460 let symmetric_width = 2.0
3461 * standard_normal_quantile(1.0 - alpha).expect("symmetric quantile")
3462 * posterior_variance.sqrt();
3463 assert!(
3464 upper - lower > symmetric_width,
3465 "the exact 3-SE interval must repair the moment-matched symmetric interval's \
3466 short, under-covering band: exact width {}, symmetric width {symmetric_width}",
3467 upper - lower
3468 );
3469 saw_repaired_short_symmetric_band = true;
3470 }
3471 }
3472
3473 assert!(
3474 saw_repaired_short_symmetric_band,
3475 "the sweep must include its 3-SE regression cell"
3476 );
3477 }
3478
3479 #[test]
3492 fn a_constraint_row_below_the_lift_accuracy_floor_is_dropped_though_detectable() {
3493 let identity = Array2::<f64>::eye(4);
3494 let center = Array1::<f64>::zeros(4);
3495
3496 let mut resolvable = Array2::<f64>::zeros((3, 4));
3497 resolvable[[0, 0]] = 1.0;
3498 resolvable[[1, 1]] = 1.0;
3499 resolvable[[2, 2]] = 1.0;
3500 let constraints = LinearInequalityConstraints::new(resolvable, Array1::<f64>::zeros(3))
3501 .expect("orthogonal constraint rows");
3502 let correction =
3503 constrained_posterior_correction_from_covariance(&identity, ¢er, &constraints)
3504 .expect("orthogonal face")
3505 .expect("an active face at zero slack");
3506 assert_eq!(
3507 correction.rows,
3508 vec![0, 1, 2],
3509 "three mutually independent constraint normals must all be retained"
3510 );
3511
3512 let sine = 3.0e-7;
3515 let pivot = sine * sine;
3516 let diagonal = 1.0 + pivot;
3517 let detectability_limit = 2.0 * f64::EPSILON * diagonal;
3518 assert!(
3519 pivot > detectability_limit,
3520 "the fixture must be DETECTABLE, or the drop below proves nothing: pivot \
3521 {pivot:e} against the bare rank limit {detectability_limit:e}"
3522 );
3523 assert!(
3524 pivot < detectability_limit / ORTHANT_MOMENT_RELATIVE_TOLERANCE,
3525 "the fixture must sit below the accuracy the first pass demands"
3526 );
3527
3528 let mut degenerate = Array2::<f64>::zeros((3, 4));
3529 degenerate[[0, 0]] = 1.0;
3530 degenerate[[1, 0]] = 1.0;
3531 degenerate[[1, 1]] = sine;
3532 degenerate[[2, 2]] = 1.0;
3533 let constraints = LinearInequalityConstraints::new(degenerate, Array1::<f64>::zeros(3))
3534 .expect("near-parallel constraint rows");
3535 let correction =
3536 constrained_posterior_correction_from_covariance(&identity, ¢er, &constraints)
3537 .expect("near-degenerate face")
3538 .expect("an active face at zero slack");
3539 assert_eq!(
3540 correction.rows,
3541 vec![0, 2],
3542 "the near-parallel row must be dropped: retaining it reports a lift whose own \
3543 defining identity A·G = I fails by more than the certified accuracy"
3544 );
3545 }
3546
3547 #[test]
3569 fn the_retained_face_satisfies_the_identity_that_defines_its_lift() {
3570 const ROWS: usize = 7;
3571 const DIMENSION: usize = 8;
3572 const DEGREE: usize = 5;
3573 const SPACING: f64 = 1.0e-2;
3574
3575 let mut a = Array2::<f64>::zeros((ROWS, DIMENSION));
3576 for row in 0..ROWS {
3577 let node = row as f64 * SPACING;
3578 for power in 0..DEGREE {
3579 a[[row, power]] = node.powi(power as i32);
3580 }
3581 }
3582 let constraints = LinearInequalityConstraints::new(a.clone(), Array1::<f64>::zeros(ROWS))
3583 .expect("clustered Vandermonde rows");
3584
3585 let covariance = Array2::<f64>::eye(DIMENSION);
3591 let mut center = Array1::<f64>::zeros(DIMENSION);
3592 center[0] = 7.0;
3593 for row in 0..ROWS {
3594 let normal = a.row(row);
3595 let slack = normal.dot(¢er) / normal.dot(&normal).sqrt();
3596 assert!(
3597 slack < 8.12 && slack > 6.0,
3598 "row {row} must be a candidate inside the resolution horizon, got slack {slack}"
3599 );
3600 }
3601
3602 let correction =
3603 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
3604 .expect("clustered Vandermonde face")
3605 .expect("an active face inside the horizon");
3606
3607 assert!(
3608 correction.rows.len() < ROWS,
3609 "the fixture must exercise the filter: all {ROWS} rows were retained"
3610 );
3611 assert!(
3612 correction.rows.len() >= 2,
3613 "the face must not collapse to a single row, or the identity below is vacuous: \
3614 retained {:?}",
3615 correction.rows
3616 );
3617
3618 let mut departure = 0.0_f64;
3619 for (i, &row_index) in correction.rows.iter().enumerate() {
3620 for j in 0..correction.rows.len() {
3621 let entry = a.row(row_index).dot(&correction.lift.column(j));
3622 let target = if i == j { 1.0 } else { 0.0 };
3623 departure = departure.max((entry - target).abs());
3624 }
3625 }
3626 assert!(
3627 departure <= ORTHANT_MOMENT_RELATIVE_TOLERANCE,
3628 "the reported lift must satisfy A·G = I, the identity it is defined by, to the \
3629 accuracy this module certifies its moments to: max|A G - I| = {departure:e} on \
3630 the retained rows {:?}",
3631 correction.rows
3632 );
3633 }
3634
3635 fn face_at_exclusion(
3641 candidates: &[(usize, f64, Array1<f64>)],
3642 constraints: &LinearInequalityConstraints,
3643 center: &Array1<f64>,
3644 excluded: &[usize],
3645 ) -> Option<(Vec<usize>, bool)> {
3646 let face = assemble_retained_face(
3647 candidates,
3648 ORTHANT_MOMENT_RELATIVE_TOLERANCE,
3649 constraints,
3650 center,
3651 excluded,
3652 )
3653 .expect("face assembly")?;
3654 let lift = cholesky_solve_right(&face.factor, &face.sigma_at).expect("lift solve");
3655 let departure =
3656 lift_identity_departure(&lift, constraints, &face.rows).expect("identity departure");
3657 Some((face.rows, departure <= ORTHANT_MOMENT_RELATIVE_TOLERANCE))
3658 }
3659
3660 #[test]
3681 fn the_walk_returns_the_largest_admissible_face_2714() {
3682 const ROWS: usize = 7;
3683 const DIMENSION: usize = 8;
3684 const DEGREE: usize = 5;
3685 const SPACING: f64 = 1.0e-2;
3686
3687 let mut a = Array2::<f64>::zeros((ROWS, DIMENSION));
3688 for row in 0..ROWS {
3689 let node = row as f64 * SPACING;
3690 for power in 0..DEGREE {
3691 a[[row, power]] = node.powi(power as i32);
3692 }
3693 }
3694 let constraints = LinearInequalityConstraints::new(a.clone(), Array1::<f64>::zeros(ROWS))
3695 .expect("clustered Vandermonde rows");
3696 let covariance = Array2::<f64>::eye(DIMENSION);
3697 let mut center = Array1::<f64>::zeros(DIMENSION);
3698 center[0] = 7.0;
3699
3700 let correction =
3701 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
3702 .expect("clustered Vandermonde face")
3703 .expect("an active face inside the horizon");
3704
3705 let sigma_at = covariance.dot(&constraints.a.t());
3706 let candidates = constraint_face_candidates(sigma_at.view(), ¢er, &constraints)
3707 .expect("candidate rows");
3708 let candidate_rows: Vec<usize> = candidates.iter().map(|(row, _, _)| *row).collect();
3709
3710 let mut admissible_faces: Vec<Vec<usize>> = Vec::new();
3711 let mut distinct_faces: Vec<Vec<usize>> = Vec::new();
3712 for mask in 0..(1u32 << candidate_rows.len()) {
3713 let excluded: Vec<usize> = candidate_rows
3714 .iter()
3715 .enumerate()
3716 .filter(|(position, _)| mask & (1 << position) != 0)
3717 .map(|(_, row)| *row)
3718 .collect();
3719 let Some((rows, admissible)) =
3720 face_at_exclusion(&candidates, &constraints, ¢er, &excluded)
3721 else {
3722 continue;
3723 };
3724 if !distinct_faces.contains(&rows) {
3725 distinct_faces.push(rows.clone());
3726 }
3727 if admissible && !admissible_faces.contains(&rows) {
3728 admissible_faces.push(rows);
3729 }
3730 }
3731 let largest = admissible_faces
3732 .iter()
3733 .map(Vec::len)
3734 .max()
3735 .expect("some exclusion set must yield a face satisfying its own identity");
3736
3737 assert!(
3741 distinct_faces.len() >= 3,
3742 "#2714: the exclusion sweep saw only {} distinct face(s), so the fixture does not \
3743 exercise a walk: {distinct_faces:?}",
3744 distinct_faces.len()
3745 );
3746 let (unexcluded, unexcluded_admissible) =
3747 face_at_exclusion(&candidates, &constraints, ¢er, &[])
3748 .expect("the unexcluded face");
3749 assert!(
3750 !unexcluded_admissible,
3751 "#2714: the unexcluded face {unexcluded:?} already satisfies its own lift identity, \
3752 so the walk is not exercised"
3753 );
3754 assert!(
3755 unexcluded.len() > largest,
3756 "#2714: the unexcluded face {unexcluded:?} is no larger than the {largest}-row \
3757 answer, so nothing had to be dropped"
3758 );
3759
3760 assert!(
3768 admissible_faces.contains(&correction.rows),
3769 "#2714: the walk returned {:?}, which is not among the {} faces whose lift satisfies \
3770 its own identity: {admissible_faces:?}",
3771 correction.rows,
3772 admissible_faces.len()
3773 );
3774 assert_eq!(
3775 correction.rows.len(),
3776 largest,
3777 "#2714: the walk returned the {}-row face {:?} where an admissible face of {largest} \
3778 rows exists. Dropping the least independent accepted row is the step that reaches \
3779 the largest one; stepping a retention floor cannot, because the floor is a proxy \
3780 for the face and the proxy is not injective.",
3781 correction.rows.len(),
3782 correction.rows
3783 );
3784 assert_eq!(
3792 correction.rows.first(),
3793 candidate_rows.first(),
3794 "#2714: the walk returned {:?}, which does not retain the tightest candidate row \
3795 {:?}. The slack ordering is the reason a dropped row imposes no constraint the \
3796 retained ones do not; dropping the binding wall would relax the posterior by a \
3797 multiple of its own standard deviation.",
3798 correction.rows,
3799 candidate_rows.first()
3800 );
3801 }
3802
3803 #[test]
3825 fn a_rank_deficient_constraint_system_still_yields_a_liftable_face_2714() {
3826 const ROWS: usize = 40;
3827 const DIMENSION: usize = 5;
3828 const SPACING: f64 = 2.0e-2;
3829
3830 let mut a = Array2::<f64>::zeros((ROWS, DIMENSION));
3831 for row in 0..ROWS {
3832 let node = row as f64 * SPACING;
3833 for power in 0..DIMENSION {
3834 a[[row, power]] = node.powi(power as i32);
3835 }
3836 }
3837 let constraints = LinearInequalityConstraints::new(a, Array1::<f64>::zeros(ROWS))
3838 .expect("clustered Vandermonde rows");
3839 let covariance = Array2::<f64>::eye(DIMENSION);
3840 let mut center = Array1::<f64>::zeros(DIMENSION);
3845 center[0] = 1.0;
3846
3847 let sigma_at = covariance.dot(&constraints.a.t());
3848 let candidates = constraint_face_candidates(sigma_at.view(), ¢er, &constraints)
3849 .expect("candidate rows");
3850 assert!(
3851 candidates.len() > DIMENSION,
3852 "#2714: the fixture must be rank-deficient to exercise the walk, and it offers only \
3853 {} candidate row(s) against {DIMENSION} columns",
3854 candidates.len()
3855 );
3856 let (unexcluded, unexcluded_admissible) =
3857 face_at_exclusion(&candidates, &constraints, ¢er, &[])
3858 .expect("the unexcluded face");
3859 assert!(
3860 !unexcluded_admissible,
3861 "#2714: the unexcluded face {unexcluded:?} already satisfies its own lift identity, \
3862 so the walk never runs and this fixture asserts nothing"
3863 );
3864
3865 let correction =
3866 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
3867 .expect("a rank-deficient constraint system must still produce a face")
3868 .expect("an active face inside the horizon");
3869
3870 let departure = lift_identity_departure(&correction.lift, &constraints, &correction.rows)
3874 .expect("identity departure of the returned lift");
3875 assert!(
3876 departure <= ORTHANT_MOMENT_RELATIVE_TOLERANCE,
3877 "#2714: the returned {}-row face {:?} misses the identity that defines its lift by \
3878 {departure:.6e}, above {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e}",
3879 correction.rows.len(),
3880 correction.rows
3881 );
3882 assert!(
3883 correction.rows.len() < unexcluded.len(),
3884 "#2714: the walk returned {:?}, which is not smaller than the inadmissible \
3885 unexcluded face {unexcluded:?} — so it accepted a face it had already rejected",
3886 correction.rows
3887 );
3888 assert_eq!(
3889 correction.rows.first(),
3890 candidates.first().map(|(row, _, _)| row),
3891 "#2714: the walk dropped the tightest candidate row"
3892 );
3893 }
3894
3895 #[test]
3913 fn dropping_a_direction_takes_its_opposite_face_with_it_2714() {
3914 const NODES: usize = 40;
3915 const DIMENSION: usize = 5;
3916 const SPACING: f64 = 2.0e-2;
3917 const FAR_WALL: f64 = 3.0;
3923
3924 let mut a = Array2::<f64>::zeros((2 * NODES, DIMENSION));
3925 let mut b = Array1::<f64>::zeros(2 * NODES);
3926 for node in 0..NODES {
3927 let position = node as f64 * SPACING;
3928 for power in 0..DIMENSION {
3929 let entry = position.powi(power as i32);
3930 a[[node, power]] = entry;
3931 a[[NODES + node, power]] = -entry;
3932 }
3933 b[node] = 0.0;
3934 b[NODES + node] = -FAR_WALL;
3935 }
3936 let constraints =
3937 LinearInequalityConstraints::new(a, b).expect("two-sided Vandermonde slabs");
3938 let covariance = Array2::<f64>::eye(DIMENSION);
3939 let mut center = Array1::<f64>::zeros(DIMENSION);
3940 center[0] = 1.0;
3941
3942 let sigma_at = covariance.dot(&constraints.a.t());
3943 let candidates = constraint_face_candidates(sigma_at.view(), ¢er, &constraints)
3944 .expect("candidate rows");
3945 assert_eq!(
3946 candidates.len(),
3947 2 * NODES,
3948 "#2714: both walls of every slab must be candidates, or the fixture is not \
3949 two-sided where the walk runs"
3950 );
3951 let (unexcluded, unexcluded_admissible) =
3952 face_at_exclusion(&candidates, &constraints, ¢er, &[])
3953 .expect("the unexcluded face");
3954 assert!(
3955 !unexcluded_admissible,
3956 "#2714: the unexcluded face {unexcluded:?} already satisfies its own lift identity, \
3957 so no direction is ever dropped and this fixture asserts nothing"
3958 );
3959
3960 let correction =
3961 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
3962 .expect("a two-sided rank-deficient system must still produce a face")
3963 .expect("an active face inside the horizon");
3964
3965 assert_eq!(
3967 correction.normal_upper_limits.len(),
3968 correction.rows.len(),
3969 "#2714: one upper limit per retained row"
3970 );
3971 for (position, &row) in correction.rows.iter().enumerate() {
3972 assert!(
3973 correction.normal_upper_limits[position].is_finite(),
3974 "#2714: retained row {row} reports an infinite upper limit on a system where \
3975 EVERY direction is two-sided. Its opposite face was left in the candidate pool \
3976 when the direction that carried the fold was dropped, so a two-sided bound is \
3977 being reported as a half-line: rows {:?}, limits {:?}",
3978 correction.rows,
3979 correction.normal_upper_limits
3980 );
3981 }
3982 for &row in &correction.rows {
3986 assert!(
3987 row < NODES,
3988 "#2714: the walk retained far-wall row {row}, which sits at slack {FAR_WALL} \
3989 against the near wall's 1 — the slacker of the pair replaced the binding one: \
3990 {:?}",
3991 correction.rows
3992 );
3993 }
3994 }
3995
3996 #[test]
4013 fn the_floor_round_trip_retains_the_row_it_was_aimed_at_2714() {
4014 let mut retained = 0usize;
4015 let mut exact_stalls = 0usize;
4016 let mut examined = 0usize;
4017 for accepted in 0..12usize {
4018 for diagonal_exponent in -8i32..=4 {
4019 for pivot_decades in 1..=15i32 {
4020 for tweak in 0..64u32 {
4021 let diagonal = 10.0_f64.powi(diagonal_exponent)
4022 * (1.0 + f64::from(tweak) / 64.0);
4023 let pivot = diagonal * 10.0_f64.powi(-pivot_decades);
4024 let scale = (accepted + 1) as f64 * f64::EPSILON * diagonal;
4025 let step = scale / pivot;
4026 let rebuilt_floor = scale / step;
4027 examined += 1;
4028 if pivot > rebuilt_floor {
4029 retained += 1;
4030 if scale / pivot == step {
4033 exact_stalls += 1;
4034 }
4035 }
4036 }
4037 }
4038 }
4039 }
4040 assert!(
4041 retained > 0,
4042 "#2714: the floor round trip never retained the row it was aimed at across \
4043 {examined} triples, which would make this refutation vacuous"
4044 );
4045 assert_eq!(
4046 retained, exact_stalls,
4047 "#2714: {retained} of {examined} triples retained the row the step was aimed at, and \
4048 {exact_stalls} of those recompute the same step. Every retention IS a stall — the \
4049 face is bit-identical, so the step is a function of unchanged inputs — and the old \
4050 rule's descent assertion fires on each one."
4051 );
4052 }
4053
4054 #[test]
4061 fn cubature_reproduces_independent_coordinates_within_its_certified_accuracy() {
4062 let mean = array![-0.5, 0.25, -1.5];
4063 let covariance = array![[2.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 1.0]];
4064 let factor = gam_linalg::triangular::cholesky_factor_in_place(
4065 covariance.view(),
4066 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
4067 )
4068 .expect("independent orthant covariance factors");
4069 let (moment_mean, moment_covariance) =
4070 box_truncated_moments(&mean, &vec![f64::INFINITY; mean.len()], &covariance, factor.view())
4071 .expect("independent orthant");
4072 for i in 0..3 {
4073 let (exact_mean, exact_variance) =
4074 scalar_truncated_moments(mean[i], covariance[[i, i]], f64::INFINITY).expect("scalar");
4075 let scale = covariance[[i, i]].sqrt();
4076 assert!(
4077 (moment_mean[i] - exact_mean[0]).abs()
4078 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * scale,
4079 "coordinate {i} mean {} vs exact {}",
4080 moment_mean[i],
4081 exact_mean[0]
4082 );
4083 assert!(
4084 (moment_covariance[[i, i]] - exact_variance[[0, 0]]).abs()
4085 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * covariance[[i, i]],
4086 "coordinate {i} variance {} vs exact {}",
4087 moment_covariance[[i, i]],
4088 exact_variance[[0, 0]]
4089 );
4090 for j in 0..3 {
4091 if i != j {
4092 assert!(
4093 moment_covariance[[i, j]].abs()
4094 < ORTHANT_MOMENT_RELATIVE_TOLERANCE
4095 * scale
4096 * covariance[[j, j]].sqrt(),
4097 "independent coordinates must stay uncorrelated under an orthant \
4098 truncation, got {} at ({i},{j})",
4099 moment_covariance[[i, j]]
4100 );
4101 }
4102 }
4103 }
4104 }
4105
4106 #[test]
4109 fn correction_lands_strictly_between_full_space_and_active_face() {
4110 let covariance = array![[1.0, 0.4], [0.4, 1.0]];
4111 let constraints =
4112 LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
4113 let center = array![-0.6, 0.3];
4115 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4116 .expect("correction")
4117 .expect("an active row");
4118 let truncated = correction.apply_to_covariance(&covariance);
4119
4120 let mut face = covariance.clone();
4123 let full_removal = correction.lift.dot(&array![[1.0]]).dot(&correction.lift.t());
4124 face -= &full_removal;
4125
4126 assert!(
4127 truncated[[0, 0]] > face[[0, 0]] + 1e-6,
4128 "truncated variance {} must exceed the active-face answer {}",
4129 truncated[[0, 0]],
4130 face[[0, 0]]
4131 );
4132 assert!(
4133 truncated[[0, 0]] < covariance[[0, 0]] - 1e-6,
4134 "truncated variance {} must fall below the unconstrained answer {}",
4135 truncated[[0, 0]],
4136 covariance[[0, 0]]
4137 );
4138 assert!(
4139 face[[0, 0]].abs() < 1e-12,
4140 "the active-face answer for a single pinned coordinate is exactly zero, got {}",
4141 face[[0, 0]]
4142 );
4143 assert!(
4144 correction.normal_mean_shift[0] > 0.0,
4145 "truncation moves the posterior mean INTO the feasible region, shift was {}",
4146 correction.normal_mean_shift[0]
4147 );
4148 }
4149
4150 #[test]
4153 fn inactive_constraints_produce_no_correction() {
4154 let covariance = array![[1.0, 0.0], [0.0, 1.0]];
4155 let constraints =
4156 LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
4157 let center = array![40.0, 0.0];
4158 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4159 .expect("correction");
4160 assert!(
4161 correction.is_none(),
4162 "a bound 40 posterior standard deviations away cannot move any moment at double \
4163 precision"
4164 );
4165 }
4166
4167 #[test]
4169 fn redundant_rows_are_dropped_by_the_rank_filter() {
4170 let covariance = array![[1.0, 0.2], [0.2, 1.0]];
4171 let constraints = LinearInequalityConstraints::new(
4172 array![[1.0, 0.0], [2.0, 0.0], [0.0, 1.0]],
4173 array![0.0, 0.0, 0.0],
4174 )
4175 .expect("cone");
4176 let center = array![-0.2, -0.3];
4177 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4178 .expect("correction")
4179 .expect("active rows");
4180 assert_eq!(
4181 correction.rows.len(),
4182 2,
4183 "the duplicated half-space must be filtered out, kept rows {:?}",
4184 correction.rows
4185 );
4186 }
4187
4188 #[test]
4190 fn corrected_covariance_stays_between_zero_and_the_unconstrained_answer() {
4191 let covariance = array![
4192 [1.0, 0.3, 0.1],
4193 [0.3, 1.2, -0.2],
4194 [0.1, -0.2, 0.8]
4195 ];
4196 let constraints = LinearInequalityConstraints::new(
4197 array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
4198 array![0.0, 0.0],
4199 )
4200 .expect("cone");
4201 for center in [
4202 array![-2.0, -1.0, 0.5],
4203 array![0.0, 0.0, 0.0],
4204 array![-0.1, 0.4, -3.0],
4205 ] {
4206 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
4207 .expect("correction")
4208 .expect("active rows");
4209 let truncated = correction.apply_to_covariance(&covariance);
4210 for i in 0..3 {
4211 assert!(
4212 truncated[[i, i]] > 0.0,
4213 "coordinate {i} lost all variance at centre {center:?}: {}",
4214 truncated[[i, i]]
4215 );
4216 assert!(
4217 truncated[[i, i]] <= covariance[[i, i]] + 1e-9,
4218 "coordinate {i} gained variance at centre {center:?}: {} vs {}",
4219 truncated[[i, i]],
4220 covariance[[i, i]]
4221 );
4222 }
4223 let diagonal = correction.removed_variance_diagonal();
4224 for i in 0..3 {
4225 assert!(
4226 (diagonal[i] - (covariance[[i, i]] - truncated[[i, i]])).abs() < 1e-9,
4227 "the diagonal-only accessor must agree with the dense correction at {i}"
4228 );
4229 }
4230 }
4231 }
4232 fn two_sided_bound_rows(lower: f64, upper: f64, columns: usize) -> LinearInequalityConstraints {
4238 let mut a = Array2::<f64>::zeros((2, columns));
4239 a[[0, 0]] = 1.0;
4240 a[[1, 0]] = -1.0;
4241 LinearInequalityConstraints::new(a, array![lower, -upper])
4242 .expect("two-sided bound rows")
4243 }
4244
4245 fn quadrature_box_moments(mean: f64, variance: f64, upper: f64) -> (f64, f64) {
4249 let sd = variance.sqrt();
4250 let low = -mean / sd;
4251 let high = (upper - mean) / sd;
4252 let reference = if low <= 0.0 && 0.0 <= high {
4253 0.0
4254 } else if high < 0.0 {
4255 high
4256 } else {
4257 low
4258 };
4259 let panels = 400_000usize;
4260 let step = (high - low) / panels as f64;
4261 let (mut mass, mut first, mut second) = (0.0f64, 0.0f64, 0.0f64);
4262 for index in 0..=panels {
4263 let z = low + step * index as f64;
4264 let simpson = if index == 0 || index == panels {
4265 1.0
4266 } else if index % 2 == 1 {
4267 4.0
4268 } else {
4269 2.0
4270 };
4271 let density = (-(z * z - reference * reference) / 2.0).exp();
4272 mass += simpson * density;
4273 first += simpson * density * z;
4274 second += simpson * density * z * z;
4275 }
4276 let m1 = first / mass;
4277 let m2 = second / mass;
4278 (mean + sd * m1, variance * (m2 - m1 * m1))
4279 }
4280
4281 #[test]
4290 fn two_sided_coefficient_bound_keeps_its_far_wall_2523() {
4291 let columns = 3;
4292 let covariance = Array2::<f64>::eye(columns);
4293 let centre = array![0.6, 0.0, 0.0];
4294 let constraints = two_sided_bound_rows(0.0, 2.0, columns);
4295 let correction = constrained_posterior_correction_from_covariance(
4296 &covariance,
4297 ¢re,
4298 &constraints,
4299 )
4300 .expect("two-sided correction")
4301 .expect("an active two-sided bound corrects the posterior");
4302
4303 assert_eq!(
4304 correction.rows.len(),
4305 1,
4306 "the anti-parallel row adds no direction, so exactly one is retained"
4307 );
4308 let limits = correction.upper_limits();
4309 assert_eq!(limits.len(), 1);
4310 assert!(
4311 (limits[0] - 2.0).abs() < 1e-12,
4312 "the far wall of [0, 2] must arrive as the coordinate's upper limit, got {}",
4313 limits[0]
4314 );
4315
4316 let (bounded_mean, bounded_variance) =
4319 quadrature_box_moments(0.6, 1.0, 2.0);
4320 let (half_line_mean, half_line_variance) = quadrature_truncated_moments(0.6, 1.0);
4321 let reported_mean = 0.6 + correction.normal_mean_shift[0];
4322 let reported_variance = 1.0 - correction.removed_normal_variance[[0, 0]];
4323 assert!(
4324 (reported_mean - bounded_mean).abs() < 1e-6,
4325 "reported mean {reported_mean} must be the [0,2] mean {bounded_mean}, \
4326 not the [0,inf) mean {half_line_mean}"
4327 );
4328 assert!(
4329 (reported_variance - bounded_variance).abs() < 1e-6,
4330 "reported variance {reported_variance} must be the [0,2] variance \
4331 {bounded_variance}, not the [0,inf) variance {half_line_variance}"
4332 );
4333 assert!(
4336 (bounded_mean - half_line_mean).abs() > 0.1
4337 && (bounded_variance - half_line_variance).abs() > 0.1,
4338 "the fixture must separate the two answers: means {bounded_mean} vs \
4339 {half_line_mean}, variances {bounded_variance} vs {half_line_variance}"
4340 );
4341 }
4342
4343 #[test]
4348 fn a_far_wall_beyond_the_horizon_restores_the_half_line_answer_exactly() {
4349 let columns = 3;
4350 let covariance = Array2::<f64>::eye(columns);
4351 let centre = array![0.6, 0.0, 0.0];
4352 let two_sided = constrained_posterior_correction_from_covariance(
4353 &covariance,
4354 ¢re,
4355 &two_sided_bound_rows(0.0, 40.0, columns),
4356 )
4357 .expect("wide two-sided correction")
4358 .expect("the lower wall is still active");
4359
4360 let mut lower_only = Array2::<f64>::zeros((1, columns));
4361 lower_only[[0, 0]] = 1.0;
4362 let one_sided = constrained_posterior_correction_from_covariance(
4363 &covariance,
4364 ¢re,
4365 &LinearInequalityConstraints::new(lower_only, array![0.0]).expect("lower wall"),
4366 )
4367 .expect("one-sided correction")
4368 .expect("an active lower bound corrects the posterior");
4369
4370 assert_eq!(two_sided.rows.len(), 1);
4371 assert_eq!(
4372 two_sided.upper_limits(),
4373 vec![f64::INFINITY],
4374 "a wall 39.4 standard deviations away is not a candidate at all"
4375 );
4376 assert_eq!(
4377 two_sided.normal_mean_shift[0], one_sided.normal_mean_shift[0],
4378 "no reachable upper limit must reproduce the half-line mean shift exactly"
4379 );
4380 assert_eq!(
4381 two_sided.removed_normal_variance[[0, 0]],
4382 one_sided.removed_normal_variance[[0, 0]],
4383 "no reachable upper limit must reproduce the half-line variance exactly"
4384 );
4385 }
4386
4387 #[test]
4401 fn a_half_line_upper_limit_survives_the_json_round_trip_2601() {
4402 for limits in [
4403 vec![f64::INFINITY; 3],
4404 vec![2.5, f64::INFINITY, 1e300],
4405 Vec::new(),
4406 ] {
4407 let q = limits.len().max(1);
4408 let correction = ConstrainedPosteriorCorrection {
4409 lift: Array2::<f64>::zeros((4, q)),
4410 removed_normal_variance: Array2::<f64>::eye(q),
4411 normal_mean_shift: Array1::<f64>::zeros(q),
4412 rows: (0..q).collect(),
4413 normal_upper_limits: limits.clone(),
4414 };
4415 let json = serde_json::to_string(&correction).expect("serialize correction");
4416 let back: ConstrainedPosteriorCorrection =
4417 serde_json::from_str(&json).unwrap_or_else(|e| {
4418 panic!("a correction with limits {limits:?} must reload: {e}\n{json}")
4419 });
4420 assert_eq!(
4421 back.normal_upper_limits, limits,
4422 "upper limits must round-trip bit for bit"
4423 );
4424 assert_eq!(back.upper_limits(), correction.upper_limits());
4425 }
4426 }
4427
4428 #[test]
4432 fn a_solver_produced_half_line_correction_reloads_2601() {
4433 let columns = 3;
4434 let covariance = Array2::<f64>::eye(columns);
4435 let centre = array![0.6, 0.0, 0.0];
4436 let mut lower_only = Array2::<f64>::zeros((1, columns));
4437 lower_only[[0, 0]] = 1.0;
4438 let correction = constrained_posterior_correction_from_covariance(
4439 &covariance,
4440 ¢re,
4441 &LinearInequalityConstraints::new(lower_only, array![0.0]).expect("lower wall"),
4442 )
4443 .expect("one-sided correction")
4444 .expect("an active lower bound corrects the posterior");
4445 assert_eq!(
4446 correction.normal_upper_limits,
4447 vec![f64::INFINITY],
4448 "precondition: a half-line coordinate carries an infinite upper limit"
4449 );
4450
4451 let json = serde_json::to_string(&correction).expect("serialize");
4452 let back: ConstrainedPosteriorCorrection =
4453 serde_json::from_str(&json).expect("a solver-produced correction must reload");
4454 assert_eq!(back.normal_upper_limits, vec![f64::INFINITY]);
4455
4456 assert!(
4459 gam_problem::ensure_serialized_floats_are_finite(&correction).is_ok(),
4460 "an unbounded upper limit is a value, not a non-finite defect"
4461 );
4462 }
4463
4464 #[test]
4469 fn a_box_the_unconstrained_centre_overshoots_stays_inside_itself() {
4470 let columns = 2;
4471 let covariance = Array2::<f64>::eye(columns);
4472 let centre = array![-3.0, 0.0];
4475 let correction = constrained_posterior_correction_from_covariance(
4476 &covariance,
4477 ¢re,
4478 &two_sided_bound_rows(-1.0, 1.0, columns),
4479 )
4480 .expect("overshooting correction")
4481 .expect("both walls bind");
4482
4483 let limits = correction.upper_limits();
4484 assert!(
4485 (limits[0] - 2.0).abs() < 1e-12,
4486 "the slab is two units wide, got {}",
4487 limits[0]
4488 );
4489 let reported_mean = -2.0 + correction.normal_mean_shift[0];
4490 let reported_variance = 1.0 - correction.removed_normal_variance[[0, 0]];
4491 assert!(
4492 reported_mean > 0.0 && reported_mean < limits[0],
4493 "the posterior mean of a law supported on [0, {}] cannot sit outside it, \
4494 got {reported_mean}",
4495 limits[0]
4496 );
4497 assert!(
4501 reported_variance > 0.0 && reported_variance <= limits[0] * limits[0] / 4.0,
4502 "variance {reported_variance} exceeds the width bound for [0, {}]",
4503 limits[0]
4504 );
4505 let (expected_mean, expected_variance) = quadrature_box_moments(-2.0, 1.0, 2.0);
4506 assert!(
4507 (reported_mean - expected_mean).abs() < 1e-6
4508 && (reported_variance - expected_variance).abs() < 1e-6,
4509 "deep-tail slab moments {reported_mean}/{reported_variance} against the \
4510 independent quadrature {expected_mean}/{expected_variance}"
4511 );
4512 }
4513
4514 #[test]
4518 fn the_two_sided_scalar_form_meets_the_mills_branch_at_a_distant_wall() {
4519 for &(mean, variance) in &[(0.6f64, 1.0f64), (-2.5, 1.0), (0.0, 4.0), (3.0, 0.25)] {
4520 let sd: f64 = variance.sqrt();
4521 let distant = mean + 40.0 * sd;
4522 let (bounded_mean, bounded_variance) =
4523 scalar_truncated_moments(mean, variance, distant).expect("bounded");
4524 let (open_mean, open_variance) =
4525 scalar_truncated_moments(mean, variance, f64::INFINITY).expect("half line");
4526 assert!(
4527 (bounded_mean[0] - open_mean[0]).abs() <= 1e-12 * open_mean[0].abs().max(1.0),
4528 "mean {} vs {} at mean={mean} variance={variance}",
4529 bounded_mean[0],
4530 open_mean[0]
4531 );
4532 assert!(
4533 (bounded_variance[[0, 0]] - open_variance[[0, 0]]).abs()
4534 <= 1e-12 * open_variance[[0, 0]].abs().max(1.0),
4535 "variance {} vs {} at mean={mean} variance={variance}",
4536 bounded_variance[[0, 0]],
4537 open_variance[[0, 0]]
4538 );
4539 }
4540 }
4541
4542 #[test]
4545 fn the_two_sided_scalar_form_matches_an_independent_quadrature() {
4546 for &(mean, variance, upper) in &[
4547 (0.6f64, 1.0f64, 2.0f64),
4548 (-1.5, 1.0, 0.5),
4549 (3.0, 0.25, 0.4),
4550 (-4.0, 1.0, 0.2),
4551 (0.05, 1.0, 0.1),
4552 (-2.0, 1.0, 2.0),
4553 (0.5, 9.0, 12.0),
4554 ] {
4555 let (moment_mean, moment_variance) =
4556 scalar_truncated_moments(mean, variance, upper).expect("two-sided moments");
4557 let (reference_mean, reference_variance) =
4558 quadrature_box_moments(mean, variance, upper);
4559 let scale = variance.sqrt();
4560 assert!(
4561 (moment_mean[0] - reference_mean).abs() < 1e-9 * scale,
4562 "mean {} vs {reference_mean} at mean={mean} variance={variance} upper={upper}",
4563 moment_mean[0]
4564 );
4565 assert!(
4566 (moment_variance[[0, 0]] - reference_variance).abs() < 1e-9 * variance,
4567 "variance {} vs {reference_variance} at mean={mean} variance={variance} \
4568 upper={upper}",
4569 moment_variance[[0, 0]]
4570 );
4571 assert!(
4572 moment_mean[0] > 0.0 && moment_mean[0] < upper,
4573 "the mean of a law on [0, {upper}] must lie inside it, got {}",
4574 moment_mean[0]
4575 );
4576 }
4577 }
4578
4579 #[test]
4584 fn the_box_cubature_reproduces_a_product_law_it_cannot_shortcut() {
4585 let mean = array![0.4, -1.2, 0.9];
4586 let covariance = Array2::from_diag(&array![1.0, 0.5, 2.0]);
4587 let upper = vec![1.5, 0.8, f64::INFINITY];
4588 let factor = gam_linalg::triangular::cholesky_factor_in_place(
4589 covariance.view(),
4590 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
4591 )
4592 .expect("diagonal factor");
4593 let (cubature_mean, cubature_covariance) =
4594 box_truncated_moments(&mean, &upper, &covariance, factor.view()).expect("box moments");
4595 for i in 0..mean.len() {
4596 let (reference_mean, reference_variance) =
4597 scalar_truncated_moments(mean[i], covariance[[i, i]], upper[i])
4598 .expect("marginal closed form");
4599 let sd = covariance[[i, i]].sqrt();
4600 assert!(
4601 (cubature_mean[i] - reference_mean[0]).abs()
4602 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * sd,
4603 "coordinate {i} mean {} vs {}",
4604 cubature_mean[i],
4605 reference_mean[0]
4606 );
4607 assert!(
4608 (cubature_covariance[[i, i]] - reference_variance[[0, 0]]).abs()
4609 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * covariance[[i, i]],
4610 "coordinate {i} variance {} vs {}",
4611 cubature_covariance[[i, i]],
4612 reference_variance[[0, 0]]
4613 );
4614 }
4615 for i in 0..mean.len() {
4619 for j in 0..mean.len() {
4620 if i == j {
4621 continue;
4622 }
4623 let sd = (covariance[[i, i]] * covariance[[j, j]]).sqrt();
4624 assert!(
4625 cubature_covariance[[i, j]].abs() < ORTHANT_MOMENT_RELATIVE_TOLERANCE * sd,
4626 "a product law truncated to a box stays a product law: entry ({i},{j}) \
4627 is {}",
4628 cubature_covariance[[i, j]]
4629 );
4630 }
4631 }
4632 }
4633
4634 #[test]
4651 fn a_slab_twelve_deviations_below_the_mean_keeps_its_mass() {
4652 let depth = 12.0_f64;
4653 let mean = array![depth, depth];
4654 let covariance = Array2::<f64>::eye(2);
4655 let upper = vec![2.0, 2.0];
4656 let factor = gam_linalg::triangular::cholesky_factor_in_place(
4657 covariance.view(),
4658 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
4659 )
4660 .expect("identity factor");
4661 let (cubature_mean, cubature_covariance) =
4662 box_truncated_moments(&mean, &upper, &covariance, factor.view())
4663 .expect("a slab deep in a tail still carries mass");
4664 let (reference_mean, reference_variance) = quadrature_box_moments(depth, 1.0, 2.0);
4665 assert!(
4669 reference_mean > 1.85 && reference_mean < 2.0,
4670 "the fixture must place the mean near the far wall, got {reference_mean}"
4671 );
4672 for i in 0..2 {
4673 assert!(
4674 (cubature_mean[i] - reference_mean).abs() < 1e-3,
4675 "coordinate {i} mean {} against the Simpson reference {reference_mean}",
4676 cubature_mean[i]
4677 );
4678 assert!(
4679 (cubature_covariance[[i, i]] - reference_variance).abs() < 1e-3,
4680 "coordinate {i} variance {} against the Simpson reference {reference_variance}",
4681 cubature_covariance[[i, i]]
4682 );
4683 }
4684 }
4685
4686 fn quadrature_box_cdf(mean: f64, variance: f64, upper: f64, x: f64) -> f64 {
4690 let sd = variance.sqrt();
4691 let low = -mean / sd;
4692 let high = (upper - mean) / sd;
4693 let point = (x - mean) / sd;
4694 let reference = if low <= 0.0 && 0.0 <= high {
4695 0.0
4696 } else if high < 0.0 {
4697 high
4698 } else {
4699 low
4700 };
4701 let mass = |from: f64, to: f64| -> f64 {
4702 let panels = 200_000usize;
4703 let step = (to - from) / panels as f64;
4704 let mut total = 0.0f64;
4705 for index in 0..=panels {
4706 let z = from + step * index as f64;
4707 let simpson = if index == 0 || index == panels {
4708 1.0
4709 } else if index % 2 == 1 {
4710 4.0
4711 } else {
4712 2.0
4713 };
4714 total += simpson * (-(z * z - reference * reference) / 2.0).exp();
4715 }
4716 total * step
4717 };
4718 mass(low, point) / mass(low, high)
4719 }
4720
4721 #[test]
4737 fn the_deep_tail_quantile_round_trips_rather_than_collapsing_to_an_endpoint() {
4738 let mean = 12.0_f64;
4739 let variance = 1.0_f64;
4740 let upper = 2.0_f64;
4741 let fractions = [0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99];
4742 let mut previous = 0.0_f64;
4743 for &fraction in &fractions {
4744 let point = scalar_truncated_quantile(mean, variance, upper, fraction)
4745 .expect("a slab deep in a tail still has quantiles");
4746 assert!(
4747 point > 0.0 && point < upper,
4748 "the {fraction} quantile of a law on [0, {upper}] must be interior, got {point}"
4749 );
4750 assert!(
4751 point > previous,
4752 "quantiles must be strictly increasing in the probability; {fraction} gave \
4753 {point} against {previous} for the fraction before it, which is what a \
4754 collapse to one endpoint looks like"
4755 );
4756 previous = point;
4757 let recovered = quadrature_box_cdf(mean, variance, upper, point);
4758 assert!(
4759 (recovered - fraction).abs() < 1e-6,
4760 "round trip at {fraction}: the quantile returned {point}, whose independent \
4761 Simpson CDF is {recovered}"
4762 );
4763 }
4764 let first = scalar_truncated_quantile(mean, variance, upper, 0.01).expect("low");
4780 let last = scalar_truncated_quantile(mean, variance, upper, 0.99).expect("high");
4781 let span = last - first;
4782 let steepest = mean / variance;
4783 let shallowest = (mean - upper) / variance;
4784 let narrowest = 99.0_f64.ln() / steepest;
4785 let widest = 99.0_f64.ln() / shallowest;
4786 assert!(
4787 span >= narrowest && span <= widest,
4788 "the 1%-99% span {span} is outside the [{narrowest}, {widest}] the log-density's \
4789 own slopes allow across this slab"
4790 );
4791 }
4792
4793 #[test]
4797 fn coincident_two_sided_walls_are_refused_not_collapsed() {
4798 let columns = 2;
4799 let covariance = Array2::<f64>::eye(columns);
4800 let centre = array![0.5, 0.0];
4801 let error = constrained_posterior_correction_from_covariance(
4802 &covariance,
4803 ¢re,
4804 &two_sided_bound_rows(0.25, 0.25, columns),
4805 )
4806 .expect_err("an empty slab has no posterior to report");
4807 assert!(
4808 error.contains("no width between them"),
4809 "the refusal must name the geometry, got: {error}"
4810 );
4811 }
4812
4813 #[test]
4817 fn a_two_sided_projection_interval_stays_within_its_own_bounds() {
4818 let columns = 2;
4819 let covariance = Array2::<f64>::eye(columns);
4820 let centre = array![0.6, 0.0];
4821 let constraints = two_sided_bound_rows(0.0, 1.0, columns);
4822 let correction = constrained_posterior_correction_from_covariance(
4823 &covariance,
4824 ¢re,
4825 &constraints,
4826 )
4827 .expect("correction")
4828 .expect("active");
4829 let geometry = ConstrainedPosteriorGeometry {
4830 constraints,
4831 mode: array![0.6, 0.0],
4832 unconstrained_center: Some(centre),
4833 correction: Some(correction),
4834 moment_status: ConstrainedPosteriorMomentStatus::Available,
4835 };
4836 let (low, high) = constrained_projection_equal_tailed_interval(
4837 &covariance,
4838 &geometry,
4839 &array![1.0, 0.0],
4840 0.95,
4841 )
4842 .expect("two-sided projection interval");
4843 assert!(
4844 low >= -1e-9 && high <= 1.0 + 1e-9,
4845 "a coefficient declared to lie in [0, 1] cannot be reported in [{low}, {high}]"
4846 );
4847 assert!(low < high, "the interval must be non-degenerate");
4848 }
4849}
4850
4851#[cfg(test)]
4874mod orthant_tilt_2601_tests {
4875 use super::*;
4876
4877 fn refusing_face() -> (Array1<f64>, Array2<f64>) {
4889 let mean = Array1::from_vec(vec![
4890 -1.73263658148929162e-01, -1.61028415044015161e-01, -1.53745491056003519e-01,
4891 -1.14020228922959738e-01, -1.13372022294778579e-01, -5.68386260921473555e-02,
4892 -2.01787006225858517e-02, 7.79317061445884696e-04, 1.73520774327455551e-03,
4893 2.00473386863282976e-02, 4.22790949294645502e-02,
4894 ]);
4895 let w = Array2::from_shape_vec(
4896 (11, 11),
4897 vec![
4898 4.28613165678045412e-03, -6.40620724624387243e-04, -6.68752057617351208e-04,
4899 -5.83957865274831135e-04, -5.55233848052857893e-04, -7.90532734874588739e-04,
4900 -8.09846363272953844e-04, -2.35978352506513071e-04, -4.59953354398140966e-04,
4901 -2.40276816847910670e-04, -4.66860409610777736e-04, -6.40620724624387243e-04,
4902 4.30519348418363125e-03, -5.77958088890881253e-04, -7.08255560103122385e-04,
4903 -6.52329255706248488e-04, -4.23821703180157501e-04, -7.53554384768477959e-04,
4904 -1.29045376459765944e-04, -2.55856263721782311e-04, -3.25781213177120355e-04,
4905 -6.88688636712164021e-04, -6.68752057617351208e-04, -5.77958088890881253e-04,
4906 4.33564940679504254e-03, -6.74857893211149419e-04, -6.22390211789637811e-04,
4907 -7.39567533429216599e-04, -4.59996677084055332e-04, -3.33480282492368946e-04,
4908 -7.09611827680745955e-04, -1.43809374573677527e-04, -2.85910172307334801e-04,
4909 -5.83957865274831135e-04, -7.08255560103122385e-04, -6.74857893211149419e-04,
4910 4.23561983569142528e-03, -3.68170739599590739e-04, -2.42878795544258373e-04,
4911 -6.65817316820568449e-04, -7.99047220448408411e-05, -1.55911037671571136e-04,
4912 -1.76197943321878327e-04, -2.51754204620259080e-04, -5.55233848052857893e-04,
4913 -6.52329255706248488e-04, -6.22390211789637811e-04, -3.68170739599590739e-04,
4914 4.29588162752489629e-03, -7.57152955247313302e-04, -2.80188801979815898e-04,
4915 -2.28980327675770300e-04, -3.66373527157145380e-04, -9.83175770979363879e-05,
4916 -1.94759571987295659e-04, -7.90532734874588739e-04, -4.23821703180157501e-04,
4917 -7.39567533429216599e-04, -2.42878795544258373e-04, -7.57152955247313302e-04,
4918 4.82590971142383366e-03, -2.30789443520484135e-04, 5.11092628516201207e-04,
4919 4.74012818803116673e-04, -1.01494286876908989e-04, -2.04446376075374456e-04,
4920 -8.09846363272953844e-04, -7.53554384768477959e-04, -4.59996677084055332e-04,
4921 -6.65817316820568449e-04, -2.80188801979815898e-04, -2.30789443520484135e-04,
4922 4.97201788205004890e-03, -9.74102815703460092e-05, -1.93235956110176968e-04,
4923 5.67517929096417986e-04, 5.90297796785945318e-04, -2.35978352506513071e-04,
4924 -1.29045376459765944e-04, -3.33480282492368946e-04, -7.99047220448408411e-05,
4925 -2.28980327675770300e-04, 5.11092628516201207e-04, -9.74102815703460092e-05,
4926 1.15689169020833748e-03, 1.48276767553545967e-03, -4.51194193048010442e-05,
4927 -9.11302898497036765e-05, -4.59953354398140966e-04, -2.55856263721782311e-04,
4928 -7.09611827680745955e-04, -1.55911037671571136e-04, -3.66373527157145380e-04,
4929 4.74012818803116673e-04, -1.93235956110176968e-04, 1.48276767553545967e-03,
4930 4.05500634945223787e-03, -8.98171912603273025e-05, -1.81408583551147815e-04,
4931 -2.40276816847910670e-04, -3.25781213177120355e-04, -1.43809374573677527e-04,
4932 -1.76197943321878327e-04, -9.83175770979363879e-05, -1.01494286876908989e-04,
4933 5.67517929096417986e-04, -4.51194193048010442e-05, -8.98171912603273025e-05,
4934 1.17935427809862667e-03, 1.52706063180176217e-03, -4.66860409610777736e-04,
4935 -6.88688636712164021e-04, -2.85910172307334801e-04, -2.51754204620259080e-04,
4936 -1.94759571987295659e-04, -2.04446376075374456e-04, 5.90297796785945318e-04,
4937 -9.11302898497036765e-05, -1.81408583551147815e-04, 1.52706063180176217e-03,
4938 4.14230573743777988e-03,
4939 ],
4940 )
4941 .expect("11x11 captured constraint-normal covariance");
4942 (mean, w)
4943 }
4944
4945 fn lower_cholesky(w: &Array2<f64>) -> Array2<f64> {
4946 let q = w.nrows();
4947 let mut factor = Array2::<f64>::zeros((q, q));
4948 for i in 0..q {
4949 for j in 0..=i {
4950 let mut sum = w[[i, j]];
4951 for k in 0..j {
4952 sum -= factor[[i, k]] * factor[[j, k]];
4953 }
4954 if i == j {
4955 factor[[i, i]] = f64::sqrt(sum);
4956 } else {
4957 factor[[i, j]] = sum / factor[[j, j]];
4958 }
4959 }
4960 }
4961 factor
4962 }
4963
4964 fn weight_efficiency(
4968 mean: &Array1<f64>,
4969 upper: &[f64],
4970 factor: ArrayView2<'_, f64>,
4971 tilt: Option<&Array1<f64>>,
4972 nodes: usize,
4973 ) -> (f64, f64) {
4974 struct WeightSpy {
4975 inner: OrthantAccumulator,
4976 log_weights: Vec<f64>,
4977 }
4978 impl OrthantNodeSink for WeightSpy {
4979 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
4980 self.log_weights.push(log_weight);
4981 self.inner.push(log_weight, point);
4982 }
4983 }
4984 let q = mean.len();
4985 let generator = kronecker_generator(q);
4986 let mut spy = WeightSpy {
4987 inner: OrthantAccumulator::new(q),
4988 log_weights: Vec::new(),
4989 };
4990 accumulate_orthant_nodes(
4991 &mut spy, mean, upper, None, factor, &generator, tilt, 0, 0, nodes,
4992 )
4993 .expect("orthant nodes");
4994 let finite: Vec<f64> = spy
4995 .log_weights
4996 .iter()
4997 .copied()
4998 .filter(|v| v.is_finite())
4999 .collect();
5000 let hi = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
5001 let lo = finite.iter().copied().fold(f64::INFINITY, f64::min);
5002 let sum: f64 = finite.iter().map(|v| (v - hi).exp()).sum();
5003 let sum_sq: f64 = finite.iter().map(|v| (2.0 * (v - hi)).exp()).sum();
5004 (
5005 (sum * sum / sum_sq) / finite.len() as f64,
5006 (hi - lo) / std::f64::consts::LN_10,
5007 )
5008 }
5009
5010 #[test]
5019 fn the_tilt_turns_a_monte_carlo_draw_back_into_a_cubature_2601() {
5020 let (mean, w) = refusing_face();
5021 let q = mean.len();
5022 let upper = vec![f64::INFINITY; q];
5023 let factor = lower_cholesky(&w);
5024
5025 let (untilted_ess, untilted_decades) =
5026 weight_efficiency(&mean, &upper, factor.view(), None, 1 << 16);
5027 let tilt = minimax_tilt(&mean, &upper, factor.view()).expect("this face is tilted");
5028 let (tilted_ess, tilted_decades) =
5029 weight_efficiency(&mean, &upper, factor.view(), Some(&tilt), 1 << 16);
5030
5031 println!(
5032 "MEASURE2601 untilted ess={:.4}% over {:.1} decades; \
5033 tilted ess={:.4}% over {:.1} decades",
5034 100.0 * untilted_ess,
5035 untilted_decades,
5036 100.0 * tilted_ess,
5037 tilted_decades,
5038 );
5039 assert!(
5040 untilted_ess < 0.05,
5041 "precondition: the untilted proposal wastes the node budget on this \
5042 face (ess {:.4}% of nodes)",
5043 100.0 * untilted_ess
5044 );
5045 assert!(
5046 tilted_ess > 0.5,
5047 "the tilt must make most nodes count; got ess {:.4}% of nodes over \
5048 {tilted_decades:.1} decades of weight",
5049 100.0 * tilted_ess
5050 );
5051 assert!(
5052 tilted_decades < 5.0,
5053 "the tilted weights must be nearly flat; got {tilted_decades:.1} decades"
5054 );
5055 }
5056
5057 #[test]
5067 fn the_face_that_refuses_2601_produces_the_right_moments() {
5068 let (mean, w) = refusing_face();
5069 let q = mean.len();
5070 let upper = vec![f64::INFINITY; q];
5071 let factor = lower_cholesky(&w);
5072
5073 let sd: Vec<f64> = (0..q).map(|i| f64::sqrt(w[[i, i]])).collect();
5076 let depth: Vec<f64> = (0..q).map(|i| -mean[i] / sd[i]).collect();
5077 let depth_min = depth.iter().copied().fold(f64::INFINITY, f64::min);
5078 let depth_max = depth.iter().copied().fold(f64::NEG_INFINITY, f64::max);
5079 let mut corr_max = 0.0f64;
5080 for i in 0..q {
5081 for j in 0..i {
5082 corr_max = corr_max.max(f64::abs(w[[i, j]] / (sd[i] * sd[j])));
5083 }
5084 }
5085 assert!(
5086 depth_max < 3.0 && depth_min < 0.0,
5087 "the refusing face is MILD in depth ({depth_min:.2}..{depth_max:.2} sd), \
5088 which is what rules depth out as the cause"
5089 );
5090 assert!(
5091 corr_max > 0.6,
5092 "the refusing face is strongly correlated (max |corr| = {corr_max:.3}), \
5093 which is the regime that fails"
5094 );
5095
5096 let (produced_mean, produced_cov) = box_truncated_moments(&mean, &upper, &w, factor.view())
5097 .expect("the face #2601 reports must produce moments");
5098
5099 let generator = kronecker_generator(q);
5100 let mut reference = OrthantAccumulator::new(q);
5101 accumulate_orthant_nodes(
5102 &mut reference,
5103 &mean,
5104 &upper,
5105 None,
5106 factor.view(),
5107 &generator,
5108 None,
5109 0,
5110 0,
5111 1 << 20,
5112 )
5113 .expect("untilted reference nodes");
5114 let truth = reference.moments().expect("reference moments");
5115 let gap = moment_relative_change(&(produced_mean, produced_cov), &truth, &w);
5116 println!("MEASURE2601 gap vs untilted 2^20 reference: {gap:.3e}");
5117 assert!(
5118 gap < 3.0e-2,
5119 "the tilted rule must agree with an INDEPENDENT untilted reference; \
5120 gap {gap:.3e} (the reference's own error at 2^20 is ~1.3e-2)"
5121 );
5122 }
5123
5124 #[test]
5134 fn the_tilt_resolves_every_correlated_face_the_sweep_could_not() {
5135 for &q in &[4usize, 8, 11] {
5136 for &c in &[0.5_f64, 1.0, 2.0, 4.0] {
5137 for &corr in &[0.0_f64, 0.6, 0.9] {
5138 let mut w = Array2::<f64>::zeros((q, q));
5139 for i in 0..q {
5140 for j in 0..q {
5141 w[[i, j]] = corr.powi((i as i32 - j as i32).abs());
5142 }
5143 }
5144 let mean = Array1::<f64>::from_elem(q, -c);
5145 let upper = vec![f64::INFINITY; q];
5146 let factor = lower_cholesky(&w);
5147 let (ess, decades) =
5148 weight_efficiency(&mean, &upper, factor.view(), None, 1 << 14);
5149 let tilt = minimax_tilt(&mean, &upper, factor.view());
5150 let (tilted_ess, tilted_decades) =
5151 weight_efficiency(&mean, &upper, factor.view(), tilt.as_ref(), 1 << 14);
5152 let outcome = box_truncated_moments(&mean, &upper, &w, factor.view());
5153 println!(
5154 "MEASURE2601 q={q} depth={c} corr={corr} \
5155 ess {:.2}%->{:.2}% decades {decades:.1}->{tilted_decades:.1} {}",
5156 100.0 * ess,
5157 100.0 * tilted_ess,
5158 if outcome.is_ok() { "converged" } else { "REFUSED" },
5159 );
5160 assert!(
5161 outcome.is_ok(),
5162 "q={q} depth={c} corr={corr} must converge: {:?}",
5163 outcome.err()
5164 );
5165 assert!(
5166 tilted_ess >= ess * 0.9,
5167 "the tilt must never make a face WORSE: q={q} depth={c} \
5168 corr={corr} ess {:.4}% -> {:.4}%",
5169 100.0 * ess,
5170 100.0 * tilted_ess
5171 );
5172 }
5173 }
5174 }
5175 }
5176}
5177
5178#[cfg(test)]
5179mod coverage_gate_tests {
5180 use super::*;
5181 use gam_linalg::triangular::{CholeskyGuard, cholesky_factor_in_place, cholesky_solve_vector};
5182
5183 struct SplitMix64 {
5186 state: u64,
5187 }
5188
5189 impl SplitMix64 {
5190 fn new(seed: u64) -> Self {
5191 Self { state: seed }
5192 }
5193 fn next_u64(&mut self) -> u64 {
5194 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
5195 let mut z = self.state;
5196 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
5197 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
5198 z ^ (z >> 31)
5199 }
5200 fn unit(&mut self) -> f64 {
5201 ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
5202 }
5203 fn normal(&mut self) -> f64 {
5204 let (u1, u2) = (self.unit().max(1.0e-12), self.unit());
5205 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
5206 }
5207 }
5208
5209 struct CoverageTally {
5211 covered: usize,
5212 replicates: usize,
5213 total_half_width: f64,
5214 }
5215
5216 impl CoverageTally {
5217 fn new() -> Self {
5218 Self {
5219 covered: 0,
5220 replicates: 0,
5221 total_half_width: 0.0,
5222 }
5223 }
5224 fn record(&mut self, center: f64, half_width: f64, truth: f64) {
5225 self.replicates += 1;
5226 self.total_half_width += half_width;
5227 if (truth - center).abs() <= half_width {
5228 self.covered += 1;
5229 }
5230 }
5231 fn coverage(&self) -> f64 {
5232 self.covered as f64 / self.replicates as f64
5233 }
5234 fn mean_half_width(&self) -> f64 {
5235 self.total_half_width / self.replicates as f64
5236 }
5237 }
5238
5239 struct CellResult {
5241 full_space: CoverageTally,
5242 active_face: CoverageTally,
5243 truncated: CoverageTally,
5244 truncated_mean_centred: CoverageTally,
5245 pinned_fraction: f64,
5246 }
5247
5248 const NOMINAL_HALF_WIDTH_MULTIPLIER: f64 = 1.959_963_984_540_054;
5250 const NOMINAL_COVERAGE: f64 = 0.95;
5251
5252 fn gaussian_posterior_covariance(gram: &Array2<f64>, noise_variance: f64) -> Array2<f64> {
5254 let p = gram.nrows();
5255 let factor = cholesky_factor_in_place(gram.view(), CholeskyGuard::FiniteStrict)
5256 .expect("simulation design is full rank");
5257 let mut covariance = Array2::<f64>::zeros((p, p));
5258 for j in 0..p {
5259 let mut unit = Array1::<f64>::zeros(p);
5260 unit[j] = 1.0;
5261 let column = cholesky_solve_vector(&factor, &unit);
5262 for i in 0..p {
5263 covariance[[i, j]] = noise_variance * column[i];
5264 }
5265 }
5266 covariance
5267 }
5268
5269 fn tight_rows_at(constraints: &LinearInequalityConstraints, beta: &Array1<f64>) -> Vec<usize> {
5272 let mut tight = Vec::new();
5273 for row_index in 0..constraints.a.nrows() {
5274 let row = constraints.a.row(row_index).to_owned();
5275 let norm = row.dot(&row).sqrt();
5276 if norm > 0.0
5277 && (row.dot(beta) - constraints.b[row_index]) / norm
5278 <= crate::active_set::ACTIVE_SET_WORKING_FACE_TOL
5279 {
5280 tight.push(row_index);
5281 }
5282 }
5283 tight
5284 }
5285
5286 fn active_face_variance(
5291 covariance: &Array2<f64>,
5292 constraints: &LinearInequalityConstraints,
5293 tight: &[usize],
5294 index: usize,
5295 ) -> f64 {
5296 if tight.is_empty() {
5297 return covariance[[index, index]];
5298 }
5299 let q = tight.len();
5300 let mut sigma_at = Array2::<f64>::zeros((covariance.nrows(), q));
5301 for (position, &row_index) in tight.iter().enumerate() {
5302 let column = covariance.dot(&constraints.a.row(row_index).to_owned());
5303 sigma_at.column_mut(position).assign(&column);
5304 }
5305 let mut normal = Array2::<f64>::zeros((q, q));
5306 for (i, &row_i) in tight.iter().enumerate() {
5307 for j in 0..q {
5308 normal[[i, j]] = constraints
5309 .a
5310 .row(row_i)
5311 .to_owned()
5312 .dot(&sigma_at.column(j).to_owned());
5313 }
5314 }
5315 let Some(factor) = cholesky_factor_in_place(normal.view(), CholeskyGuard::FiniteStrict)
5316 else {
5317 return 0.0;
5320 };
5321 let row = sigma_at.row(index).to_owned();
5322 let solved = cholesky_solve_vector(&factor, &row);
5323 covariance[[index, index]] - row.dot(&solved)
5324 }
5325
5326 fn run_cell(
5331 design: &Array2<f64>,
5332 truth: &Array1<f64>,
5333 constraints: &LinearInequalityConstraints,
5334 reported_index: usize,
5335 noise_sd: f64,
5336 replicates: usize,
5337 seed: u64,
5338 ) -> CellResult {
5339 let n = design.nrows();
5340 let p = design.ncols();
5341 let gram = design.t().dot(design);
5342 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
5343 let mut rng = SplitMix64::new(seed);
5344 let mut result = CellResult {
5345 full_space: CoverageTally::new(),
5346 active_face: CoverageTally::new(),
5347 truncated: CoverageTally::new(),
5348 truncated_mean_centred: CoverageTally::new(),
5349 pinned_fraction: 0.0,
5350 };
5351 let mean_response = design.dot(truth);
5352 let mut pinned = 0usize;
5353
5354 for _ in 0..replicates {
5355 let mut response = Array1::<f64>::zeros(n);
5356 for i in 0..n {
5357 response[i] = mean_response[i] + noise_sd * rng.normal();
5358 }
5359 let rhs = design.t().dot(&response);
5360 let start = crate::active_set::feasible_point_for_linear_constraints(constraints, p)
5361 .expect("the simulation cone has an interior");
5362 let (beta_hat, _) = crate::active_set::solve_quadratic_with_linear_constraints(
5363 &gram,
5364 &rhs,
5365 &start,
5366 constraints,
5367 None,
5368 )
5369 .expect("constrained quadratic solve");
5370
5371 let full_half_width =
5372 NOMINAL_HALF_WIDTH_MULTIPLIER * covariance[[reported_index, reported_index]].sqrt();
5373 result.full_space.record(
5374 beta_hat[reported_index],
5375 full_half_width,
5376 truth[reported_index],
5377 );
5378
5379 let tight = tight_rows_at(constraints, &beta_hat);
5380 if !tight.is_empty() {
5381 pinned += 1;
5382 }
5383 let face_variance =
5384 active_face_variance(&covariance, constraints, &tight, reported_index);
5385 result.active_face.record(
5386 beta_hat[reported_index],
5387 NOMINAL_HALF_WIDTH_MULTIPLIER * face_variance.max(0.0).sqrt(),
5388 truth[reported_index],
5389 );
5390
5391 let penalized_gradient = gram.dot(&beta_hat) - &rhs;
5395 let center = &beta_hat
5396 - &(covariance.dot(&penalized_gradient) / (noise_sd * noise_sd));
5397 let correction =
5398 constrained_posterior_correction_from_covariance(&covariance, ¢er, constraints)
5399 .expect("truncated correction");
5400 let (truncated_half_width, truncated_center) = match correction {
5401 None => (full_half_width, beta_hat[reported_index]),
5402 Some(ref correction) => {
5403 let variance = covariance[[reported_index, reported_index]]
5404 - correction.removed_variance_diagonal()[reported_index];
5405 (
5406 NOMINAL_HALF_WIDTH_MULTIPLIER * variance.max(0.0).sqrt(),
5407 correction.posterior_mean(¢er)[reported_index],
5408 )
5409 }
5410 };
5411 result.truncated.record(
5412 beta_hat[reported_index],
5413 truncated_half_width,
5414 truth[reported_index],
5415 );
5416 result.truncated_mean_centred.record(
5417 truncated_center,
5418 truncated_half_width,
5419 truth[reported_index],
5420 );
5421 }
5422 result.pinned_fraction = pinned as f64 / replicates as f64;
5423 result
5424 }
5425
5426 fn report_cell(label: &str, cell: &CellResult) {
5427 eprintln!(
5428 "[#2417 coverage] {label}: nominal {NOMINAL_COVERAGE:.2}, {} replicates, mode pinned \
5429 in {:.1}% of them",
5430 cell.full_space.replicates,
5431 100.0 * cell.pinned_fraction
5432 );
5433 for (name, tally) in [
5434 ("full space ", &cell.full_space),
5435 ("active face ", &cell.active_face),
5436 ("truncated ", &cell.truncated),
5437 ("truncated+mean shift", &cell.truncated_mean_centred),
5438 ] {
5439 eprintln!(
5440 "[#2417 coverage] {name} coverage {:.4} mean half-width {:.5}",
5441 tally.coverage(),
5442 tally.mean_half_width()
5443 );
5444 }
5445 }
5446
5447 #[test]
5452 fn box_bound_at_half_a_standard_error_separates_the_three_covariances() {
5453 let n = 60;
5454 let mut rng = SplitMix64::new(20_417);
5455 let mut design = Array2::<f64>::zeros((n, 2));
5456 for i in 0..n {
5457 design[[i, 0]] = 1.0;
5458 design[[i, 1]] = rng.normal();
5459 }
5460 let gram = design.t().dot(&design);
5461 let noise_sd = 1.0;
5462 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
5463 let standard_error = covariance[[1, 1]].sqrt();
5464 let truth = Array1::from_vec(vec![0.3, 0.5 * standard_error]);
5465 let constraints =
5466 LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
5467 .expect("nonnegativity bound");
5468
5469 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 91_137);
5470 report_cell("box bound, truth 0.5 se", &cell);
5471
5472 assert!(
5475 cell.pinned_fraction > 0.2,
5476 "the cell must actually exercise the boundary, pinned fraction {:.3}",
5477 cell.pinned_fraction
5478 );
5479 assert!(
5480 cell.active_face.coverage() < 0.80,
5481 "the active-face covariance must under-cover catastrophically here — it reports a \
5482 zero-width interval whenever the mode pins — but coverage was {:.4}",
5483 cell.active_face.coverage()
5484 );
5485 assert!(
5486 cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.01,
5487 "the truncated covariance must reach nominal coverage, got {:.4}",
5488 cell.truncated.coverage()
5489 );
5490 assert!(
5491 cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
5492 "and it must still reach it once the centre moves to the truncated posterior \
5493 mean, got {:.4}",
5494 cell.truncated_mean_centred.coverage()
5495 );
5496 assert!(
5497 cell.truncated.mean_half_width() < 0.85 * cell.full_space.mean_half_width(),
5498 "the truncated covariance must buy its coverage with materially SHORTER intervals \
5499 than the full-space answer: {:.5} vs {:.5}",
5500 cell.truncated.mean_half_width(),
5501 cell.full_space.mean_half_width()
5502 );
5503 assert!(
5504 cell.full_space.coverage() >= NOMINAL_COVERAGE,
5505 "the full-space covariance over-covers by construction, got {:.4}",
5506 cell.full_space.coverage()
5507 );
5508 }
5509
5510 #[test]
5524 fn narrowing_the_covariance_without_moving_the_mean_is_a_regression() {
5525 let n = 60;
5526 let mut rng = SplitMix64::new(31_417);
5527 let mut design = Array2::<f64>::zeros((n, 2));
5528 for i in 0..n {
5529 design[[i, 0]] = 1.0;
5530 design[[i, 1]] = rng.normal();
5531 }
5532 let gram = design.t().dot(&design);
5533 let noise_sd = 1.0;
5534 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
5535 let standard_error = covariance[[1, 1]].sqrt();
5536 let truth = Array1::from_vec(vec![-0.2, 1.5 * standard_error]);
5537 let constraints =
5538 LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
5539 .expect("nonnegativity bound");
5540
5541 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 47_903);
5542 report_cell("box bound, truth 1.5 se", &cell);
5543
5544 assert!(
5545 cell.truncated.coverage() < NOMINAL_COVERAGE - 0.02,
5546 "this cell exists BECAUSE the mode-centred truncated interval under-covers here; \
5547 if it stopped doing so the counterexample would no longer be testing anything, \
5548 got {:.4}",
5549 cell.truncated.coverage()
5550 );
5551 assert!(
5552 cell.truncated.coverage() < cell.active_face.coverage(),
5553 "the point of the cell: narrowing the covariance while leaving the interval \
5554 centred on the mode is worse than the active-face answer it replaces, {:.4} vs \
5555 {:.4}",
5556 cell.truncated.coverage(),
5557 cell.active_face.coverage()
5558 );
5559 assert!(
5560 cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
5561 "moving the centre to the truncated posterior mean recovers nominal coverage with \
5562 the same covariance, got {:.4}",
5563 cell.truncated_mean_centred.coverage()
5564 );
5565 assert!(
5566 cell.truncated_mean_centred.mean_half_width() < cell.full_space.mean_half_width(),
5567 "and it does so with shorter intervals than the full-space answer: {:.5} vs {:.5}",
5568 cell.truncated_mean_centred.mean_half_width(),
5569 cell.full_space.mean_half_width()
5570 );
5571 }
5572
5573 #[test]
5576 fn two_coupled_bounds_exercise_the_orthant_cubature() {
5577 let n = 80;
5578 let mut rng = SplitMix64::new(74_211);
5579 let mut design = Array2::<f64>::zeros((n, 3));
5580 for i in 0..n {
5581 design[[i, 0]] = 1.0;
5582 let shared = rng.normal();
5583 design[[i, 1]] = shared;
5584 design[[i, 2]] = 0.7 * shared + 0.7 * rng.normal();
5587 }
5588 let gram = design.t().dot(&design);
5589 let noise_sd = 1.0;
5590 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
5591 let truth = Array1::from_vec(vec![
5592 0.25,
5593 0.5 * covariance[[1, 1]].sqrt(),
5594 0.5 * covariance[[2, 2]].sqrt(),
5595 ]);
5596 let constraints = LinearInequalityConstraints::new(
5597 ndarray::array![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
5598 ndarray::array![0.0, 0.0],
5599 )
5600 .expect("two nonnegativity bounds");
5601
5602 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 600, 55_301);
5603 report_cell("two coupled bounds, truth 0.5 se", &cell);
5604
5605 assert!(
5606 cell.active_face.coverage() < 0.85,
5607 "the active-face covariance must under-cover here too, got {:.4}",
5608 cell.active_face.coverage()
5609 );
5610 assert!(
5611 cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.03,
5612 "the truncated covariance must reach nominal coverage through the orthant \
5613 cubature, got {:.4}",
5614 cell.truncated.coverage()
5615 );
5616 assert!(
5617 cell.truncated.mean_half_width() < cell.full_space.mean_half_width(),
5618 "shorter intervals at nominal coverage: {:.5} vs {:.5}",
5619 cell.truncated.mean_half_width(),
5620 cell.full_space.mean_half_width()
5621 );
5622 }
5623
5624}
5625
5626
5627#[cfg(test)]
5628mod affine_ceiling_tests {
5629 use super::*;
5630 use ndarray::array;
5631
5632 fn log_mass(
5635 mean: &Array1<f64>,
5636 sd: &Array1<f64>,
5637 ceiling: Option<&StandardizedCeiling>,
5638 upper: &[f64],
5639 nodes: usize,
5640 ) -> Option<f64> {
5641 let q = mean.len();
5642 let mut factor = Array2::<f64>::zeros((q, q));
5643 for i in 0..q {
5644 factor[[i, i]] = sd[i];
5645 }
5646 let generator = kronecker_generator(q);
5647 let mut accumulator = OrthantAccumulator::new(q);
5648 accumulate_orthant_nodes(
5649 &mut accumulator,
5650 mean,
5651 upper,
5652 ceiling,
5653 factor.view(),
5654 &generator,
5655 None,
5656 0,
5657 0,
5658 nodes,
5659 )
5660 .expect("cubature");
5661 if !(accumulator.weight_sum.is_finite() && accumulator.weight_sum > 0.0) {
5665 return None;
5666 }
5667 Some(accumulator.log_scale + accumulator.weight_sum.ln() - (nodes as f64).ln())
5668 }
5669
5670 fn diagonal_factor(sd: &Array1<f64>) -> Array2<f64> {
5671 let q = sd.len();
5672 let mut factor = Array2::<f64>::zeros((q, q));
5673 for i in 0..q {
5674 factor[[i, i]] = sd[i];
5675 }
5676 factor
5677 }
5678
5679 #[test]
5680 fn a_coordinate_normal_reproduces_the_box_it_is_the_degenerate_case_of() {
5681 let mean = array![0.35, -0.20];
5687 let sd = array![1.0, 0.8];
5688 let factor = diagonal_factor(&sd);
5689 let width = 1.4;
5690
5691 let boxed = log_mass(&mean, &sd, None, &[width, f64::INFINITY], 1 << 14)
5692 .expect("box mass");
5693 let wall = StandardizedCeiling::new(&array![1.0, 0.0], width, &mean, factor.view())
5694 .expect("coordinate wall");
5695 let affine = log_mass(
5696 &mean,
5697 &sd,
5698 Some(&wall),
5699 &[f64::INFINITY, f64::INFINITY],
5700 1 << 14,
5701 )
5702 .expect("affine mass");
5703 assert_eq!(wall.pivot, 0, "a normal touching only coordinate 0 pivots there");
5704 assert!(
5705 (boxed - affine).abs() < 1e-12,
5706 "box {boxed:.15} and affine {affine:.15} describe the same region"
5707 );
5708 }
5709
5710 #[test]
5711 fn an_affine_ceiling_removes_the_mass_it_should() {
5712 let mean = array![0.4, -0.3];
5717 let sd = array![0.9, 1.1];
5718 let factor = diagonal_factor(&sd);
5719 let bound = 1.6;
5720 let wall = StandardizedCeiling::new(&array![1.0, 1.0], bound, &mean, factor.view())
5721 .expect("sum wall");
5722 assert_eq!(wall.pivot, 1, "a wall touching both coordinates pivots on the last");
5723
5724 let got = log_mass(
5725 &mean,
5726 &sd,
5727 Some(&wall),
5728 &[f64::INFINITY, f64::INFINITY],
5729 1 << 16,
5730 )
5731 .expect("triangle mass");
5732
5733 let panels = 4000usize;
5735 let step = bound / panels as f64;
5736 let density = |x: f64| {
5737 let z = (x - mean[0]) / sd[0];
5738 (-0.5 * z * z).exp() / (sd[0] * (2.0 * std::f64::consts::PI).sqrt())
5739 };
5740 let inner = |x: f64| {
5741 let hi = (bound - x - mean[1]) / sd[1];
5742 let lo = -mean[1] / sd[1];
5743 if hi <= lo {
5744 0.0
5745 } else {
5746 normal_cdf(hi) - normal_cdf(lo)
5747 }
5748 };
5749 let integrand = |x: f64| density(x) * inner(x);
5750 let mut total = integrand(0.0) + integrand(bound);
5751 for k in 1..panels {
5752 let x = k as f64 * step;
5753 total += integrand(x) * if k % 2 == 0 { 2.0 } else { 4.0 };
5754 }
5755 let reference = (total * step / 3.0).ln();
5756 assert!(
5757 (got - reference).abs() < 5e-4,
5758 "cubature {got:.12} against the Simpson reference {reference:.12}"
5759 );
5760
5761 let unbounded = log_mass(
5765 &mean,
5766 &sd,
5767 None,
5768 &[f64::INFINITY, f64::INFINITY],
5769 1 << 16,
5770 )
5771 .expect("unbounded mass");
5772 assert!(
5773 unbounded > got + 0.05,
5774 "the wall removed {:.4} nats, which is not enough to call it active",
5775 unbounded - got
5776 );
5777 }
5778
5779 #[test]
5780 fn a_wall_that_crosses_the_orthant_leaves_no_mass() {
5781 let mean = array![0.2, 0.1];
5785 let sd = array![1.0, 1.0];
5786 let factor = diagonal_factor(&sd);
5787 let wall = StandardizedCeiling::new(&array![1.0, 1.0], -1.0, &mean, factor.view())
5788 .expect("infeasible wall");
5789 assert!(
5790 log_mass(&mean, &sd, Some(&wall), &[f64::INFINITY, f64::INFINITY], 1 << 10).is_none(),
5791 "an empty region reports no mass"
5792 );
5793 }
5794
5795 #[test]
5796 fn a_vanishing_normal_is_refused_rather_than_pivoted_arbitrarily() {
5797 let mean = array![0.0, 0.0];
5798 let factor = diagonal_factor(&array![1.0, 1.0]);
5799 let message = StandardizedCeiling::new(&array![0.0, 0.0], 1.0, &mean, factor.view())
5800 .expect_err("a zero normal constrains nothing");
5801 assert!(
5802 message.contains("vanished"),
5803 "the refusal must say the normal vanished, got: {message}"
5804 );
5805 let mismatched = StandardizedCeiling::new(&array![1.0], 1.0, &mean, factor.view())
5806 .expect_err("a normal of the wrong length is refused");
5807 assert!(mismatched.contains("length"), "got: {mismatched}");
5808 }
5809
5810 #[test]
5811 fn the_pivot_follows_the_factor_not_just_the_normal() {
5812 let mean = array![0.0, 0.0, 0.0];
5817 let mut factor = Array2::<f64>::zeros((3, 3));
5818 factor[[0, 0]] = 1.0;
5819 factor[[1, 0]] = 0.7;
5820 factor[[1, 1]] = 1.0;
5821 factor[[2, 0]] = 0.3;
5822 factor[[2, 1]] = 0.4;
5823 factor[[2, 2]] = 1.0;
5824 let wall = StandardizedCeiling::new(&array![0.0, 0.0, 1.0], 2.0, &mean, factor.view())
5825 .expect("last-coordinate normal");
5826 assert_eq!(wall.pivot, 2);
5827 let early = StandardizedCeiling::new(&array![1.0, 0.0, 0.0], 2.0, &mean, factor.view())
5828 .expect("first-coordinate normal");
5829 assert_eq!(
5830 early.pivot, 0,
5831 "a normal on coordinate 0 cannot reach a later coordinate through a lower-triangular factor"
5832 );
5833 }
5834}
5835
5836
5837#[cfg(test)]
5838mod projection_law_2446_tests {
5839 use super::*;
5840 use ndarray::array;
5841
5842 fn integrand(w: f64) -> f64 {
5847 1.0 / (1.0 + (-0.5 + w).exp())
5848 }
5849
5850 fn simpson<F: Fn(f64) -> f64>(lower: f64, upper: f64, points: usize, f: F) -> f64 {
5852 assert!(points % 2 == 1, "Simpson needs an odd point count");
5853 let h = (upper - lower) / ((points - 1) as f64);
5854 let mut total = 0.0;
5855 for index in 0..points {
5856 let weight = if index == 0 || index == points - 1 {
5857 1.0
5858 } else if index % 2 == 1 {
5859 4.0
5860 } else {
5861 2.0
5862 };
5863 total += weight * f(lower + h * (index as f64));
5864 }
5865 total * h / 3.0
5866 }
5867
5868 fn exact_orthant_expectation(
5873 center: &Array1<f64>,
5874 covariance: &Array2<f64>,
5875 contrast: &Array1<f64>,
5876 ) -> f64 {
5877 exact_orthant_expectation_of(center, covariance, contrast, integrand)
5878 }
5879
5880 fn exact_orthant_expectation_of<F: Fn(f64) -> f64>(
5882 center: &Array1<f64>,
5883 covariance: &Array2<f64>,
5884 contrast: &Array1<f64>,
5885 functional: F,
5886 ) -> f64 {
5887 let det = covariance[[0, 0]] * covariance[[1, 1]] - covariance[[0, 1]] * covariance[[1, 0]];
5888 let inverse = array![
5889 [covariance[[1, 1]] / det, -covariance[[0, 1]] / det],
5890 [-covariance[[1, 0]] / det, covariance[[0, 0]] / det]
5891 ];
5892 let density = |b0: f64, b1: f64| -> f64 {
5893 let d0 = b0 - center[0];
5894 let d1 = b1 - center[1];
5895 let quadratic = inverse[[0, 0]] * d0 * d0
5896 + 2.0 * inverse[[0, 1]] * d0 * d1
5897 + inverse[[1, 1]] * d1 * d1;
5898 (-0.5 * quadratic).exp()
5899 };
5900 let upper0 = center[0].max(0.0) + 12.0 * covariance[[0, 0]].sqrt();
5904 let upper1 = center[1].max(0.0) + 12.0 * covariance[[1, 1]].sqrt();
5905 let points = 2001;
5906 let mass = simpson(0.0, upper0, points, |b0| {
5907 simpson(0.0, upper1, points, |b1| density(b0, b1))
5908 });
5909 let weighted = simpson(0.0, upper0, points, |b0| {
5910 simpson(0.0, upper1, points, |b1| {
5911 density(b0, b1) * functional(contrast[0] * b0 + contrast[1] * b1)
5912 })
5913 });
5914 weighted / mass
5915 }
5916
5917 fn normal_expectation(mean: f64, variance: f64) -> f64 {
5921 let sd = variance.sqrt();
5922 let points = 4001;
5923 simpson(mean - 12.0 * sd, mean + 12.0 * sd, points, |w| {
5924 let z = (w - mean) / sd;
5925 (-0.5 * z * z).exp() * integrand(w)
5926 }) / (sd * (2.0 * std::f64::consts::PI).sqrt())
5927 }
5928
5929 fn normal_infeasible_mass(mean: f64, variance: f64) -> f64 {
5932 let sd = variance.sqrt();
5933 let lower = mean - 12.0 * sd;
5934 if lower >= 0.0 {
5935 return 0.0;
5936 }
5937 simpson(lower, 0.0, 4001, |w| {
5938 let z = (w - mean) / sd;
5939 (-0.5 * z * z).exp()
5940 }) / (sd * (2.0 * std::f64::consts::PI).sqrt())
5941 }
5942
5943 #[test]
5957 fn projection_law_beats_the_moment_matched_normal_on_a_two_row_cone_2446() {
5958 let ambient = array![[0.40, 0.24], [0.24, 0.36]];
5959 let center = array![0.05, -0.10];
5960 let contrast = array![0.70, 0.30];
5961 let constraints =
5962 LinearInequalityConstraints::new(array![[1.0, 0.0], [0.0, 1.0]], array![0.0, 0.0])
5963 .expect("build the two-row non-negativity cone");
5964 let correction =
5965 constrained_posterior_correction_from_covariance(&ambient, ¢er, &constraints)
5966 .expect("the correction is computable on this face")
5967 .expect("a centre straddling both walls must retain the face");
5968 let mut retained = correction.rows.clone();
5970 retained.sort_unstable();
5971 assert_eq!(
5972 retained,
5973 vec![0, 1],
5974 "the fixture must retain BOTH rows; a one-row face is the closed-form case and \
5975 would measure nothing about the pushforward"
5976 );
5977 let geometry = ConstrainedPosteriorGeometry {
5978 constraints,
5979 mode: array![0.0, 0.0],
5980 unconstrained_center: Some(center.clone()),
5981 correction: Some(correction.clone()),
5982 moment_status: ConstrainedPosteriorMomentStatus::Available,
5983 };
5984
5985 let law = constrained_projection_law(&ambient, &geometry, &contrast)
5987 .expect("projection law on a retained face");
5988 assert!(
5989 law.residual_variance <= 1e-12 * ambient[[0, 0]],
5990 "with `A = I` the contrast is carried entirely by the constraint normals, so the \
5991 tangent component must vanish and the mixture must BE the whole law; got residual \
5992 variance {:.3e}",
5993 law.residual_variance
5994 );
5995 let weight_sum = law.nodes.iter().map(|(_, weight)| weight).sum::<f64>();
5996 assert!(
5997 (weight_sum - 1.0).abs() < 1e-9,
5998 "node weights must be normalized, got {weight_sum:.12e}"
5999 );
6000 let infeasible_nodes = law
6001 .nodes
6002 .iter()
6003 .filter(|(location, _)| *location < 0.0)
6004 .count();
6005 assert_eq!(
6006 infeasible_nodes, 0,
6007 "every cubature node is a point of the retained orthant, so no node may carry a \
6008 negative warp"
6009 );
6010 let node_sum = law
6011 .nodes
6012 .iter()
6013 .map(|(location, weight)| weight * integrand(*location))
6014 .sum::<f64>();
6015
6016 let posterior_mean = contrast.dot(&correction.posterior_mean(¢er));
6019 let corrected = correction.apply_to_covariance(&ambient);
6020 let posterior_variance = contrast.dot(&corrected.dot(&contrast));
6021 let normal_value = normal_expectation(posterior_mean, posterior_variance);
6022 let infeasible_mass = normal_infeasible_mass(posterior_mean, posterior_variance);
6023
6024 let reference = exact_orthant_expectation(¢er, &ambient, &contrast);
6025 let node_error = (node_sum - reference).abs();
6026 let normal_error = (normal_value - reference).abs();
6027 eprintln!(
6028 "[2446] nodes={} reference={reference:.12e} node_sum={node_sum:.12e} \
6029 (err {node_error:.3e}) normal={normal_value:.12e} (err {normal_error:.3e}) \
6030 normal_infeasible_mass={infeasible_mass:.4e} \
6031 posterior_mean={posterior_mean:.9e} law_mean={:.9e} \
6032 posterior_variance={posterior_variance:.9e} law_variance={:.9e}",
6033 law.nodes.len(),
6034 law.mean(),
6035 law.variance()
6036 );
6037
6038 let moment_tolerance = 4.0 * ORTHANT_MOMENT_RELATIVE_TOLERANCE;
6045 assert!(
6046 (law.mean() - posterior_mean).abs()
6047 <= moment_tolerance * posterior_variance.sqrt(),
6048 "the node mixture's mean must be the reported posterior mean: law {:.9e} vs \
6049 reported {posterior_mean:.9e}",
6050 law.mean()
6051 );
6052 assert!(
6053 (law.variance() - posterior_variance).abs()
6054 <= moment_tolerance * posterior_variance,
6055 "the node mixture's variance must be the reported posterior variance: law {:.9e} \
6056 vs reported {posterior_variance:.9e}",
6057 law.variance()
6058 );
6059
6060 assert!(
6062 infeasible_mass > 1.0e-2,
6063 "the moment-matched normal must put a non-trivial share of its mass on warps the \
6064 cone excludes, or this fixture measures nothing; got {infeasible_mass:.3e}"
6065 );
6066 assert!(
6070 node_error < 1.0e-4,
6071 "the node mixture must agree with the exact pushforward: reference \
6072 {reference:.12e}, node sum {node_sum:.12e} (error {node_error:.3e}); the \
6073 moment-matched normal is at {normal_value:.12e} (error {normal_error:.3e}) with \
6074 {infeasible_mass:.3e} of its mass on w < 0"
6075 );
6076 }
6077
6078 #[test]
6103 fn joint_cubature_carries_the_tangent_block_at_a_bounded_point_count_2679() {
6104 let ambient = array![[0.40, 0.24], [0.24, 0.36]];
6105 let center = array![0.05, -0.10];
6106 let contrast = array![0.70, 0.30];
6107 let tangent_sd = 0.6_f64;
6110 let constraints =
6111 LinearInequalityConstraints::new(array![[1.0, 0.0], [0.0, 1.0]], array![0.0, 0.0])
6112 .expect("build the two-row non-negativity cone");
6113 let correction =
6114 constrained_posterior_correction_from_covariance(&ambient, ¢er, &constraints)
6115 .expect("the correction is computable on this face")
6116 .expect("a centre straddling both walls must retain the face");
6117 let mut retained = correction.rows.clone();
6118 retained.sort_unstable();
6119 assert_eq!(
6120 retained,
6121 vec![0, 1],
6122 "the fixture must retain BOTH rows or the pushforward is the closed-form case"
6123 );
6124
6125 let upper_limits = correction.upper_limits();
6128 let normal_center = Array1::from_vec(
6129 correction
6130 .rows
6131 .iter()
6132 .map(|&row| center[row])
6133 .collect::<Vec<_>>(),
6134 );
6135 let normal_covariance = {
6136 let mut out = Array2::<f64>::zeros((2, 2));
6137 for (i, &row_i) in correction.rows.iter().enumerate() {
6138 for (j, &row_j) in correction.rows.iter().enumerate() {
6139 out[[i, j]] = ambient[[row_i, row_j]];
6140 }
6141 }
6142 out
6143 };
6144 let lift_contrast = Array1::from_vec(
6145 correction
6146 .rows
6147 .iter()
6148 .map(|&row| contrast[row])
6149 .collect::<Vec<_>>(),
6150 );
6151
6152 const POINTS: usize = 1 << 13;
6153 let joint = constrained_posterior_joint_cubature(
6154 &normal_center,
6155 &normal_covariance,
6156 &upper_limits,
6157 1,
6158 POINTS,
6159 )
6160 .expect("joint cubature on a retained two-row face");
6161 assert_eq!(
6162 joint.len(),
6163 POINTS,
6164 "the joint rule's cost is the point count it was asked for and nothing else"
6165 );
6166
6167 let infeasible_points = joint
6170 .iter()
6171 .filter(|point| point.normal_coordinates.iter().any(|&value| value < 0.0))
6172 .count();
6173 assert_eq!(
6174 infeasible_points, 0,
6175 "every joint point must lie in the retained cone; {infeasible_points} of {POINTS} did \
6176 not"
6177 );
6178
6179 let weight_sum = joint.iter().map(|point| point.weight).sum::<f64>();
6180 assert!(
6181 (weight_sum - 1.0).abs() < 1e-9,
6182 "joint weights must be normalized, got {weight_sum:.12e}"
6183 );
6184
6185 let tangent_mean = joint
6187 .iter()
6188 .map(|point| point.weight * point.tangent[0])
6189 .sum::<f64>();
6190 let tangent_second = joint
6191 .iter()
6192 .map(|point| point.weight * point.tangent[0] * point.tangent[0])
6193 .sum::<f64>();
6194 eprintln!(
6195 "[2679] points={POINTS} tangent_mean={tangent_mean:.6e} \
6196 tangent_second={tangent_second:.6e}"
6197 );
6198 assert!(
6199 tangent_mean.abs() < 2.0e-2,
6200 "the tangent block must integrate to a zero mean under the SOV weights, got \
6201 {tangent_mean:.6e}"
6202 );
6203 assert!(
6204 (tangent_second - 1.0).abs() < 5.0e-2,
6205 "the tangent block must integrate to unit variance under the SOV weights, got \
6206 {tangent_second:.6e}"
6207 );
6208
6209 let joint_value = joint
6210 .iter()
6211 .map(|point| {
6212 let normal_part = lift_contrast.dot(&point.normal_coordinates);
6213 point.weight * integrand(normal_part + tangent_sd * point.tangent[0])
6214 })
6215 .sum::<f64>();
6216
6217 let convolved = |x: f64| -> f64 {
6219 simpson(
6220 x - 12.0 * tangent_sd,
6221 x + 12.0 * tangent_sd,
6222 4001,
6223 |value| {
6224 let z = (value - x) / tangent_sd;
6225 (-0.5 * z * z).exp() * integrand(value)
6226 },
6227 ) / (tangent_sd * (2.0 * std::f64::consts::PI).sqrt())
6228 };
6229 let reference = exact_orthant_expectation_of(¢er, &ambient, &contrast, convolved);
6230
6231 let posterior_mean = contrast.dot(&correction.posterior_mean(¢er));
6235 let corrected = correction.apply_to_covariance(&ambient);
6236 let posterior_variance =
6237 contrast.dot(&corrected.dot(&contrast)) + tangent_sd * tangent_sd;
6238 let normal_value = normal_expectation(posterior_mean, posterior_variance);
6239
6240 let joint_error = (joint_value - reference).abs();
6241 let normal_error = (normal_value - reference).abs();
6242 eprintln!(
6243 "[2679] reference={reference:.12e} joint={joint_value:.12e} (err {joint_error:.3e}) \
6244 normal={normal_value:.12e} (err {normal_error:.3e})"
6245 );
6246 assert!(
6247 normal_error > 1.0e-4,
6248 "the fixture must leave the moment-matched normal measurably wrong, or the \
6249 comparison below is vacuous; got {normal_error:.3e}"
6250 );
6251 assert!(
6252 joint_error < 0.2 * normal_error,
6253 "the joint rule must be decisively closer to the exact pushforward than the \
6254 moment-matched normal: joint error {joint_error:.3e} vs normal error \
6255 {normal_error:.3e} against reference {reference:.12e}"
6256 );
6257 }
6258}