use crate::matrix::DesignMatrix;
use crate::pirls::LinearInequalityConstraints;
use ndarray::{Array1, Array2};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GuardPolicy {
NonNegative,
Positive,
}
impl GuardPolicy {
#[inline]
fn admits(self, guard: f64) -> bool {
if !guard.is_finite() {
return false;
}
match self {
GuardPolicy::NonNegative => guard >= 0.0,
GuardPolicy::Positive => guard > 0.0,
}
}
#[inline]
pub fn range_description(self) -> &'static str {
match self {
GuardPolicy::NonNegative => ">= 0",
GuardPolicy::Positive => "> 0",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FeasibilityTolerance {
AbsoluteScaled,
EpsilonScaled,
}
impl FeasibilityTolerance {
#[inline]
fn slack(self, offset: f64, guard: f64) -> f64 {
let scale = 1.0 + offset.abs().max(guard.abs());
match self {
FeasibilityTolerance::AbsoluteScaled => 1e-12 * scale,
FeasibilityTolerance::EpsilonScaled => 256.0 * f64::EPSILON * scale,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct GuardConstraintPolicy {
pub guard_policy: GuardPolicy,
pub feasibility: FeasibilityTolerance,
}
#[derive(Clone, Debug)]
pub enum GuardConstraintFailure {
RowOffsetMismatch { rows: usize, offsets: usize },
GuardOutOfRange { guard: f64, range: &'static str },
NonFiniteOffset { row: usize, offset: f64 },
NonFiniteDesign { row: usize, col: usize },
InfeasibleRow {
row: usize,
offset: f64,
guard: f64,
no_time_coefficients: bool,
},
}
pub fn build_time_derivative_guard_constraints(
design_derivative_exit: &DesignMatrix,
derivative_offset_exit: &Array1<f64>,
derivative_guard: f64,
policy: GuardConstraintPolicy,
) -> Result<Option<LinearInequalityConstraints>, GuardConstraintFailure> {
if design_derivative_exit.nrows() != derivative_offset_exit.len() {
return Err(GuardConstraintFailure::RowOffsetMismatch {
rows: design_derivative_exit.nrows(),
offsets: derivative_offset_exit.len(),
});
}
if !policy.guard_policy.admits(derivative_guard) {
return Err(GuardConstraintFailure::GuardOutOfRange {
guard: derivative_guard,
range: policy.guard_policy.range_description(),
});
}
let p = design_derivative_exit.ncols();
if p == 0 {
for (row, &offset) in derivative_offset_exit.iter().enumerate() {
if !offset.is_finite() {
return Err(GuardConstraintFailure::NonFiniteOffset { row, offset });
}
if offset + policy.feasibility.slack(offset, derivative_guard) < derivative_guard {
return Err(GuardConstraintFailure::InfeasibleRow {
row,
offset,
guard: derivative_guard,
no_time_coefficients: true,
});
}
}
return Ok(None);
}
let dense = design_derivative_exit.to_dense();
let mut active_rows: Vec<usize> = Vec::new();
for row in 0..dense.nrows() {
let offset = derivative_offset_exit[row];
if !offset.is_finite() {
return Err(GuardConstraintFailure::NonFiniteOffset { row, offset });
}
let mut row_norm_sq = 0.0_f64;
for col in 0..p {
let value = dense[[row, col]];
if !value.is_finite() {
return Err(GuardConstraintFailure::NonFiniteDesign { row, col });
}
row_norm_sq += value * value;
}
let required = derivative_guard - offset;
if row_norm_sq <= 1e-24 {
if required > policy.feasibility.slack(offset, derivative_guard) {
return Err(GuardConstraintFailure::InfeasibleRow {
row,
offset,
guard: derivative_guard,
no_time_coefficients: false,
});
}
continue;
}
active_rows.push(row);
}
if active_rows.is_empty() {
return Ok(None);
}
let mut a = Array2::<f64>::zeros((active_rows.len(), p));
let mut b = Array1::<f64>::zeros(active_rows.len());
for (out_row, &src_row) in active_rows.iter().enumerate() {
let row = dense.row(src_row);
let rhs = derivative_guard - derivative_offset_exit[src_row];
let row_norm = row.dot(&row).sqrt();
let scale = row_norm.max(rhs.abs()).max(1.0);
for col in 0..p {
a[[out_row, col]] = dense[[src_row, col]] / scale;
}
b[out_row] = rhs / scale;
}
Ok(Some(LinearInequalityConstraints::from_paired(a, b)))
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
const LS_POLICY: GuardConstraintPolicy = GuardConstraintPolicy {
guard_policy: GuardPolicy::NonNegative,
feasibility: FeasibilityTolerance::AbsoluteScaled,
};
const MS_POLICY: GuardConstraintPolicy = GuardConstraintPolicy {
guard_policy: GuardPolicy::Positive,
feasibility: FeasibilityTolerance::EpsilonScaled,
};
fn dense(rows: usize, cols: usize, data: &[f64]) -> DesignMatrix {
DesignMatrix::from(Array2::from_shape_vec((rows, cols), data.to_vec()).unwrap())
}
#[test]
fn parity_of_constraint_matrices_across_family_policies() {
let design = dense(3, 2, &[2.0, 0.5, 1.0, -0.25, 4.0, 1.5]);
let offsets = array![0.10, -0.20, 0.40];
let guard = 0.05;
let ls = build_time_derivative_guard_constraints(&design, &offsets, guard, LS_POLICY)
.expect("location-scale policy must build")
.expect("expected active rows");
let ms = build_time_derivative_guard_constraints(&design, &offsets, guard, MS_POLICY)
.expect("marginal-slope policy must build")
.expect("expected active rows");
assert_eq!(ls.a.shape(), ms.a.shape());
assert_eq!(ls.b.len(), ms.b.len());
for (x, y) in ls.a.iter().zip(ms.a.iter()) {
assert_eq!(
x, y,
"constraint A entries must match exactly across policies"
);
}
for (x, y) in ls.b.iter().zip(ms.b.iter()) {
assert_eq!(
x, y,
"constraint b entries must match exactly across policies"
);
}
let raw_row = design.to_dense().row(0).to_owned();
let raw_rhs = guard - offsets[0];
let scale = raw_row.dot(&raw_row).sqrt().max(raw_rhs.abs()).max(1.0);
assert!((ls.a[[0, 0]] - raw_row[0] / scale).abs() < 1e-15);
assert!((ls.a[[0, 1]] - raw_row[1] / scale).abs() < 1e-15);
assert!((ls.b[0] - raw_rhs / scale).abs() < 1e-15);
}
#[test]
fn guard_policy_difference_is_explicit() {
let design = dense(2, 1, &[1.0, 1.0]);
let offsets = array![0.0, 0.0];
let ls_zero = build_time_derivative_guard_constraints(&design, &offsets, 0.0, LS_POLICY);
assert!(ls_zero.is_ok(), "non-negative policy must admit guard == 0");
let ms_zero = build_time_derivative_guard_constraints(&design, &offsets, 0.0, MS_POLICY);
match ms_zero {
Err(GuardConstraintFailure::GuardOutOfRange { guard, range }) => {
assert_eq!(guard, 0.0);
assert_eq!(range, "> 0");
}
other => panic!("positive policy must reject guard == 0, got {other:?}"),
}
for policy in [LS_POLICY, MS_POLICY] {
match build_time_derivative_guard_constraints(&design, &offsets, -1.0, policy) {
Err(GuardConstraintFailure::GuardOutOfRange { .. }) => {}
other => panic!("negative guard must be rejected, got {other:?}"),
}
}
}
#[test]
fn coefficient_free_feasibility_uses_policy_slack() {
let design = dense(2, 0, &[]);
let ok =
build_time_derivative_guard_constraints(&design, &array![1.0, 2.0], 0.5, MS_POLICY)
.expect("feasible offsets must not error");
assert!(
ok.is_none(),
"coefficient-free feasible block emits no rows"
);
match build_time_derivative_guard_constraints(&design, &array![1.0, 0.1], 0.5, MS_POLICY) {
Err(GuardConstraintFailure::InfeasibleRow {
row,
no_time_coefficients,
..
}) => {
assert_eq!(row, 1);
assert!(no_time_coefficients);
}
other => panic!("expected infeasible coefficient-free row, got {other:?}"),
}
}
#[test]
fn zero_design_row_reports_individual_infeasibility() {
let design = dense(2, 1, &[0.0, 1.0]);
let offsets = array![0.0, 0.0];
match build_time_derivative_guard_constraints(&design, &offsets, 0.5, MS_POLICY) {
Err(GuardConstraintFailure::InfeasibleRow {
row,
no_time_coefficients,
..
}) => {
assert_eq!(row, 0);
assert!(!no_time_coefficients);
}
other => panic!("expected zero-row infeasibility, got {other:?}"),
}
}
}