use pounce_common::types::Number;
use pounce_nlp::expression_provider::{ExpressionProvider, FbbtOp};
use crate::fbbt::forward::forward_pass;
use crate::fbbt::interval::Interval;
use crate::fbbt::reverse::reverse_pass;
#[derive(Debug, Clone, Copy)]
pub struct FbbtConfig {
pub tol: Number,
pub max_iter: usize,
pub max_constraints: usize,
}
impl Default for FbbtConfig {
fn default() -> Self {
Self {
tol: 1.0e-6,
max_iter: 10,
max_constraints: 0,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FbbtReport {
pub iterations: usize,
pub bound_updates: usize,
pub infeasibility_witness: Option<usize>,
pub total_tightening: Number,
}
pub fn run_fbbt(
provider: &dyn ExpressionProvider,
n_vars: usize,
n_constraints: usize,
x_lo: &mut [Number],
x_hi: &mut [Number],
g_lo: &[Number],
g_hi: &[Number],
cfg: &FbbtConfig,
) -> FbbtReport {
let mut report = FbbtReport::default();
assert_eq!(x_lo.len(), n_vars, "x_lo length");
assert_eq!(x_hi.len(), n_vars, "x_hi length");
assert_eq!(g_lo.len(), n_constraints, "g_lo length");
assert_eq!(g_hi.len(), n_constraints, "g_hi length");
let cap = if cfg.max_constraints == 0 {
n_constraints
} else {
cfg.max_constraints.min(n_constraints)
};
for _iter in 0..cfg.max_iter {
report.iterations += 1;
let mut improved = false;
for i in 0..cap {
let Some(tape) = provider.constraint_expression(i) else {
continue;
};
if tape.is_empty() {
continue;
}
let forward = match forward_pass(&tape, x_lo, x_hi) {
Ok(v) => v,
Err(_) => continue, };
let bound = Interval::new(g_lo[i], g_hi[i]);
let reverse = reverse_pass(&tape, &forward, bound);
if reverse.infeasible {
report.infeasibility_witness = Some(i);
return report;
}
let mut tighten: Vec<Interval> = vec![Interval::ENTIRE; n_vars];
for (slot_idx, op) in tape.ops.iter().enumerate() {
if let FbbtOp::Var(j) = *op {
tighten[j] = tighten[j].intersect(reverse.slots[slot_idx]);
}
}
for j in 0..n_vars {
let t = tighten[j];
if t.is_empty() {
report.infeasibility_witness = Some(i);
return report;
}
if t.is_entire() {
continue;
}
let new_lo = x_lo[j].max(t.lo);
let new_hi = x_hi[j].min(t.hi);
if new_lo > new_hi {
report.infeasibility_witness = Some(i);
return report;
}
let delta_lo = (new_lo - x_lo[j]).max(0.0);
let delta_hi = (x_hi[j] - new_hi).max(0.0);
let delta = delta_lo.max(delta_hi);
if delta > cfg.tol {
x_lo[j] = new_lo;
x_hi[j] = new_hi;
report.bound_updates += 1;
report.total_tightening += delta;
improved = true;
} else if delta_lo > 0.0 || delta_hi > 0.0 {
x_lo[j] = new_lo;
x_hi[j] = new_hi;
}
}
}
if !improved {
break;
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
use pounce_nlp::expression_provider::{FbbtOp, FbbtTape};
struct StubProvider {
tapes: Vec<Option<FbbtTape>>,
}
impl ExpressionProvider for StubProvider {
fn constraint_expression(&self, i: usize) -> Option<FbbtTape> {
self.tapes.get(i).and_then(|t| t.clone())
}
}
#[test]
fn unit_circle_tightens_box() {
let tape = FbbtTape {
ops: vec![
FbbtOp::Var(0),
FbbtOp::PowInt(0, 2),
FbbtOp::Var(1),
FbbtOp::PowInt(2, 2),
FbbtOp::Add(1, 3),
],
};
let provider = StubProvider {
tapes: vec![Some(tape)],
};
let mut x_lo = vec![-10.0, -10.0];
let mut x_hi = vec![10.0, 10.0];
let r = run_fbbt(
&provider,
2,
1,
&mut x_lo,
&mut x_hi,
&[1.0],
&[1.0],
&FbbtConfig::default(),
);
assert!(r.infeasibility_witness.is_none());
assert!(r.bound_updates >= 2, "got {} updates", r.bound_updates);
for (lo, hi) in x_lo.iter().zip(&x_hi) {
assert!(*lo >= -1.0 - 1e-6, "lo = {lo}");
assert!(*hi <= 1.0 + 1e-6, "hi = {hi}");
}
}
#[test]
fn exp_upper_bound_tightens() {
let tape = FbbtTape {
ops: vec![FbbtOp::Var(0), FbbtOp::Exp(0)],
};
let provider = StubProvider {
tapes: vec![Some(tape)],
};
let mut x_lo = vec![-10.0];
let mut x_hi = vec![10.0];
let r = run_fbbt(
&provider,
1,
1,
&mut x_lo,
&mut x_hi,
&[Number::NEG_INFINITY],
&[10.0],
&FbbtConfig::default(),
);
assert!(r.infeasibility_witness.is_none());
assert!(x_hi[0] <= 2.31, "x_hi = {}", x_hi[0]);
assert_eq!(x_lo[0], -10.0);
}
#[test]
fn coupled_constraints_iterate() {
let tape_a = FbbtTape {
ops: vec![FbbtOp::Var(1), FbbtOp::PowInt(0, 2)],
};
let tape_b = FbbtTape {
ops: vec![
FbbtOp::Var(0),
FbbtOp::Var(1),
FbbtOp::PowInt(1, 2),
FbbtOp::Add(0, 2),
],
};
let provider = StubProvider {
tapes: vec![Some(tape_a), Some(tape_b)],
};
let mut x_lo = vec![-10.0, -10.0];
let mut x_hi = vec![10.0, 10.0];
let r = run_fbbt(
&provider,
2,
2,
&mut x_lo,
&mut x_hi,
&[Number::NEG_INFINITY, 0.5],
&[1.0, 0.5],
&FbbtConfig::default(),
);
assert!(r.infeasibility_witness.is_none());
assert!(x_lo[1] >= -1.0 - 1e-6, "y_lo = {}", x_lo[1]);
assert!(x_hi[1] <= 1.0 + 1e-6, "y_hi = {}", x_hi[1]);
assert!(x_lo[0] >= -0.5 - 1e-6, "x_lo = {}", x_lo[0]);
assert!(x_hi[0] <= 0.5 + 1e-6, "x_hi = {}", x_hi[0]);
}
#[test]
fn detects_infeasibility() {
let tape = FbbtTape {
ops: vec![FbbtOp::Var(0)],
};
let provider = StubProvider {
tapes: vec![Some(tape)],
};
let mut x_lo = vec![10.0];
let mut x_hi = vec![20.0];
let r = run_fbbt(
&provider,
1,
1,
&mut x_lo,
&mut x_hi,
&[1.0],
&[5.0],
&FbbtConfig::default(),
);
assert_eq!(r.infeasibility_witness, Some(0));
}
#[test]
fn missing_expression_is_silent_noop() {
let provider = StubProvider { tapes: vec![None] };
let mut x_lo = vec![-1.0];
let mut x_hi = vec![1.0];
let r = run_fbbt(
&provider,
1,
1,
&mut x_lo,
&mut x_hi,
&[-100.0],
&[100.0],
&FbbtConfig::default(),
);
assert!(r.infeasibility_witness.is_none());
assert_eq!(r.bound_updates, 0);
assert_eq!(x_lo, vec![-1.0]);
assert_eq!(x_hi, vec![1.0]);
}
#[test]
fn max_iter_caps_iteration_count() {
let tape_sum = FbbtTape {
ops: vec![FbbtOp::Var(0), FbbtOp::Var(1), FbbtOp::Add(0, 1)],
};
let tape_diff = FbbtTape {
ops: vec![FbbtOp::Var(0), FbbtOp::Var(1), FbbtOp::Sub(0, 1)],
};
let provider = StubProvider {
tapes: vec![Some(tape_sum), Some(tape_diff)],
};
let mut x_lo = vec![-10.0, -10.0];
let mut x_hi = vec![10.0, 10.0];
let cfg = FbbtConfig {
tol: 1e-6,
max_iter: 1,
max_constraints: 0,
};
let r = run_fbbt(
&provider,
2,
2,
&mut x_lo,
&mut x_hi,
&[1.0, 0.0],
&[1.0, 0.0],
&cfg,
);
assert!(r.infeasibility_witness.is_none());
assert_eq!(r.iterations, 1);
let width0 = x_hi[0] - x_lo[0];
let width1 = x_hi[1] - x_lo[1];
assert!(
width0 > 1e-3 || width1 > 1e-3,
"single sweep already converged unexpectedly"
);
}
#[test]
fn max_constraints_truncates_sweep() {
let tape_a = FbbtTape {
ops: vec![FbbtOp::Var(0)],
};
let tape_b = FbbtTape {
ops: vec![FbbtOp::Var(1)],
};
let provider = StubProvider {
tapes: vec![Some(tape_a), Some(tape_b)],
};
let mut x_lo = vec![-10.0, -10.0];
let mut x_hi = vec![10.0, 10.0];
let cfg = FbbtConfig {
tol: 1e-6,
max_iter: 5,
max_constraints: 1, };
let _ = run_fbbt(
&provider,
2,
2,
&mut x_lo,
&mut x_hi,
&[-1.0, -1.0],
&[1.0, 1.0],
&cfg,
);
assert!(x_lo[0] >= -1.0 - 1e-12);
assert!(x_hi[0] <= 1.0 + 1e-12);
assert_eq!(x_lo[1], -10.0);
assert_eq!(x_hi[1], 10.0);
}
#[test]
fn fuzz_soundness_pointwise() {
let tape = FbbtTape {
ops: vec![
FbbtOp::Var(1),
FbbtOp::PowInt(0, 2),
FbbtOp::Var(0),
FbbtOp::Add(1, 2),
],
};
let provider = StubProvider {
tapes: vec![Some(tape)],
};
let mut x_lo = vec![-10.0, -3.0];
let mut x_hi = vec![5.0, 3.0];
let _ = run_fbbt(
&provider,
2,
1,
&mut x_lo,
&mut x_hi,
&[5.0],
&[5.0],
&FbbtConfig::default(),
);
for k in -30..=30 {
let y = k as Number / 10.0;
if !(-3.0..=3.0).contains(&y) {
continue;
}
let x = 5.0 - y * y;
if !(-10.0..=5.0).contains(&x) {
continue;
}
assert!(
x_lo[0] - 1e-6 <= x && x <= x_hi[0] + 1e-6,
"feasible x={x} dropped (bounds {} .. {})",
x_lo[0],
x_hi[0]
);
assert!(
x_lo[1] - 1e-6 <= y && y <= x_hi[1] + 1e-6,
"feasible y={y} dropped"
);
}
}
}