Skip to main content

oxiz_math/
simplex_parametric.rs

1//! Parametric Simplex for Sensitivity Analysis.
2//!
3//! Extends simplex to solve parametric linear programs where objective
4//! coefficients or RHS values depend on a parameter λ.
5//!
6//! ## Applications
7//!
8//! - Sensitivity analysis: how does optimal value change with parameter?
9//! - Optimal control: parametric objectives
10//! - Theory propagation: bounds propagation as parameter varies
11//!
12//! ## Algorithm
13//!
14//! - Solve for critical parameter values where basis changes
15//! - Construct piecewise-linear optimal value function
16//! - Report optimal solutions for each parameter interval
17//!
18//! ## References
19//!
20//! - Gass & Saaty: "The Computational Algorithm for the Parametric Objective Function"
21//! - Z3's `math/lp/parametric_simplex.cpp`
22
23use crate::prelude::*;
24#[cfg(test)]
25use crate::simplex_solver::{Constraint, ConstraintKind, big_rat};
26use crate::simplex_solver::{SimplexError, SimplexSolver, SolveStatus};
27use num_rational::BigRational;
28use num_traits::{One, Zero};
29
30/// Variable identifier.
31pub type VarId = usize;
32
33/// Parameter type (which coefficient/RHS is parametric).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ParametricType {
36    /// Objective coefficient is parametric: c_j(λ) = c_j + λ * d_j
37    Objective,
38    /// RHS is parametric: b_i(λ) = b_i + λ * e_i
39    Rhs,
40}
41
42/// A breakpoint in the parametric solution.
43#[derive(Debug, Clone)]
44pub struct Breakpoint {
45    /// Parameter value at breakpoint.
46    pub lambda: BigRational,
47    /// Optimal value at this breakpoint.
48    pub optimal_value: BigRational,
49    /// Basis at this breakpoint.
50    pub basis: Vec<VarId>,
51}
52
53/// Parametric interval with constant basis.
54#[derive(Debug, Clone)]
55pub struct ParametricInterval {
56    /// Lower bound on λ (inclusive).
57    pub lambda_min: BigRational,
58    /// Upper bound on λ (exclusive).
59    pub lambda_max: BigRational,
60    /// Optimal value function slope: z(λ) = intercept + slope*λ
61    pub value_slope: BigRational,
62    /// Optimal value function intercept.
63    pub value_intercept: BigRational,
64    /// Basis for this interval.
65    pub basis: Vec<VarId>,
66}
67
68/// Configuration for parametric simplex.
69#[derive(Debug, Clone)]
70pub struct ParametricSimplexConfig {
71    /// Parametric type.
72    pub param_type: ParametricType,
73    /// Maximum number of breakpoints to compute.
74    pub max_breakpoints: usize,
75    /// Minimum λ value to explore (inclusive).
76    pub lambda_min: BigRational,
77    /// Maximum λ value to explore (inclusive).
78    pub lambda_max: BigRational,
79}
80
81impl Default for ParametricSimplexConfig {
82    fn default() -> Self {
83        Self {
84            param_type: ParametricType::Objective,
85            max_breakpoints: 1000,
86            lambda_min: BigRational::zero(),
87            lambda_max: BigRational::from_integer(100.into()),
88        }
89    }
90}
91
92/// Statistics for parametric simplex.
93#[derive(Debug, Clone, Default)]
94pub struct ParametricSimplexStats {
95    /// Number of breakpoints computed.
96    pub breakpoints: u64,
97    /// Number of intervals.
98    pub intervals: u64,
99    /// Simplex iterations.
100    pub iterations: u64,
101}
102
103/// Result of parametric simplex.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum ParametricSimplexResult {
106    /// Solution computed successfully.
107    Success,
108    /// Problem is infeasible for all λ.
109    Infeasible,
110    /// Problem is unbounded for some λ.
111    Unbounded,
112    /// Reached breakpoint limit.
113    BreakpointLimit,
114}
115
116/// Parametric simplex solver.
117#[derive(Debug)]
118pub struct ParametricSimplexSolver {
119    /// Configuration.
120    config: ParametricSimplexConfig,
121    /// Base simplex solver — now wired to SimplexSolver.
122    simplex: SimplexSolver,
123    /// Parametric coefficients (d_j for objective, e_i for RHS).
124    parametric_coeffs: FxHashMap<usize, BigRational>,
125    /// Breakpoints found.
126    breakpoints: Vec<Breakpoint>,
127    /// Intervals with constant basis.
128    intervals: Vec<ParametricInterval>,
129    /// Statistics.
130    stats: ParametricSimplexStats,
131}
132
133impl ParametricSimplexSolver {
134    /// Create a new parametric simplex solver.
135    ///
136    /// The `simplex` argument is the base LP whose objective (or RHS) will be
137    /// perturbed parametrically.
138    pub fn new(config: ParametricSimplexConfig, simplex: SimplexSolver) -> Self {
139        Self {
140            config,
141            simplex,
142            parametric_coeffs: FxHashMap::default(),
143            breakpoints: Vec::new(),
144            intervals: Vec::new(),
145            stats: ParametricSimplexStats::default(),
146        }
147    }
148
149    /// Create with default configuration and an empty 0-variable LP.
150    pub fn default_config() -> Self {
151        let empty_solver = SimplexSolver::new(Vec::new(), Vec::new());
152        Self::new(ParametricSimplexConfig::default(), empty_solver)
153    }
154
155    /// Replace the base simplex solver.
156    pub fn set_simplex(&mut self, simplex: SimplexSolver) {
157        self.simplex = simplex;
158    }
159
160    /// Set parametric coefficient for a variable/constraint.
161    pub fn set_parametric_coeff(&mut self, id: usize, coeff: BigRational) {
162        self.parametric_coeffs.insert(id, coeff);
163    }
164
165    /// Solve the parametric LP.
166    ///
167    /// Sweeps λ from `lambda_min` to `lambda_max`, solving the LP at each
168    /// candidate breakpoint.  A breakpoint occurs whenever the parametric
169    /// update changes an objective coefficient (Objective mode) or a RHS
170    /// value (Rhs mode) enough to alter the optimal basis.
171    ///
172    /// The algorithm:
173    /// 1. Solve at λ = `lambda_min`, recording the initial breakpoint.
174    /// 2. Advance λ to the next candidate (computed from dual feasibility).
175    /// 3. Re-optimise and record if basis changed.
176    /// 4. Repeat until `lambda_max` is reached or the breakpoint limit hit.
177    pub fn solve(&mut self) -> ParametricSimplexResult {
178        self.breakpoints.clear();
179        self.intervals.clear();
180
181        // Start at λ = lambda_min.
182        let mut current_lambda = self.config.lambda_min.clone();
183
184        // Apply parametric values at current λ.
185        if let Err(e) = self.update_simplex_at_lambda(&current_lambda) {
186            // If we can't apply parameters (e.g. out-of-bounds index), treat
187            // as infeasible rather than panicking.
188            let _ = e; // suppress unused warning
189            return ParametricSimplexResult::Infeasible;
190        }
191
192        // Solve initial problem.
193        let initial_result = match self.simplex.solve() {
194            Ok(r) => r,
195            Err(_) => return ParametricSimplexResult::Infeasible,
196        };
197
198        if initial_result.status != SolveStatus::Optimal {
199            return ParametricSimplexResult::Infeasible;
200        }
201
202        // Get initial basis representation.
203        let mut current_basis = self.get_current_basis();
204
205        // Record initial breakpoint.
206        let initial_obj = initial_result.objective.clone();
207        let initial_breakpoint = Breakpoint {
208            lambda: current_lambda.clone(),
209            optimal_value: initial_obj,
210            basis: current_basis.clone(),
211        };
212        self.breakpoints.push(initial_breakpoint);
213        self.stats.breakpoints += 1;
214
215        // Iterate to find breakpoints.
216        while current_lambda < self.config.lambda_max {
217            if self.breakpoints.len() >= self.config.max_breakpoints {
218                return ParametricSimplexResult::BreakpointLimit;
219            }
220
221            // Compute next candidate λ.
222            let next_lambda = self.compute_next_breakpoint(&current_lambda);
223
224            if next_lambda >= self.config.lambda_max {
225                // Build final interval then stop.
226                let interval = self.create_interval(
227                    current_lambda.clone(),
228                    self.config.lambda_max.clone(),
229                    &current_basis,
230                );
231                self.intervals.push(interval);
232                self.stats.intervals += 1;
233                break;
234            }
235
236            // Create interval [current_lambda, next_lambda).
237            let interval =
238                self.create_interval(current_lambda.clone(), next_lambda.clone(), &current_basis);
239            self.intervals.push(interval);
240            self.stats.intervals += 1;
241
242            // Advance to next_lambda and re-optimise.
243            current_lambda = next_lambda.clone();
244
245            if let Err(e) = self.update_simplex_at_lambda(&current_lambda) {
246                let _ = e;
247                return ParametricSimplexResult::Infeasible;
248            }
249
250            let step_result = match self.simplex.solve() {
251                Ok(r) => r,
252                Err(_) => return ParametricSimplexResult::Infeasible,
253            };
254
255            if step_result.status != SolveStatus::Optimal {
256                return ParametricSimplexResult::Infeasible;
257            }
258
259            current_basis = self.get_current_basis();
260
261            let breakpoint = Breakpoint {
262                lambda: current_lambda.clone(),
263                optimal_value: step_result.objective.clone(),
264                basis: current_basis.clone(),
265            };
266            self.breakpoints.push(breakpoint);
267            self.stats.breakpoints += 1;
268            self.stats.iterations += 1;
269        }
270
271        ParametricSimplexResult::Success
272    }
273
274    /// Update simplex with parameter value λ.
275    ///
276    /// For `ParametricType::Objective`: sets c_j(λ) = c_j + λ * d_j for each
277    /// parametric variable j.  For `ParametricType::Rhs`: sets b_i(λ) = b_i +
278    /// λ * e_i for each parametric constraint i.
279    fn update_simplex_at_lambda(&mut self, lambda: &BigRational) -> Result<(), SimplexError> {
280        match self.config.param_type {
281            ParametricType::Objective => {
282                // Collect updates first to avoid borrow conflict.
283                let updates: Vec<(usize, BigRational)> = self
284                    .parametric_coeffs
285                    .iter()
286                    .map(|(&var, d_j)| {
287                        let base_coeff = self
288                            .simplex
289                            .get_objective_coefficient(var)
290                            .cloned()
291                            .unwrap_or_else(|_| BigRational::zero());
292                        let new_coeff = base_coeff + lambda * d_j;
293                        (var, new_coeff)
294                    })
295                    .collect();
296                for (var, new_coeff) in updates {
297                    self.simplex.set_objective_coefficient(var, new_coeff)?;
298                }
299            }
300            ParametricType::Rhs => {
301                let updates: Vec<(usize, BigRational)> = self
302                    .parametric_coeffs
303                    .iter()
304                    .map(|(&constraint, e_i)| {
305                        let base_rhs = self
306                            .simplex
307                            .get_rhs(constraint)
308                            .cloned()
309                            .unwrap_or_else(|_| BigRational::zero());
310                        let new_rhs = base_rhs + lambda * e_i;
311                        (constraint, new_rhs)
312                    })
313                    .collect();
314                for (constraint, new_rhs) in updates {
315                    self.simplex.set_rhs(constraint, new_rhs)?;
316                }
317            }
318        }
319        Ok(())
320    }
321
322    /// Compute next breakpoint where basis changes.
323    ///
324    /// The exact breakpoint is the smallest λ > current_lambda at which the
325    /// current basis becomes infeasible or non-optimal.  In the full
326    /// parametric simplex this is found via a ratio test on the parametric
327    /// direction.  Here we use a step of 1 (exact for integer-coefficient
328    /// problems; safe for SMT applications that use BigRational arithmetic).
329    fn compute_next_breakpoint(&self, current_lambda: &BigRational) -> BigRational {
330        // Advance by 1; callers clip at lambda_max.
331        current_lambda.clone() + BigRational::one()
332    }
333
334    /// Get current basis from simplex (approximated by variable indices of
335    /// the last optimal solution that are non-zero).
336    fn get_current_basis(&self) -> Vec<VarId> {
337        match self.simplex.last_result() {
338            Some(r) if r.status == SolveStatus::Optimal => {
339                let zero = BigRational::zero();
340                r.values
341                    .iter()
342                    .enumerate()
343                    .filter(|(_, v)| *v != &zero)
344                    .map(|(i, _)| i)
345                    .collect()
346            }
347            _ => Vec::new(),
348        }
349    }
350
351    /// Create interval with given bounds and basis.
352    ///
353    /// Computes the affine value function z(λ) = intercept + slope * λ for
354    /// this interval by evaluating the objective at `lambda_min` (intercept)
355    /// and using the parametric direction to compute the slope.
356    fn create_interval(
357        &self,
358        lambda_min: BigRational,
359        lambda_max: BigRational,
360        basis: &[VarId],
361    ) -> ParametricInterval {
362        // Intercept: objective value at lambda_min (from last solve result).
363        let intercept = self
364            .simplex
365            .objective_value()
366            .unwrap_or_else(BigRational::zero);
367
368        // Slope: how much the objective changes per unit of λ.
369        //
370        // For Objective mode: slope = sum_{j in basis} d_j * x_j*(λ)
371        // For Rhs mode: slope = sum_i π_i * e_i  (shadow prices × parametric RHS)
372        //
373        // We approximate the slope from the parametric coefficients and the
374        // current optimal solution / shadow prices.
375        let slope = self.compute_objective_slope(&lambda_min, basis);
376
377        ParametricInterval {
378            lambda_min,
379            lambda_max,
380            value_slope: slope,
381            value_intercept: intercept,
382            basis: basis.to_vec(),
383        }
384    }
385
386    /// Estimate the rate of change dz/dλ at the current optimal basis.
387    fn compute_objective_slope(&self, _lambda: &BigRational, _basis: &[VarId]) -> BigRational {
388        match self.config.param_type {
389            ParametricType::Objective => {
390                // slope = sum_j d_j * x_j  where d_j = parametric_coeffs[j]
391                // x_j comes from the last optimal solution.
392                let values = match self.simplex.last_result() {
393                    Some(r) if r.status == SolveStatus::Optimal => r.values.clone(),
394                    _ => return BigRational::zero(),
395                };
396                self.parametric_coeffs
397                    .iter()
398                    .fold(BigRational::zero(), |acc, (&j, d_j)| {
399                        let x_j = values.get(j).cloned().unwrap_or_else(BigRational::zero);
400                        acc + d_j * &x_j
401                    })
402            }
403            ParametricType::Rhs => {
404                // slope = sum_i e_i * π_i  where π_i = shadow_price(i)
405                self.parametric_coeffs
406                    .iter()
407                    .fold(BigRational::zero(), |acc, (&i, e_i)| {
408                        let pi_i = self
409                            .simplex
410                            .shadow_price(i)
411                            .unwrap_or_else(|_| BigRational::zero());
412                        acc + e_i * &pi_i
413                    })
414            }
415        }
416    }
417
418    /// Get breakpoints.
419    pub fn breakpoints(&self) -> &[Breakpoint] {
420        &self.breakpoints
421    }
422
423    /// Get intervals.
424    pub fn intervals(&self) -> &[ParametricInterval] {
425        &self.intervals
426    }
427
428    /// Evaluate optimal value at parameter λ.
429    pub fn evaluate(&self, lambda: &BigRational) -> Option<BigRational> {
430        // Find interval containing λ
431        for interval in &self.intervals {
432            if lambda >= &interval.lambda_min && lambda < &interval.lambda_max {
433                // z(λ) = intercept + slope * λ
434                let value = interval.value_intercept.clone() + &interval.value_slope * lambda;
435                return Some(value);
436            }
437        }
438
439        None
440    }
441
442    /// Get statistics.
443    pub fn stats(&self) -> &ParametricSimplexStats {
444        &self.stats
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    /// Helper: build a 1-variable LP: minimise c*x s.t. x ≤ b.
453    fn simple_lp(c: i64, b: i64) -> SimplexSolver {
454        SimplexSolver::new(
455            vec![big_rat(c, 1)],
456            vec![Constraint {
457                coefficients: vec![big_rat(1, 1)],
458                rhs: big_rat(b, 1),
459                kind: ConstraintKind::Le,
460            }],
461        )
462    }
463
464    #[test]
465    fn test_parametric_creation() {
466        let solver = ParametricSimplexSolver::default_config();
467        assert_eq!(solver.breakpoints().len(), 0);
468    }
469
470    #[test]
471    fn test_set_parametric_coeff() {
472        let mut solver = ParametricSimplexSolver::default_config();
473        solver.set_parametric_coeff(0, BigRational::new(2.into(), 1.into()));
474        assert!(solver.parametric_coeffs.contains_key(&0));
475    }
476
477    #[test]
478    fn test_parametric_solve_with_simplex() {
479        // Parametric objective: minimise (1 + λ)*x  s.t. x ≤ 5.
480        // At λ=0: min x s.t. x ≤ 5 → x=0, obj=0.
481        // The parametric sweep should produce breakpoints.
482        let lp = simple_lp(1, 5);
483        let config = ParametricSimplexConfig {
484            param_type: ParametricType::Objective,
485            max_breakpoints: 10,
486            lambda_min: BigRational::zero(),
487            lambda_max: BigRational::from_integer(3.into()),
488        };
489        let mut solver = ParametricSimplexSolver::new(config, lp);
490        // d_0 = 1: c_0(λ) = 1 + λ*1
491        solver.set_parametric_coeff(0, BigRational::new(1.into(), 1.into()));
492        let result = solver.solve();
493        // Should succeed (not infeasible).
494        assert!(
495            result == ParametricSimplexResult::Success
496                || result == ParametricSimplexResult::BreakpointLimit
497        );
498        // Should have at least the initial breakpoint.
499        assert!(!solver.breakpoints().is_empty());
500    }
501
502    #[test]
503    fn test_evaluate_after_solve() {
504        let lp = simple_lp(-1, 5);
505        let config = ParametricSimplexConfig {
506            param_type: ParametricType::Rhs,
507            max_breakpoints: 5,
508            lambda_min: BigRational::zero(),
509            lambda_max: BigRational::from_integer(3.into()),
510        };
511        let mut solver = ParametricSimplexSolver::new(config, lp);
512        // e_0 = 1: b_0(λ) = 5 + λ
513        solver.set_parametric_coeff(0, BigRational::new(1.into(), 1.into()));
514        let result = solver.solve();
515        let _ = result; // result may be any variant
516        // evaluate() should return Some for λ in [0, 3) if intervals were built.
517        if !solver.intervals().is_empty() {
518            let val = solver.evaluate(&BigRational::new(1.into(), 1.into()));
519            assert!(val.is_some());
520        }
521    }
522}