1use gam_math::probability::{
88 normal_cdf, normal_logsf, signed_probit_logcdf_and_mills_ratio, standard_normal_quantile,
89 standard_normal_quantile_from_log_cdf,
90};
91use gam_problem::LinearInequalityConstraints;
92use ndarray::{Array1, Array2, ArrayView2};
93use serde::{Deserialize, Serialize};
94
95const ORTHANT_MOMENT_RELATIVE_TOLERANCE: f64 = 1e-3;
111
112const ORTHANT_MOMENT_INITIAL_POINTS: usize = 1 << 11;
118
119const ORTHANT_MOMENT_MAXIMUM_POINTS: usize = 1 << 20;
124
125#[derive(Clone, Debug, Serialize, Deserialize)]
133pub struct ConstrainedPosteriorCorrection {
134 pub lift: Array2<f64>,
136 pub removed_normal_variance: Array2<f64>,
139 pub normal_mean_shift: Array1<f64>,
143 pub rows: Vec<usize>,
145}
146
147impl ConstrainedPosteriorCorrection {
148 pub fn apply_to_covariance_in_place(&self, covariance: &mut Array2<f64>) {
151 let scaled = self.lift.dot(&self.removed_normal_variance);
152 let p = covariance.nrows();
153 for i in 0..p {
154 for j in 0..=i {
155 let removed = scaled.row(i).dot(&self.lift.row(j));
156 covariance[[i, j]] -= removed;
157 if i != j {
158 covariance[[j, i]] = covariance[[i, j]];
159 }
160 }
161 }
162 }
163
164 pub fn apply_to_covariance(&self, covariance: &Array2<f64>) -> Array2<f64> {
166 let mut corrected = covariance.clone();
167 self.apply_to_covariance_in_place(&mut corrected);
168 corrected
169 }
170
171 pub fn removed_variance_diagonal(&self) -> Array1<f64> {
174 let scaled = self.lift.dot(&self.removed_normal_variance);
175 let p = self.lift.nrows();
176 let mut diagonal = Array1::<f64>::zeros(p);
177 for i in 0..p {
178 diagonal[i] = scaled.row(i).dot(&self.lift.row(i));
179 }
180 diagonal
181 }
182
183 pub fn posterior_mean(&self, unconstrained_center: &Array1<f64>) -> Array1<f64> {
185 unconstrained_center + &self.lift.dot(&self.normal_mean_shift)
186 }
187}
188
189#[derive(Clone, Debug, Serialize, Deserialize)]
205pub struct ConstrainedPosteriorGeometry {
206 pub constraints: LinearInequalityConstraints,
209 pub mode: Array1<f64>,
210 pub unconstrained_center: Array1<f64>,
211 pub correction: Option<ConstrainedPosteriorCorrection>,
216}
217
218impl ConstrainedPosteriorGeometry {
219 pub fn posterior_mean(&self) -> Array1<f64> {
220 self.correction
221 .as_ref()
222 .map(|correction| correction.posterior_mean(&self.unconstrained_center))
223 .unwrap_or_else(|| self.unconstrained_center.clone())
224 }
225
226 pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), String> {
227 if self.constraints.a.ncols() != dimension
228 || self.constraints.a.nrows() != self.constraints.b.len()
229 {
230 return Err(format!(
231 "constrained posterior inequalities have shape {}x{} with {} bounds, expected {dimension} columns",
232 self.constraints.a.nrows(),
233 self.constraints.a.ncols(),
234 self.constraints.b.len()
235 ));
236 }
237 if self.mode.len() != dimension || self.unconstrained_center.len() != dimension {
238 return Err(format!(
239 "constrained posterior locations have lengths mode={} and center={}, expected {dimension}",
240 self.mode.len(),
241 self.unconstrained_center.len()
242 ));
243 }
244 if self
245 .mode
246 .iter()
247 .chain(self.unconstrained_center.iter())
248 .chain(self.constraints.a.iter())
249 .chain(self.constraints.b.iter())
250 .any(|value| !value.is_finite())
251 {
252 return Err("constrained posterior geometry contains a non-finite value".to_string());
253 }
254 if let Some(correction) = self.correction.as_ref() {
255 let q = correction.lift.ncols();
256 if correction.lift.nrows() != dimension {
257 return Err(format!(
258 "constrained posterior lift has {} rows, expected {dimension}",
259 correction.lift.nrows()
260 ));
261 }
262 if correction.removed_normal_variance.dim() != (q, q)
263 || correction.normal_mean_shift.len() != q
264 || correction.rows.len() != q
265 {
266 return Err(format!(
267 "constrained posterior normal geometry is inconsistent: lift={}x{q}, removed={:?}, mean={}, rows={}",
268 correction.lift.nrows(),
269 correction.removed_normal_variance.dim(),
270 correction.normal_mean_shift.len(),
271 correction.rows.len()
272 ));
273 }
274 let mut unique_rows = correction.rows.clone();
275 unique_rows.sort_unstable();
276 unique_rows.dedup();
277 if unique_rows.len() != q
278 || unique_rows
279 .iter()
280 .any(|&row| row >= self.constraints.a.nrows())
281 {
282 return Err(format!(
283 "constrained posterior retained rows {:?} are not unique valid indices for {} inequalities",
284 correction.rows,
285 self.constraints.a.nrows()
286 ));
287 }
288 if correction
289 .lift
290 .iter()
291 .chain(correction.removed_normal_variance.iter())
292 .chain(correction.normal_mean_shift.iter())
293 .any(|value| !value.is_finite())
294 {
295 return Err(
296 "constrained posterior correction contains a non-finite value".to_string()
297 );
298 }
299 }
300 Ok(())
301 }
302}
303
304pub fn constrained_projection_equal_tailed_interval(
322 ambient_covariance: &Array2<f64>,
323 geometry: &ConstrainedPosteriorGeometry,
324 contrast: &Array1<f64>,
325 level: f64,
326) -> Result<(f64, f64), String> {
327 let p = contrast.len();
328 geometry.validate_for_dimension(p)?;
329 if ambient_covariance.dim() != (p, p) {
330 return Err(format!(
331 "constrained projection interval needs a {p}x{p} ambient covariance, got {:?}",
332 ambient_covariance.dim()
333 ));
334 }
335 if !(level.is_finite() && level > 0.0 && level < 1.0) {
336 return Err(format!(
337 "constrained projection interval level must lie in (0, 1), got {level}"
338 ));
339 }
340 if ambient_covariance.iter().any(|value| !value.is_finite())
341 || contrast.iter().any(|value| !value.is_finite())
342 {
343 return Err(
344 "constrained projection interval received a non-finite covariance or contrast"
345 .to_string(),
346 );
347 }
348
349 let ambient_mean = contrast.dot(&geometry.unconstrained_center);
350 let sigma_c = ambient_covariance.dot(contrast);
351 let ambient_variance = contrast.dot(&sigma_c);
352 let covariance_scale = ambient_covariance
353 .diag()
354 .iter()
355 .map(|value| value.abs())
356 .fold(f64::MIN_POSITIVE, f64::max);
357 let contrast_scale = contrast.dot(contrast).max(f64::MIN_POSITIVE);
358 let variance_floor =
359 (p.max(1) as f64) * f64::EPSILON * covariance_scale * contrast_scale;
360 if ambient_variance < -variance_floor || !ambient_variance.is_finite() {
361 return Err(format!(
362 "constrained projection interval has invalid ambient variance {ambient_variance:.6e}"
363 ));
364 }
365 let ambient_variance = ambient_variance.max(0.0);
366 let alpha = 0.5 * (1.0 - level);
367
368 let Some(correction) = geometry.correction.as_ref() else {
369 let sd = ambient_variance.sqrt();
370 if sd == 0.0 {
371 return Ok((ambient_mean, ambient_mean));
372 }
373 let z = standard_normal_quantile(1.0 - alpha)
374 .map_err(|error| format!("constrained projection normal quantile: {error}"))?;
375 return Ok((ambient_mean - z * sd, ambient_mean + z * sd));
376 };
377
378 let q = correction.rows.len();
379 let mut normal_center = Array1::<f64>::zeros(q);
380 let mut normal_covariance = Array2::<f64>::zeros((q, q));
381 let mut sigma_a = Array2::<f64>::zeros((p, q));
382 for (position, &row) in correction.rows.iter().enumerate() {
383 let a = geometry.constraints.a.row(row);
384 normal_center[position] = a.dot(&geometry.unconstrained_center)
385 - geometry.constraints.b[row];
386 sigma_a
387 .column_mut(position)
388 .assign(&ambient_covariance.dot(&a));
389 }
390 for i in 0..q {
391 let ai = geometry.constraints.a.row(correction.rows[i]);
392 for j in 0..=i {
393 let value = ai.dot(&sigma_a.column(j));
394 normal_covariance[[i, j]] = value;
395 normal_covariance[[j, i]] = value;
396 }
397 }
398
399 let projection_lift = correction.lift.t().dot(contrast);
400 let normal_component_variance =
401 projection_lift.dot(&normal_covariance.dot(&projection_lift));
402 let residual_variance = ambient_variance - normal_component_variance;
403 let residual_floor = (p.max(q).max(1) as f64)
404 * f64::EPSILON
405 * ambient_variance.max(normal_component_variance).max(f64::MIN_POSITIVE);
406 if residual_variance < -residual_floor || !residual_variance.is_finite() {
407 return Err(format!(
408 "constrained projection decomposition produced residual variance \
409 {residual_variance:.6e} from ambient {ambient_variance:.6e}"
410 ));
411 }
412 let residual_variance = residual_variance.max(0.0);
413 let posterior_mean =
414 ambient_mean + projection_lift.dot(&correction.normal_mean_shift);
415 if q == 1 && residual_variance == 0.0 && projection_lift[0] != 0.0 {
416 let scalar_quantile = |probability: f64| -> Result<f64, String> {
417 let normal_probability = if projection_lift[0] > 0.0 {
418 probability
419 } else {
420 1.0 - probability
421 };
422 let value = scalar_lower_truncated_quantile(
423 normal_center[0],
424 normal_covariance[[0, 0]],
425 normal_probability,
426 )?;
427 Ok(ambient_mean + projection_lift[0] * (value - normal_center[0]))
428 };
429 return Ok((scalar_quantile(alpha)?, scalar_quantile(1.0 - alpha)?));
430 }
431 let nodes = converged_projection_nodes(
432 &normal_center,
433 &normal_covariance,
434 &projection_lift,
435 ambient_mean,
436 )?;
437 let lower = projection_quantile(
438 &nodes,
439 residual_variance,
440 alpha,
441 posterior_mean,
442 ambient_variance.sqrt(),
443 )?;
444 let upper = projection_quantile(
445 &nodes,
446 residual_variance,
447 1.0 - alpha,
448 posterior_mean,
449 ambient_variance.sqrt(),
450 )?;
451 Ok((lower, upper))
452}
453
454fn scalar_lower_truncated_quantile(
455 mean: f64,
456 variance: f64,
457 probability: f64,
458) -> Result<f64, String> {
459 if !(variance.is_finite() && variance > 0.0) {
460 return Err(format!(
461 "scalar truncated quantile needs positive finite variance, got {variance:?}"
462 ));
463 }
464 if !(probability.is_finite() && probability > 0.0 && probability < 1.0) {
465 return Err(format!(
466 "scalar truncated quantile probability must lie in (0, 1), got {probability}"
467 ));
468 }
469 let sd = variance.sqrt();
470 let alpha = -mean / sd;
471 let log_tail = (1.0 - probability).ln() + normal_logsf(alpha);
474 let z = -standard_normal_quantile_from_log_cdf(log_tail)
475 .map_err(|error| format!("scalar truncated quantile: {error}"))?;
476 Ok(mean + sd * z)
477}
478
479pub fn constrained_posterior_correction_from_covariance(
494 covariance: &Array2<f64>,
495 unconstrained_center: &Array1<f64>,
496 constraints: &LinearInequalityConstraints,
497) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
498 let p = covariance.nrows();
499 if covariance.ncols() != p {
500 return Err(format!(
501 "constrained posterior correction needs a square covariance, got {}x{}",
502 covariance.nrows(),
503 covariance.ncols()
504 ));
505 }
506 if constraints.a.ncols() != p {
507 return Err(format!(
508 "constrained posterior correction: covariance is {p}x{p} but the constraint \
509 system has {} columns",
510 constraints.a.ncols()
511 ));
512 }
513 let sigma_times_at = covariance.dot(&constraints.a.t());
514 constrained_posterior_correction(sigma_times_at.view(), unconstrained_center, constraints)
515}
516
517pub fn constrained_posterior_correction(
524 sigma_times_constraint_transpose: ArrayView2<'_, f64>,
525 unconstrained_center: &Array1<f64>,
526 constraints: &LinearInequalityConstraints,
527) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
528 let p = sigma_times_constraint_transpose.nrows();
529 if sigma_times_constraint_transpose.ncols() != constraints.a.nrows() {
530 return Err(format!(
531 "constrained posterior correction: the constraint system has {} rows but \
532 Sigma·Aᵀ has {} columns",
533 constraints.a.nrows(),
534 sigma_times_constraint_transpose.ncols()
535 ));
536 }
537 if unconstrained_center.len() != p {
538 return Err(format!(
539 "constrained posterior correction: Sigma·Aᵀ has {p} rows but the centre has \
540 length {}",
541 unconstrained_center.len()
542 ));
543 }
544 if constraints.a.ncols() != p {
545 return Err(format!(
546 "constrained posterior correction: Sigma·Aᵀ has {p} rows but the constraint \
547 system has {} columns",
548 constraints.a.ncols()
549 ));
550 }
551
552 let slack_horizon = -standard_normal_quantile(f64::EPSILON)
556 .map_err(|error| format!("resolution horizon for the constraint slack: {error}"))?;
557
558 let mut candidates: Vec<(usize, f64, Array1<f64>)> = Vec::new();
561 for row_index in 0..constraints.a.nrows() {
562 let row = constraints.a.row(row_index).to_owned();
563 let sigma_row = sigma_times_constraint_transpose
564 .column(row_index)
565 .to_owned();
566 let variance = row.dot(&sigma_row);
567 if !(variance.is_finite() && variance > 0.0) {
568 continue;
571 }
572 let slack = (row.dot(unconstrained_center) - constraints.b[row_index]) / variance.sqrt();
573 if !slack.is_finite() {
574 return Err(format!(
575 "constraint row {row_index} produced a non-finite standardized slack"
576 ));
577 }
578 if slack < slack_horizon {
579 candidates.push((row_index, slack, sigma_row));
580 }
581 }
582 if candidates.is_empty() {
583 return Ok(None);
584 }
585 candidates.sort_by(|left, right| {
586 left.1
587 .partial_cmp(&right.1)
588 .unwrap_or(std::cmp::Ordering::Equal)
589 .then_with(|| left.0.cmp(&right.0))
590 });
591
592 let mut rows: Vec<usize> = Vec::new();
596 let mut sigma_a_columns: Vec<Array1<f64>> = Vec::new();
597 let mut offsets: Vec<f64> = Vec::new();
598 let mut w_accepted = Array2::<f64>::zeros((0, 0));
599 let mut factor = Array2::<f64>::zeros((0, 0));
600 for (row_index, _, sigma_row) in candidates {
601 let row = constraints.a.row(row_index);
602 let accepted = rows.len();
603 let diagonal = row.dot(&sigma_row);
604 let mut cross = Array1::<f64>::zeros(accepted);
605 for (position, column) in sigma_a_columns.iter().enumerate() {
606 cross[position] = row.dot(column);
607 }
608 let mut new_column = Array1::<f64>::zeros(accepted);
610 for i in 0..accepted {
611 let mut sum = cross[i];
612 for k in 0..i {
613 sum -= factor[[i, k]] * new_column[k];
614 }
615 new_column[i] = sum / factor[[i, i]];
616 }
617 let pivot = diagonal - new_column.dot(&new_column);
618 let rank_floor = (accepted + 1) as f64 * f64::EPSILON * diagonal;
619 if !(pivot.is_finite() && pivot > rank_floor) {
620 continue;
621 }
622 let mut grown = Array2::<f64>::zeros((accepted + 1, accepted + 1));
623 grown
624 .slice_mut(ndarray::s![..accepted, ..accepted])
625 .assign(&factor);
626 for i in 0..accepted {
627 grown[[accepted, i]] = new_column[i];
628 }
629 grown[[accepted, accepted]] = pivot.sqrt();
630 factor = grown;
631
632 let mut grown_w = Array2::<f64>::zeros((accepted + 1, accepted + 1));
633 grown_w
634 .slice_mut(ndarray::s![..accepted, ..accepted])
635 .assign(&w_accepted);
636 for i in 0..accepted {
637 grown_w[[accepted, i]] = cross[i];
638 grown_w[[i, accepted]] = cross[i];
639 }
640 grown_w[[accepted, accepted]] = diagonal;
641 w_accepted = grown_w;
642
643 rows.push(row_index);
644 sigma_a_columns.push(sigma_row);
645 offsets.push(constraints.b[row_index]);
646 }
647 if rows.is_empty() {
648 return Ok(None);
649 }
650
651 let q = rows.len();
652 let mut sigma_at = Array2::<f64>::zeros((p, q));
653 for (position, column) in sigma_a_columns.iter().enumerate() {
654 sigma_at.column_mut(position).assign(column);
655 }
656 let lift = cholesky_solve_right(&factor, &sigma_at)?;
659
660 let mut normal_center = Array1::<f64>::zeros(q);
661 for (position, &row_index) in rows.iter().enumerate() {
662 normal_center[position] =
663 constraints.a.row(row_index).dot(unconstrained_center) - offsets[position];
664 }
665
666 let (normal_mean, normal_covariance) = orthant_truncated_moments(&normal_center, &w_accepted)?;
667
668 let mut removed = &w_accepted - &normal_covariance;
669 symmetrize_in_place(&mut removed);
670 certify_removed_variance(&removed, &w_accepted)?;
671
672 Ok(Some(ConstrainedPosteriorCorrection {
673 lift,
674 removed_normal_variance: removed,
675 normal_mean_shift: normal_mean - normal_center,
676 rows,
677 }))
678}
679
680fn cholesky_solve_right(factor: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>, String> {
683 let q = factor.nrows();
684 if b.ncols() != q {
685 return Err(format!(
686 "constraint-normal solve: factor is {q}x{q} but the right-hand side has {} columns",
687 b.ncols()
688 ));
689 }
690 let rows = b.nrows();
691 let mut out = Array2::<f64>::zeros((rows, q));
692 let mut work = Array1::<f64>::zeros(q);
693 for r in 0..rows {
694 for i in 0..q {
695 let mut sum = b[[r, i]];
696 for k in 0..i {
697 sum -= factor[[i, k]] * work[k];
698 }
699 work[i] = sum / factor[[i, i]];
700 }
701 for i in (0..q).rev() {
702 let mut sum = work[i];
703 for k in (i + 1)..q {
704 sum -= factor[[k, i]] * out[[r, k]];
705 }
706 out[[r, i]] = sum / factor[[i, i]];
707 }
708 }
709 Ok(out)
710}
711
712fn certify_removed_variance(removed: &Array2<f64>, w: &Array2<f64>) -> Result<(), String> {
719 let q = removed.nrows();
720 let slack = ORTHANT_MOMENT_RELATIVE_TOLERANCE * (q as f64);
723 for i in 0..q {
724 let scale = w[[i, i]];
725 if removed[[i, i]] < -slack * scale {
726 return Err(format!(
727 "truncated orthant moments inflated the constraint-normal variance at index {i} \
728 (removed {:.6e} against scale {scale:.6e}); truncation cannot increase a \
729 Gaussian covariance",
730 removed[[i, i]]
731 ));
732 }
733 if removed[[i, i]] > (1.0 + slack) * scale {
734 return Err(format!(
735 "truncated orthant moments removed more variance than exists at index {i} \
736 (removed {:.6e} against scale {scale:.6e})",
737 removed[[i, i]]
738 ));
739 }
740 for j in 0..q {
741 if !removed[[i, j]].is_finite() {
742 return Err(format!(
743 "truncated orthant moments produced a non-finite entry at ({i},{j})"
744 ));
745 }
746 }
747 }
748 Ok(())
749}
750
751fn symmetrize_in_place(matrix: &mut Array2<f64>) {
752 let n = matrix.nrows();
753 for i in 0..n {
754 for j in (i + 1)..n {
755 let averaged = 0.5 * (matrix[[i, j]] + matrix[[j, i]]);
756 matrix[[i, j]] = averaged;
757 matrix[[j, i]] = averaged;
758 }
759 }
760}
761
762fn orthant_truncated_moments(
772 mean: &Array1<f64>,
773 covariance: &Array2<f64>,
774) -> Result<(Array1<f64>, Array2<f64>), String> {
775 let q = mean.len();
776 if covariance.nrows() != q || covariance.ncols() != q {
777 return Err(format!(
778 "orthant moments: mean has length {q} but the covariance is {}x{}",
779 covariance.nrows(),
780 covariance.ncols()
781 ));
782 }
783 if q == 1 {
784 return scalar_truncated_moments(mean[0], covariance[[0, 0]]);
785 }
786
787 let factor = gam_linalg::triangular::cholesky_factor_in_place(
788 covariance.view(),
789 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
790 )
791 .ok_or_else(|| {
792 "orthant moments: the constraint-normal covariance W = AΣAᵀ is not numerically \
793 positive definite"
794 .to_string()
795 })?;
796
797 let generator = kronecker_generator(q);
798 let mut accumulator = OrthantAccumulator::new(q);
799 let mut evaluated = 0usize;
800 let mut previous: Option<(Array1<f64>, Array2<f64>)> = None;
801 loop {
802 let target = if evaluated == 0 {
803 ORTHANT_MOMENT_INITIAL_POINTS
804 } else {
805 evaluated * 2
806 };
807 accumulate_orthant_nodes(
808 &mut accumulator,
809 mean,
810 factor.view(),
811 &generator,
812 evaluated,
813 target,
814 )?;
815 evaluated = target;
816 let current = accumulator.moments()?;
817 if let Some(ref last) = previous
818 && moment_relative_change(last, ¤t, covariance)
819 <= ORTHANT_MOMENT_RELATIVE_TOLERANCE
820 {
821 return Ok(current);
822 }
823 if evaluated >= ORTHANT_MOMENT_MAXIMUM_POINTS {
824 let change = previous
825 .as_ref()
826 .map(|last| moment_relative_change(last, ¤t, covariance))
827 .unwrap_or(f64::INFINITY);
828 return Err(format!(
829 "orthant moments for a {q}-dimensional constraint face did not converge: \
830 relative moment change {change:.3e} still exceeds \
831 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e} at {evaluated} cubature nodes"
832 ));
833 }
834 previous = Some(current);
835 }
836}
837
838struct OrthantAccumulator {
846 log_scale: f64,
847 weight_sum: f64,
848 weighted_mean: Array1<f64>,
849 weighted_second: Array2<f64>,
850}
851
852trait OrthantNodeSink {
853 fn push(&mut self, log_weight: f64, point: &Array1<f64>);
854}
855
856impl OrthantAccumulator {
857 fn new(q: usize) -> Self {
858 Self {
859 log_scale: f64::NEG_INFINITY,
860 weight_sum: 0.0,
861 weighted_mean: Array1::zeros(q),
862 weighted_second: Array2::zeros((q, q)),
863 }
864 }
865
866 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
867 let q = point.len();
868 if log_weight > self.log_scale {
869 let rescale = (self.log_scale - log_weight).exp();
870 self.weight_sum *= rescale;
871 self.weighted_mean *= rescale;
872 self.weighted_second *= rescale;
873 self.log_scale = log_weight;
874 }
875 let weight = (log_weight - self.log_scale).exp();
876 self.weight_sum += weight;
877 for i in 0..q {
878 self.weighted_mean[i] += weight * point[i];
879 for j in 0..=i {
880 self.weighted_second[[i, j]] += weight * point[i] * point[j];
881 }
882 }
883 }
884
885 fn moments(&self) -> Result<(Array1<f64>, Array2<f64>), String> {
886 if !(self.weight_sum.is_finite() && self.weight_sum > 0.0) {
887 return Err(format!(
888 "orthant cubature accumulated no feasible mass (weight sum {:?}); the \
889 constraint face has no representable interior",
890 self.weight_sum
891 ));
892 }
893 let q = self.weighted_mean.len();
894 let mean = &self.weighted_mean / self.weight_sum;
895 let mut covariance = Array2::<f64>::zeros((q, q));
896 for i in 0..q {
897 for j in 0..=i {
898 let centered = self.weighted_second[[i, j]] / self.weight_sum - mean[i] * mean[j];
899 covariance[[i, j]] = centered;
900 covariance[[j, i]] = centered;
901 }
902 }
903 Ok((mean, covariance))
904 }
905}
906
907impl OrthantNodeSink for OrthantAccumulator {
908 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
909 OrthantAccumulator::push(self, log_weight, point);
910 }
911}
912
913fn accumulate_orthant_nodes<S: OrthantNodeSink>(
916 accumulator: &mut S,
917 mean: &Array1<f64>,
918 factor: ArrayView2<'_, f64>,
919 generator: &[f64],
920 first: usize,
921 last: usize,
922) -> Result<(), String> {
923 let q = mean.len();
924 let mut z = Array1::<f64>::zeros(q);
925 let mut point = Array1::<f64>::zeros(q);
926 for node in first..last {
927 let offset = node as f64 + 0.5;
928 let mut log_weight = 0.0f64;
929 for i in 0..q {
930 let mut bound = -mean[i];
931 for j in 0..i {
932 bound -= factor[[i, j]] * z[j];
933 }
934 let lower = bound / factor[[i, i]];
935 let log_tail = normal_logsf(lower);
936 if !log_tail.is_finite() {
937 log_weight = f64::NEG_INFINITY;
941 break;
942 }
943 log_weight += log_tail;
944 let lattice = {
949 let raw = offset * generator[i];
950 let fractional = raw - raw.floor();
951 1.0 - (2.0 * fractional - 1.0).abs()
952 };
953 let log_fraction = (1.0 - lattice).max(f64::MIN_POSITIVE).ln();
960 let log_upper_tail = log_fraction + log_tail;
961 let resolved = if log_upper_tail < 0.0 {
962 log_upper_tail
963 } else {
964 -f64::MIN_POSITIVE
965 };
966 z[i] = -standard_normal_quantile_from_log_cdf(resolved)
967 .map_err(|error| format!("orthant cubature coordinate {i}: {error}"))?;
968 }
969 if !log_weight.is_finite() {
970 continue;
971 }
972 for i in 0..q {
973 let mut value = mean[i];
974 for j in 0..=i {
975 value += factor[[i, j]] * z[j];
976 }
977 point[i] = value;
978 }
979 accumulator.push(log_weight, &point);
980 }
981 Ok(())
982}
983
984#[derive(Clone, Copy)]
985struct WeightedProjectionNode {
986 conditional_mean: f64,
987 weight: f64,
988}
989
990struct ProjectionNodeAccumulator<'a> {
991 moments: OrthantAccumulator,
992 normal_center: &'a Array1<f64>,
993 projection_lift: &'a Array1<f64>,
994 ambient_mean: f64,
995 nodes: Vec<(f64, f64)>,
996}
997
998impl<'a> ProjectionNodeAccumulator<'a> {
999 fn new(
1000 normal_center: &'a Array1<f64>,
1001 projection_lift: &'a Array1<f64>,
1002 ambient_mean: f64,
1003 ) -> Self {
1004 Self {
1005 moments: OrthantAccumulator::new(normal_center.len()),
1006 normal_center,
1007 projection_lift,
1008 ambient_mean,
1009 nodes: Vec::new(),
1010 }
1011 }
1012
1013 fn normalized_nodes(self) -> Result<Vec<WeightedProjectionNode>, String> {
1014 let max_log_weight = self
1015 .nodes
1016 .iter()
1017 .map(|(_, log_weight)| *log_weight)
1018 .fold(f64::NEG_INFINITY, f64::max);
1019 if !max_log_weight.is_finite() {
1020 return Err(
1021 "orthant projection cubature accumulated no finite node weight".to_string(),
1022 );
1023 }
1024 let weight_sum = self
1025 .nodes
1026 .iter()
1027 .map(|(_, log_weight)| (*log_weight - max_log_weight).exp())
1028 .sum::<f64>();
1029 if !(weight_sum.is_finite() && weight_sum > 0.0) {
1030 return Err(format!(
1031 "orthant projection cubature has invalid normalized weight sum {weight_sum:?}"
1032 ));
1033 }
1034 Ok(self
1035 .nodes
1036 .into_iter()
1037 .map(|(conditional_mean, log_weight)| WeightedProjectionNode {
1038 conditional_mean,
1039 weight: (log_weight - max_log_weight).exp() / weight_sum,
1040 })
1041 .collect())
1042 }
1043}
1044
1045impl OrthantNodeSink for ProjectionNodeAccumulator<'_> {
1046 fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
1047 self.moments.push(log_weight, point);
1048 let conditional_mean = self.ambient_mean
1049 + self
1050 .projection_lift
1051 .iter()
1052 .zip(point.iter().zip(self.normal_center.iter()))
1053 .map(|(&lift, (&value, ¢er))| lift * (value - center))
1054 .sum::<f64>();
1055 self.nodes.push((conditional_mean, log_weight));
1056 }
1057}
1058
1059fn converged_projection_nodes(
1060 mean: &Array1<f64>,
1061 covariance: &Array2<f64>,
1062 projection_lift: &Array1<f64>,
1063 ambient_mean: f64,
1064) -> Result<Vec<WeightedProjectionNode>, String> {
1065 let q = mean.len();
1066 if covariance.dim() != (q, q) || projection_lift.len() != q {
1067 return Err(format!(
1068 "orthant projection geometry mismatch: mean={q}, covariance={:?}, lift={}",
1069 covariance.dim(),
1070 projection_lift.len()
1071 ));
1072 }
1073 let factor = gam_linalg::triangular::cholesky_factor_in_place(
1074 covariance.view(),
1075 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
1076 )
1077 .ok_or_else(|| {
1078 "orthant projection: the constraint-normal covariance is not numerically positive definite"
1079 .to_string()
1080 })?;
1081 let generator = kronecker_generator(q);
1082 let mut accumulator = ProjectionNodeAccumulator::new(mean, projection_lift, ambient_mean);
1083 let mut evaluated = 0usize;
1084 let mut previous: Option<(Array1<f64>, Array2<f64>)> = None;
1085 loop {
1086 let target = if evaluated == 0 {
1087 ORTHANT_MOMENT_INITIAL_POINTS
1088 } else {
1089 evaluated * 2
1090 };
1091 accumulate_orthant_nodes(
1092 &mut accumulator,
1093 mean,
1094 factor.view(),
1095 &generator,
1096 evaluated,
1097 target,
1098 )?;
1099 evaluated = target;
1100 let current = accumulator.moments.moments()?;
1101 if let Some(ref last) = previous
1102 && moment_relative_change(last, ¤t, covariance)
1103 <= ORTHANT_MOMENT_RELATIVE_TOLERANCE
1104 {
1105 return accumulator.normalized_nodes();
1106 }
1107 if evaluated >= ORTHANT_MOMENT_MAXIMUM_POINTS {
1108 let change = previous
1109 .as_ref()
1110 .map(|last| moment_relative_change(last, ¤t, covariance))
1111 .unwrap_or(f64::INFINITY);
1112 return Err(format!(
1113 "orthant projection for a {q}-dimensional constraint face did not converge: \
1114 relative moment change {change:.3e} still exceeds \
1115 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e} at {evaluated} cubature nodes"
1116 ));
1117 }
1118 previous = Some(current);
1119 }
1120}
1121
1122fn projection_quantile(
1123 nodes: &[WeightedProjectionNode],
1124 residual_variance: f64,
1125 probability: f64,
1126 posterior_mean: f64,
1127 ambient_sd: f64,
1128) -> Result<f64, String> {
1129 if nodes.is_empty() {
1130 return Err("orthant projection quantile received no cubature nodes".to_string());
1131 }
1132 if residual_variance == 0.0 {
1133 let mut ordered = nodes.to_vec();
1134 ordered.sort_by(|left, right| left.conditional_mean.total_cmp(&right.conditional_mean));
1135 let mut cumulative = 0.0;
1136 for node in &ordered {
1137 cumulative += node.weight;
1138 if cumulative >= probability {
1139 return Ok(node.conditional_mean);
1140 }
1141 }
1142 return Ok(ordered
1143 .last()
1144 .expect("non-empty projection node set")
1145 .conditional_mean);
1146 }
1147
1148 let residual_sd = residual_variance.sqrt();
1149 let cdf = |value: f64| {
1150 nodes
1151 .iter()
1152 .map(|node| {
1153 node.weight * normal_cdf((value - node.conditional_mean) / residual_sd)
1154 })
1155 .sum::<f64>()
1156 };
1157 let mut step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
1158 let mut lower = posterior_mean - step;
1159 let mut upper = posterior_mean + step;
1160 while cdf(lower) > probability {
1161 step *= 2.0;
1162 lower = posterior_mean - step;
1163 if !lower.is_finite() {
1164 return Err(format!(
1165 "orthant projection quantile could not bracket lower probability {probability}"
1166 ));
1167 }
1168 }
1169 step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
1170 while cdf(upper) < probability {
1171 step *= 2.0;
1172 upper = posterior_mean + step;
1173 if !upper.is_finite() {
1174 return Err(format!(
1175 "orthant projection quantile could not bracket upper probability {probability}"
1176 ));
1177 }
1178 }
1179
1180 let resolution = f64::EPSILON.sqrt() * ambient_sd.max(residual_sd);
1181 loop {
1182 let midpoint = lower + 0.5 * (upper - lower);
1183 if midpoint == lower || midpoint == upper || upper - lower <= resolution {
1184 return Ok(midpoint);
1185 }
1186 if cdf(midpoint) < probability {
1187 lower = midpoint;
1188 } else {
1189 upper = midpoint;
1190 }
1191 }
1192}
1193
1194fn scalar_truncated_moments(
1196 mean: f64,
1197 variance: f64,
1198) -> Result<(Array1<f64>, Array2<f64>), String> {
1199 if !(variance.is_finite() && variance > 0.0) {
1200 return Err(format!(
1201 "scalar truncated moments need a positive finite variance, got {variance:?}"
1202 ));
1203 }
1204 let sd = variance.sqrt();
1205 let alpha = -mean / sd;
1209 let mills = signed_probit_logcdf_and_mills_ratio(-alpha).1;
1210 if !(mills.is_finite() && mills >= 0.0) {
1211 return Err(format!(
1212 "scalar truncated moments: inverse Mills ratio at {alpha} is {mills:?}"
1213 ));
1214 }
1215 let truncated_mean = mean + sd * mills;
1216 let truncated_variance = variance * (1.0 + alpha * mills - mills * mills);
1217 if !(truncated_variance.is_finite() && truncated_variance >= 0.0) {
1218 return Err(format!(
1219 "scalar truncated moments produced variance {truncated_variance:?} at \
1220 standardized truncation point {alpha}"
1221 ));
1222 }
1223 Ok((
1224 Array1::from_elem(1, truncated_mean),
1225 Array2::from_elem((1, 1), truncated_variance),
1226 ))
1227}
1228
1229fn moment_relative_change(
1233 previous: &(Array1<f64>, Array2<f64>),
1234 current: &(Array1<f64>, Array2<f64>),
1235 w: &Array2<f64>,
1236) -> f64 {
1237 let q = current.0.len();
1238 let mut worst = 0.0f64;
1239 for i in 0..q {
1240 let sd_i = w[[i, i]].sqrt();
1241 worst = worst.max((current.0[i] - previous.0[i]).abs() / sd_i);
1242 for j in 0..q {
1243 let sd_j = w[[j, j]].sqrt();
1244 worst =
1245 worst.max((current.1[[i, j]] - previous.1[[i, j]]).abs() / (sd_i * sd_j));
1246 }
1247 }
1248 worst
1249}
1250
1251fn kronecker_generator(dimension: usize) -> Vec<f64> {
1256 let mut generator = Vec::with_capacity(dimension);
1257 let mut candidate = 2u64;
1258 while generator.len() < dimension {
1259 if is_prime(candidate) {
1260 let root = (candidate as f64).sqrt();
1261 generator.push(root - root.floor());
1262 }
1263 candidate += 1;
1264 }
1265 generator
1266}
1267
1268fn is_prime(value: u64) -> bool {
1269 if value < 2 {
1270 return false;
1271 }
1272 let mut divisor = 2u64;
1273 while divisor * divisor <= value {
1274 if value % divisor == 0 {
1275 return false;
1276 }
1277 divisor += 1;
1278 }
1279 true
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284 use super::*;
1285 use ndarray::array;
1286
1287
1288 fn quadrature_truncated_moments(mean: f64, variance: f64) -> (f64, f64) {
1292 let sd = variance.sqrt();
1293 let alpha = -mean / sd;
1294 let panels = 400_000usize;
1295 let upper = alpha + 60.0;
1296 let step = (upper - alpha) / panels as f64;
1297 let mut mass = 0.0f64;
1298 let mut first = 0.0f64;
1299 let mut second = 0.0f64;
1300 for index in 0..=panels {
1301 let z = alpha + step * index as f64;
1302 let simpson = if index == 0 || index == panels {
1303 1.0
1304 } else if index % 2 == 1 {
1305 4.0
1306 } else {
1307 2.0
1308 };
1309 let density = (-(z * z - alpha * alpha) / 2.0).exp();
1310 mass += simpson * density;
1311 first += simpson * density * z;
1312 second += simpson * density * z * z;
1313 }
1314 let m1 = first / mass;
1315 let m2 = second / mass;
1316 (mean + sd * m1, variance * (m2 - m1 * m1))
1317 }
1318
1319 #[test]
1322 fn scalar_truncated_moments_match_the_closed_form_at_every_regime() {
1323 let (mean, variance) = scalar_truncated_moments(0.0, 1.0).expect("half normal");
1325 let expected_mean = (2.0 / std::f64::consts::PI).sqrt();
1326 assert!(
1327 (mean[0] - expected_mean).abs() < 1e-12,
1328 "half-normal mean {} vs {expected_mean}",
1329 mean[0]
1330 );
1331 let expected_variance = 1.0 - 2.0 / std::f64::consts::PI;
1332 assert!(
1333 (variance[[0, 0]] - expected_variance).abs() < 1e-12,
1334 "half-normal variance {} vs {expected_variance}",
1335 variance[[0, 0]]
1336 );
1337 assert!(
1338 variance[[0, 0]] > 0.36 && variance[[0, 0]] < 0.37,
1339 "a coefficient whose mode sits exactly on its bound keeps a THIRD of its \
1340 unconstrained variance, not zero: got {}",
1341 variance[[0, 0]]
1342 );
1343
1344 for center in [-2.0, -4.0, -8.0] {
1349 let (deep_mean, deep) = scalar_truncated_moments(center, 1.0).expect("deep tail");
1350 let (reference_mean, reference_variance) = quadrature_truncated_moments(center, 1.0);
1351 assert!(
1352 (deep_mean[0] - reference_mean).abs() < 1e-9 * reference_mean.abs().max(1.0),
1353 "closed-form mean {} vs quadrature {reference_mean} at centre {center}",
1354 deep_mean[0]
1355 );
1356 assert!(
1357 (deep[[0, 0]] / reference_variance - 1.0).abs() < 1e-8,
1358 "closed-form variance {} vs quadrature {reference_variance} at centre {center}",
1359 deep[[0, 0]]
1360 );
1361 assert!(
1362 deep[[0, 0]] > 0.0,
1363 "a finite multiplier never gives zero variance, got {} at centre {center}",
1364 deep[[0, 0]]
1365 );
1366 }
1367 let (_, at_eight) = scalar_truncated_moments(-8.0, 1.0).expect("deep tail");
1370 assert!(
1371 at_eight[[0, 0]] * 64.0 > 0.9 && at_eight[[0, 0]] * 64.0 < 1.0,
1372 "variance times alpha^2 should approach one from below, got {}",
1373 at_eight[[0, 0]] * 64.0
1374 );
1375
1376 let (far_mean, far_variance) = scalar_truncated_moments(10.0, 4.0).expect("inactive");
1382 let (reference_mean, reference_variance) = quadrature_truncated_moments(10.0, 4.0);
1383 assert!(
1384 (far_mean[0] - reference_mean).abs() < 1e-9,
1385 "inactive-bound mean {} vs quadrature {reference_mean}",
1386 far_mean[0]
1387 );
1388 assert!(
1389 (far_variance[[0, 0]] - reference_variance).abs() < 1e-9,
1390 "inactive-bound variance {} vs quadrature {reference_variance}",
1391 far_variance[[0, 0]]
1392 );
1393 assert!(
1394 (far_mean[0] - 10.0).abs() < 1e-5 && far_mean[0] > 10.0,
1395 "a bound five sd away moves the mean by the tail mass and no more, got {}",
1396 far_mean[0]
1397 );
1398 assert!(
1399 (far_variance[[0, 0]] - 4.0).abs() < 1e-4 && far_variance[[0, 0]] < 4.0,
1400 "a bound five sd away shrinks the variance by the tail mass and no more, got {}",
1401 far_variance[[0, 0]]
1402 );
1403 }
1404
1405 #[test]
1406 fn equal_tailed_projection_interval_is_asymmetric_for_a_half_normal() {
1407 let covariance = array![[1.0]];
1408 let center = array![0.0];
1409 let constraints =
1410 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
1411 let correction =
1412 constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
1413 .expect("correction")
1414 .expect("active half-space");
1415 let geometry = ConstrainedPosteriorGeometry {
1416 constraints,
1417 mode: array![0.0],
1418 unconstrained_center: center,
1419 correction: Some(correction),
1420 };
1421 let (lower, upper) = constrained_projection_equal_tailed_interval(
1422 &covariance,
1423 &geometry,
1424 &array![1.0],
1425 0.95,
1426 )
1427 .expect("equal-tailed interval");
1428
1429 let expected_lower = standard_normal_quantile(0.5125).expect("lower quantile");
1432 let expected_upper = standard_normal_quantile(0.9875).expect("upper quantile");
1433 assert!(
1434 (lower - expected_lower).abs() < 2e-3,
1435 "half-normal lower endpoint {lower} vs {expected_lower}"
1436 );
1437 assert!(
1438 (upper - expected_upper).abs() < 2e-3,
1439 "half-normal upper endpoint {upper} vs {expected_upper}"
1440 );
1441 let posterior_mean = (2.0 / std::f64::consts::PI).sqrt();
1442 assert!(
1443 (posterior_mean - lower) < (upper - posterior_mean),
1444 "the exact skew interval must not collapse back to mean +/- z*sd"
1445 );
1446 }
1447
1448 #[test]
1449 fn equal_tailed_projection_sweep_has_exact_mass_and_repairs_the_short_symmetric_band() {
1450 let covariance = array![[1.0]];
1451 let constraints =
1452 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
1453 let alpha = 0.025;
1454 let ambient_width =
1455 2.0 * standard_normal_quantile(1.0 - alpha).expect("ambient quantile");
1456 let mut saw_repaired_short_symmetric_band = false;
1457
1458 for center_value in [0.0, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 5.0] {
1459 let center = array![center_value];
1460 let correction = constrained_posterior_correction_from_covariance(
1461 &covariance,
1462 ¢er,
1463 &constraints,
1464 )
1465 .expect("correction")
1466 .expect("finite lower truncation");
1467 let posterior_variance =
1468 1.0 - correction.removed_variance_diagonal()[0];
1469 let geometry = ConstrainedPosteriorGeometry {
1470 constraints: constraints.clone(),
1471 mode: array![center_value.max(0.0)],
1472 unconstrained_center: center,
1473 correction: Some(correction),
1474 };
1475 let (lower, upper) = constrained_projection_equal_tailed_interval(
1476 &covariance,
1477 &geometry,
1478 &array![1.0],
1479 0.95,
1480 )
1481 .expect("equal-tailed interval");
1482
1483 let mass_below_bound = normal_cdf(-center_value);
1484 let retained_mass = 1.0 - mass_below_bound;
1485 let truncated_cdf = |value: f64| {
1486 (normal_cdf(value - center_value) - mass_below_bound) / retained_mass
1487 };
1488 assert!(
1489 (truncated_cdf(lower) - alpha).abs() < 2e-8
1490 && (truncated_cdf(upper) - (1.0 - alpha)).abs() < 2e-8,
1491 "centre {center_value}: endpoints [{lower}, {upper}] do not enclose exact \
1492 posterior mass 0.95"
1493 );
1494 assert!(
1495 lower >= 0.0,
1496 "centre {center_value}: lower endpoint {lower} escaped the saved cone"
1497 );
1498 assert!(
1499 upper - lower <= ambient_width + 1e-10,
1500 "centre {center_value}: truncation widened [{lower}, {upper}] beyond the \
1501 ambient Gaussian interval"
1502 );
1503
1504 if center_value == 3.0 {
1505 let symmetric_width = 2.0
1506 * standard_normal_quantile(1.0 - alpha).expect("symmetric quantile")
1507 * posterior_variance.sqrt();
1508 assert!(
1509 upper - lower > symmetric_width,
1510 "the exact 3-SE interval must repair the moment-matched symmetric interval's \
1511 short, under-covering band: exact width {}, symmetric width {symmetric_width}",
1512 upper - lower
1513 );
1514 saw_repaired_short_symmetric_band = true;
1515 }
1516 }
1517
1518 assert!(
1519 saw_repaired_short_symmetric_band,
1520 "the sweep must include its 3-SE regression cell"
1521 );
1522 }
1523
1524 #[test]
1531 fn cubature_reproduces_independent_coordinates_within_its_certified_accuracy() {
1532 let mean = array![-0.5, 0.25, -1.5];
1533 let covariance = array![[2.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 1.0]];
1534 let (moment_mean, moment_covariance) =
1535 orthant_truncated_moments(&mean, &covariance).expect("independent orthant");
1536 for i in 0..3 {
1537 let (exact_mean, exact_variance) =
1538 scalar_truncated_moments(mean[i], covariance[[i, i]]).expect("scalar");
1539 let scale = covariance[[i, i]].sqrt();
1540 assert!(
1541 (moment_mean[i] - exact_mean[0]).abs()
1542 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * scale,
1543 "coordinate {i} mean {} vs exact {}",
1544 moment_mean[i],
1545 exact_mean[0]
1546 );
1547 assert!(
1548 (moment_covariance[[i, i]] - exact_variance[[0, 0]]).abs()
1549 < ORTHANT_MOMENT_RELATIVE_TOLERANCE * covariance[[i, i]],
1550 "coordinate {i} variance {} vs exact {}",
1551 moment_covariance[[i, i]],
1552 exact_variance[[0, 0]]
1553 );
1554 for j in 0..3 {
1555 if i != j {
1556 assert!(
1557 moment_covariance[[i, j]].abs()
1558 < ORTHANT_MOMENT_RELATIVE_TOLERANCE
1559 * scale
1560 * covariance[[j, j]].sqrt(),
1561 "independent coordinates must stay uncorrelated under an orthant \
1562 truncation, got {} at ({i},{j})",
1563 moment_covariance[[i, j]]
1564 );
1565 }
1566 }
1567 }
1568 }
1569
1570 #[test]
1573 fn correction_lands_strictly_between_full_space_and_active_face() {
1574 let covariance = array![[1.0, 0.4], [0.4, 1.0]];
1575 let constraints =
1576 LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
1577 let center = array![-0.6, 0.3];
1579 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
1580 .expect("correction")
1581 .expect("an active row");
1582 let truncated = correction.apply_to_covariance(&covariance);
1583
1584 let mut face = covariance.clone();
1587 let full_removal = correction.lift.dot(&array![[1.0]]).dot(&correction.lift.t());
1588 face -= &full_removal;
1589
1590 assert!(
1591 truncated[[0, 0]] > face[[0, 0]] + 1e-6,
1592 "truncated variance {} must exceed the active-face answer {}",
1593 truncated[[0, 0]],
1594 face[[0, 0]]
1595 );
1596 assert!(
1597 truncated[[0, 0]] < covariance[[0, 0]] - 1e-6,
1598 "truncated variance {} must fall below the unconstrained answer {}",
1599 truncated[[0, 0]],
1600 covariance[[0, 0]]
1601 );
1602 assert!(
1603 face[[0, 0]].abs() < 1e-12,
1604 "the active-face answer for a single pinned coordinate is exactly zero, got {}",
1605 face[[0, 0]]
1606 );
1607 assert!(
1608 correction.normal_mean_shift[0] > 0.0,
1609 "truncation moves the posterior mean INTO the feasible region, shift was {}",
1610 correction.normal_mean_shift[0]
1611 );
1612 }
1613
1614 #[test]
1617 fn inactive_constraints_produce_no_correction() {
1618 let covariance = array![[1.0, 0.0], [0.0, 1.0]];
1619 let constraints =
1620 LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
1621 let center = array![40.0, 0.0];
1622 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
1623 .expect("correction");
1624 assert!(
1625 correction.is_none(),
1626 "a bound 40 posterior standard deviations away cannot move any moment at double \
1627 precision"
1628 );
1629 }
1630
1631 #[test]
1633 fn redundant_rows_are_dropped_by_the_rank_filter() {
1634 let covariance = array![[1.0, 0.2], [0.2, 1.0]];
1635 let constraints = LinearInequalityConstraints::new(
1636 array![[1.0, 0.0], [2.0, 0.0], [0.0, 1.0]],
1637 array![0.0, 0.0, 0.0],
1638 )
1639 .expect("cone");
1640 let center = array![-0.2, -0.3];
1641 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
1642 .expect("correction")
1643 .expect("active rows");
1644 assert_eq!(
1645 correction.rows.len(),
1646 2,
1647 "the duplicated half-space must be filtered out, kept rows {:?}",
1648 correction.rows
1649 );
1650 }
1651
1652 #[test]
1654 fn corrected_covariance_stays_between_zero_and_the_unconstrained_answer() {
1655 let covariance = array![
1656 [1.0, 0.3, 0.1],
1657 [0.3, 1.2, -0.2],
1658 [0.1, -0.2, 0.8]
1659 ];
1660 let constraints = LinearInequalityConstraints::new(
1661 array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1662 array![0.0, 0.0],
1663 )
1664 .expect("cone");
1665 for center in [
1666 array![-2.0, -1.0, 0.5],
1667 array![0.0, 0.0, 0.0],
1668 array![-0.1, 0.4, -3.0],
1669 ] {
1670 let correction = constrained_posterior_correction_from_covariance(&covariance, ¢er, &constraints)
1671 .expect("correction")
1672 .expect("active rows");
1673 let truncated = correction.apply_to_covariance(&covariance);
1674 for i in 0..3 {
1675 assert!(
1676 truncated[[i, i]] > 0.0,
1677 "coordinate {i} lost all variance at centre {center:?}: {}",
1678 truncated[[i, i]]
1679 );
1680 assert!(
1681 truncated[[i, i]] <= covariance[[i, i]] + 1e-9,
1682 "coordinate {i} gained variance at centre {center:?}: {} vs {}",
1683 truncated[[i, i]],
1684 covariance[[i, i]]
1685 );
1686 }
1687 let diagonal = correction.removed_variance_diagonal();
1688 for i in 0..3 {
1689 assert!(
1690 (diagonal[i] - (covariance[[i, i]] - truncated[[i, i]])).abs() < 1e-9,
1691 "the diagonal-only accessor must agree with the dense correction at {i}"
1692 );
1693 }
1694 }
1695 }
1696}
1697
1698#[cfg(test)]
1721mod coverage_gate_tests {
1722 use super::*;
1723 use gam_linalg::triangular::{CholeskyGuard, cholesky_factor_in_place, cholesky_solve_vector};
1724
1725 struct SplitMix64 {
1728 state: u64,
1729 }
1730
1731 impl SplitMix64 {
1732 fn new(seed: u64) -> Self {
1733 Self { state: seed }
1734 }
1735 fn next_u64(&mut self) -> u64 {
1736 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
1737 let mut z = self.state;
1738 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1739 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1740 z ^ (z >> 31)
1741 }
1742 fn unit(&mut self) -> f64 {
1743 ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
1744 }
1745 fn normal(&mut self) -> f64 {
1746 let (u1, u2) = (self.unit().max(1.0e-12), self.unit());
1747 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
1748 }
1749 }
1750
1751 struct CoverageTally {
1753 covered: usize,
1754 replicates: usize,
1755 total_half_width: f64,
1756 }
1757
1758 impl CoverageTally {
1759 fn new() -> Self {
1760 Self {
1761 covered: 0,
1762 replicates: 0,
1763 total_half_width: 0.0,
1764 }
1765 }
1766 fn record(&mut self, center: f64, half_width: f64, truth: f64) {
1767 self.replicates += 1;
1768 self.total_half_width += half_width;
1769 if (truth - center).abs() <= half_width {
1770 self.covered += 1;
1771 }
1772 }
1773 fn coverage(&self) -> f64 {
1774 self.covered as f64 / self.replicates as f64
1775 }
1776 fn mean_half_width(&self) -> f64 {
1777 self.total_half_width / self.replicates as f64
1778 }
1779 }
1780
1781 struct CellResult {
1783 full_space: CoverageTally,
1784 active_face: CoverageTally,
1785 truncated: CoverageTally,
1786 truncated_mean_centred: CoverageTally,
1787 pinned_fraction: f64,
1788 }
1789
1790 const NOMINAL_HALF_WIDTH_MULTIPLIER: f64 = 1.959_963_984_540_054;
1792 const NOMINAL_COVERAGE: f64 = 0.95;
1793
1794 fn gaussian_posterior_covariance(gram: &Array2<f64>, noise_variance: f64) -> Array2<f64> {
1796 let p = gram.nrows();
1797 let factor = cholesky_factor_in_place(gram.view(), CholeskyGuard::FiniteStrict)
1798 .expect("simulation design is full rank");
1799 let mut covariance = Array2::<f64>::zeros((p, p));
1800 for j in 0..p {
1801 let mut unit = Array1::<f64>::zeros(p);
1802 unit[j] = 1.0;
1803 let column = cholesky_solve_vector(&factor, &unit);
1804 for i in 0..p {
1805 covariance[[i, j]] = noise_variance * column[i];
1806 }
1807 }
1808 covariance
1809 }
1810
1811 fn tight_rows_at(constraints: &LinearInequalityConstraints, beta: &Array1<f64>) -> Vec<usize> {
1814 let mut tight = Vec::new();
1815 for row_index in 0..constraints.a.nrows() {
1816 let row = constraints.a.row(row_index).to_owned();
1817 let norm = row.dot(&row).sqrt();
1818 if norm > 0.0
1819 && (row.dot(beta) - constraints.b[row_index]) / norm
1820 <= crate::active_set::ACTIVE_SET_WORKING_FACE_TOL
1821 {
1822 tight.push(row_index);
1823 }
1824 }
1825 tight
1826 }
1827
1828 fn active_face_variance(
1833 covariance: &Array2<f64>,
1834 constraints: &LinearInequalityConstraints,
1835 tight: &[usize],
1836 index: usize,
1837 ) -> f64 {
1838 if tight.is_empty() {
1839 return covariance[[index, index]];
1840 }
1841 let q = tight.len();
1842 let mut sigma_at = Array2::<f64>::zeros((covariance.nrows(), q));
1843 for (position, &row_index) in tight.iter().enumerate() {
1844 let column = covariance.dot(&constraints.a.row(row_index).to_owned());
1845 sigma_at.column_mut(position).assign(&column);
1846 }
1847 let mut normal = Array2::<f64>::zeros((q, q));
1848 for (i, &row_i) in tight.iter().enumerate() {
1849 for j in 0..q {
1850 normal[[i, j]] = constraints
1851 .a
1852 .row(row_i)
1853 .to_owned()
1854 .dot(&sigma_at.column(j).to_owned());
1855 }
1856 }
1857 let Some(factor) = cholesky_factor_in_place(normal.view(), CholeskyGuard::FiniteStrict)
1858 else {
1859 return 0.0;
1862 };
1863 let row = sigma_at.row(index).to_owned();
1864 let solved = cholesky_solve_vector(&factor, &row);
1865 covariance[[index, index]] - row.dot(&solved)
1866 }
1867
1868 fn run_cell(
1873 design: &Array2<f64>,
1874 truth: &Array1<f64>,
1875 constraints: &LinearInequalityConstraints,
1876 reported_index: usize,
1877 noise_sd: f64,
1878 replicates: usize,
1879 seed: u64,
1880 ) -> CellResult {
1881 let n = design.nrows();
1882 let p = design.ncols();
1883 let gram = design.t().dot(design);
1884 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
1885 let mut rng = SplitMix64::new(seed);
1886 let mut result = CellResult {
1887 full_space: CoverageTally::new(),
1888 active_face: CoverageTally::new(),
1889 truncated: CoverageTally::new(),
1890 truncated_mean_centred: CoverageTally::new(),
1891 pinned_fraction: 0.0,
1892 };
1893 let mean_response = design.dot(truth);
1894 let mut pinned = 0usize;
1895
1896 for _ in 0..replicates {
1897 let mut response = Array1::<f64>::zeros(n);
1898 for i in 0..n {
1899 response[i] = mean_response[i] + noise_sd * rng.normal();
1900 }
1901 let rhs = design.t().dot(&response);
1902 let start = crate::active_set::feasible_point_for_linear_constraints(constraints, p)
1903 .expect("the simulation cone has an interior");
1904 let (beta_hat, _) = crate::active_set::solve_quadratic_with_linear_constraints(
1905 &gram,
1906 &rhs,
1907 &start,
1908 constraints,
1909 None,
1910 )
1911 .expect("constrained quadratic solve");
1912
1913 let full_half_width =
1914 NOMINAL_HALF_WIDTH_MULTIPLIER * covariance[[reported_index, reported_index]].sqrt();
1915 result.full_space.record(
1916 beta_hat[reported_index],
1917 full_half_width,
1918 truth[reported_index],
1919 );
1920
1921 let tight = tight_rows_at(constraints, &beta_hat);
1922 if !tight.is_empty() {
1923 pinned += 1;
1924 }
1925 let face_variance =
1926 active_face_variance(&covariance, constraints, &tight, reported_index);
1927 result.active_face.record(
1928 beta_hat[reported_index],
1929 NOMINAL_HALF_WIDTH_MULTIPLIER * face_variance.max(0.0).sqrt(),
1930 truth[reported_index],
1931 );
1932
1933 let penalized_gradient = gram.dot(&beta_hat) - &rhs;
1937 let center = &beta_hat
1938 - &(covariance.dot(&penalized_gradient) / (noise_sd * noise_sd));
1939 let correction =
1940 constrained_posterior_correction_from_covariance(&covariance, ¢er, constraints)
1941 .expect("truncated correction");
1942 let (truncated_half_width, truncated_center) = match correction {
1943 None => (full_half_width, beta_hat[reported_index]),
1944 Some(ref correction) => {
1945 let variance = covariance[[reported_index, reported_index]]
1946 - correction.removed_variance_diagonal()[reported_index];
1947 (
1948 NOMINAL_HALF_WIDTH_MULTIPLIER * variance.max(0.0).sqrt(),
1949 correction.posterior_mean(¢er)[reported_index],
1950 )
1951 }
1952 };
1953 result.truncated.record(
1954 beta_hat[reported_index],
1955 truncated_half_width,
1956 truth[reported_index],
1957 );
1958 result.truncated_mean_centred.record(
1959 truncated_center,
1960 truncated_half_width,
1961 truth[reported_index],
1962 );
1963 }
1964 result.pinned_fraction = pinned as f64 / replicates as f64;
1965 result
1966 }
1967
1968 fn report_cell(label: &str, cell: &CellResult) {
1969 eprintln!(
1970 "[#2417 coverage] {label}: nominal {NOMINAL_COVERAGE:.2}, {} replicates, mode pinned \
1971 in {:.1}% of them",
1972 cell.full_space.replicates,
1973 100.0 * cell.pinned_fraction
1974 );
1975 for (name, tally) in [
1976 ("full space ", &cell.full_space),
1977 ("active face ", &cell.active_face),
1978 ("truncated ", &cell.truncated),
1979 ("truncated+mean shift", &cell.truncated_mean_centred),
1980 ] {
1981 eprintln!(
1982 "[#2417 coverage] {name} coverage {:.4} mean half-width {:.5}",
1983 tally.coverage(),
1984 tally.mean_half_width()
1985 );
1986 }
1987 }
1988
1989 #[test]
1994 fn box_bound_at_half_a_standard_error_separates_the_three_covariances() {
1995 let n = 60;
1996 let mut rng = SplitMix64::new(20_417);
1997 let mut design = Array2::<f64>::zeros((n, 2));
1998 for i in 0..n {
1999 design[[i, 0]] = 1.0;
2000 design[[i, 1]] = rng.normal();
2001 }
2002 let gram = design.t().dot(&design);
2003 let noise_sd = 1.0;
2004 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
2005 let standard_error = covariance[[1, 1]].sqrt();
2006 let truth = Array1::from_vec(vec![0.3, 0.5 * standard_error]);
2007 let constraints =
2008 LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
2009 .expect("nonnegativity bound");
2010
2011 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 91_137);
2012 report_cell("box bound, truth 0.5 se", &cell);
2013
2014 assert!(
2017 cell.pinned_fraction > 0.2,
2018 "the cell must actually exercise the boundary, pinned fraction {:.3}",
2019 cell.pinned_fraction
2020 );
2021 assert!(
2022 cell.active_face.coverage() < 0.80,
2023 "the active-face covariance must under-cover catastrophically here — it reports a \
2024 zero-width interval whenever the mode pins — but coverage was {:.4}",
2025 cell.active_face.coverage()
2026 );
2027 assert!(
2028 cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.01,
2029 "the truncated covariance must reach nominal coverage, got {:.4}",
2030 cell.truncated.coverage()
2031 );
2032 assert!(
2033 cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
2034 "and it must still reach it once the centre moves to the truncated posterior \
2035 mean, got {:.4}",
2036 cell.truncated_mean_centred.coverage()
2037 );
2038 assert!(
2039 cell.truncated.mean_half_width() < 0.85 * cell.full_space.mean_half_width(),
2040 "the truncated covariance must buy its coverage with materially SHORTER intervals \
2041 than the full-space answer: {:.5} vs {:.5}",
2042 cell.truncated.mean_half_width(),
2043 cell.full_space.mean_half_width()
2044 );
2045 assert!(
2046 cell.full_space.coverage() >= NOMINAL_COVERAGE,
2047 "the full-space covariance over-covers by construction, got {:.4}",
2048 cell.full_space.coverage()
2049 );
2050 }
2051
2052 #[test]
2066 fn narrowing_the_covariance_without_moving_the_mean_is_a_regression() {
2067 let n = 60;
2068 let mut rng = SplitMix64::new(31_417);
2069 let mut design = Array2::<f64>::zeros((n, 2));
2070 for i in 0..n {
2071 design[[i, 0]] = 1.0;
2072 design[[i, 1]] = rng.normal();
2073 }
2074 let gram = design.t().dot(&design);
2075 let noise_sd = 1.0;
2076 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
2077 let standard_error = covariance[[1, 1]].sqrt();
2078 let truth = Array1::from_vec(vec![-0.2, 1.5 * standard_error]);
2079 let constraints =
2080 LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
2081 .expect("nonnegativity bound");
2082
2083 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 47_903);
2084 report_cell("box bound, truth 1.5 se", &cell);
2085
2086 assert!(
2087 cell.truncated.coverage() < NOMINAL_COVERAGE - 0.02,
2088 "this cell exists BECAUSE the mode-centred truncated interval under-covers here; \
2089 if it stopped doing so the counterexample would no longer be testing anything, \
2090 got {:.4}",
2091 cell.truncated.coverage()
2092 );
2093 assert!(
2094 cell.truncated.coverage() < cell.active_face.coverage(),
2095 "the point of the cell: narrowing the covariance while leaving the interval \
2096 centred on the mode is worse than the active-face answer it replaces, {:.4} vs \
2097 {:.4}",
2098 cell.truncated.coverage(),
2099 cell.active_face.coverage()
2100 );
2101 assert!(
2102 cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
2103 "moving the centre to the truncated posterior mean recovers nominal coverage with \
2104 the same covariance, got {:.4}",
2105 cell.truncated_mean_centred.coverage()
2106 );
2107 assert!(
2108 cell.truncated_mean_centred.mean_half_width() < cell.full_space.mean_half_width(),
2109 "and it does so with shorter intervals than the full-space answer: {:.5} vs {:.5}",
2110 cell.truncated_mean_centred.mean_half_width(),
2111 cell.full_space.mean_half_width()
2112 );
2113 }
2114
2115 #[test]
2118 fn two_coupled_bounds_exercise_the_orthant_cubature() {
2119 let n = 80;
2120 let mut rng = SplitMix64::new(74_211);
2121 let mut design = Array2::<f64>::zeros((n, 3));
2122 for i in 0..n {
2123 design[[i, 0]] = 1.0;
2124 let shared = rng.normal();
2125 design[[i, 1]] = shared;
2126 design[[i, 2]] = 0.7 * shared + 0.7 * rng.normal();
2129 }
2130 let gram = design.t().dot(&design);
2131 let noise_sd = 1.0;
2132 let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
2133 let truth = Array1::from_vec(vec![
2134 0.25,
2135 0.5 * covariance[[1, 1]].sqrt(),
2136 0.5 * covariance[[2, 2]].sqrt(),
2137 ]);
2138 let constraints = LinearInequalityConstraints::new(
2139 ndarray::array![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2140 ndarray::array![0.0, 0.0],
2141 )
2142 .expect("two nonnegativity bounds");
2143
2144 let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 600, 55_301);
2145 report_cell("two coupled bounds, truth 0.5 se", &cell);
2146
2147 assert!(
2148 cell.active_face.coverage() < 0.85,
2149 "the active-face covariance must under-cover here too, got {:.4}",
2150 cell.active_face.coverage()
2151 );
2152 assert!(
2153 cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.03,
2154 "the truncated covariance must reach nominal coverage through the orthant \
2155 cubature, got {:.4}",
2156 cell.truncated.coverage()
2157 );
2158 assert!(
2159 cell.truncated.mean_half_width() < cell.full_space.mean_half_width(),
2160 "shorter intervals at nominal coverage: {:.5} vs {:.5}",
2161 cell.truncated.mean_half_width(),
2162 cell.full_space.mean_half_width()
2163 );
2164 }
2165}