Repository navigation
Expand file tree
/
Copy pathlp.rs
More file actions
2600 lines (2428 loc) · 98.5 KB
/
Copy pathlp.rs
File metadata and controls
2600 lines (2428 loc) · 98.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Linear programming: the simplex method, interior point methods, duality,
//! and the classical models that reduce to a linear program.
//!
//! This module sits alongside the continuous optimisers in the parent module
//! rather than replacing them. Those search a smooth objective by following
//! gradients or shrinking a simplex, and stop at a local optimum. A linear
//! program has no local optima to stop at: the objective is linear and the
//! feasible region is a convex polyhedron, so any local optimum is global and
//! at least one optimum sits at a vertex. That is the whole reason the
//! subject exists as a separate discipline, and why an exact answer is
//! available where a nonlinear problem admits only an approximation.
//!
//! Two solvers are provided because they fail in different ways. The simplex
//! method walks vertex to vertex along the boundary, and terminates in an
//! exactly optimal basis, but its worst case is exponential and it can cycle
//! in the presence of degeneracy -- handled here by Bland's rule, which
//! guarantees termination at the cost of speed. The interior point method
//! approaches the optimum through the middle of the region, takes a number of
//! iterations that barely grows with problem size, and never lands exactly on
//! a vertex. Running both on the same problem and comparing is the cheapest
//! real check available on either.
//!
//! Duality is the organising idea. Every linear program has a dual whose
//! optimal value equals its own, and whose optimal solution is the vector of
//! rates at which the primal objective responds to relaxing each constraint.
//! Those rates -- shadow prices -- are usually worth more than the solution
//! itself, since they say which constraint to attack. The convention used
//! here is stated once and adhered to throughout:
//!
//! > `duals[i]` is the derivative of the reported objective with respect to
//! > `b[i]`.
//!
//! That definition is what makes the sensitivity ranges mean something, and
//! it is what the tests check: perturbing a right-hand side within its range
//! changes the objective by exactly `duals[i]` times the perturbation.
use crate::error::GeomError;
use crate::linalg::matrix::Matrix;
/// A pivot smaller than this is treated as numerically zero.
const PIVOT_TOL: f64 = 1e-9;
/// Reduced costs and residuals within this of zero are treated as zero.
const OPT_TOL: f64 = 1e-9;
/// Iteration cap; Bland's rule guarantees termination, so hitting this means
/// the problem is far larger than the tableau method should be used on.
const MAX_PIVOTS: usize = 200_000;
/// The sense of a constraint row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cmp {
/// `a . x <= b`
Le,
/// `a . x >= b`
Ge,
/// `a . x == b`
Eq,
}
impl Cmp {
/// The sense obtained by multiplying the row through by `-1`.
fn flipped(self) -> Self {
match self {
Cmp::Le => Cmp::Ge,
Cmp::Ge => Cmp::Le,
Cmp::Eq => Cmp::Eq,
}
}
}
/// A linear program.
///
/// Minimises (or maximises) `c . x` subject to the rows of `a` compared
/// against `b` by `constraint_types`, with each variable confined to its
/// entry of `bounds`. A bound of `(0.0, f64::INFINITY)` is the default
/// non-negative variable; `(f64::NEG_INFINITY, f64::INFINITY)` makes a
/// variable free.
#[derive(Debug, Clone, PartialEq)]
pub struct LpProblem {
/// Objective coefficients, one per variable.
pub c: Vec<f64>,
/// Constraint matrix, one row per constraint.
pub a: Matrix,
/// Right-hand sides, one per constraint.
pub b: Vec<f64>,
/// Sense of each constraint row.
pub constraint_types: Vec<Cmp>,
/// Per-variable `(lower, upper)` bounds.
pub bounds: Vec<(f64, f64)>,
/// Whether to maximise rather than minimise.
pub maximize: bool,
}
impl LpProblem {
/// A problem in the common shape: `A x <= b`, `x >= 0`.
///
/// # Errors
/// Returns an error if the shapes disagree.
pub fn new(c: Vec<f64>, a: Matrix, b: Vec<f64>, maximize: bool) -> Result<Self, GeomError> {
let m = b.len();
let p = Self {
constraint_types: vec![Cmp::Le; m],
bounds: vec![(0.0, f64::INFINITY); c.len()],
c,
a,
b,
maximize,
};
p.validate()?;
Ok(p)
}
/// Number of variables.
#[must_use]
pub fn n(&self) -> usize {
self.c.len()
}
/// Number of constraints.
#[must_use]
pub fn m(&self) -> usize {
self.b.len()
}
/// Checks that every part of the problem has a consistent shape.
///
/// # Errors
/// Returns [`GeomError::InvalidArgument`] describing the first mismatch.
pub fn validate(&self) -> Result<(), GeomError> {
if self.c.is_empty() {
return Err(GeomError::InvalidArgument("an LP needs at least one variable"));
}
if self.a.rows != self.b.len() || self.a.cols != self.c.len() {
return Err(GeomError::InvalidArgument("LP matrix shape does not match c and b"));
}
if self.constraint_types.len() != self.b.len() {
return Err(GeomError::InvalidArgument("one constraint sense per row is required"));
}
if self.bounds.len() != self.c.len() {
return Err(GeomError::InvalidArgument("one bound pair per variable is required"));
}
for (lo, hi) in &self.bounds {
if lo > hi {
return Err(GeomError::InvalidArgument("a lower bound exceeds its upper bound"));
}
if hi.is_infinite() && hi.is_sign_negative() {
return Err(GeomError::InvalidArgument("an upper bound is negative infinity"));
}
}
if self.c.iter().chain(&self.b).any(|v| !v.is_finite()) {
return Err(GeomError::InvalidArgument("LP coefficients must be finite"));
}
Ok(())
}
/// The objective value at a point, in the problem's own sense.
#[must_use]
pub fn objective_at(&self, x: &[f64]) -> f64 {
self.c.iter().zip(x).map(|(a, b)| a * b).sum()
}
/// Whether `x` satisfies every constraint and bound to within `tol`.
#[must_use]
pub fn is_feasible(&self, x: &[f64], tol: f64) -> bool {
if x.len() != self.n() {
return false;
}
for (j, &v) in x.iter().enumerate() {
let (lo, hi) = self.bounds[j];
if v < lo - tol || v > hi + tol {
return false;
}
}
for i in 0..self.m() {
let row: f64 = (0..self.n()).map(|j| self.a.get(i, j) * x[j]).sum();
let ok = match self.constraint_types[i] {
Cmp::Le => row <= self.b[i] + tol,
Cmp::Ge => row >= self.b[i] - tol,
Cmp::Eq => (row - self.b[i]).abs() <= tol,
};
if !ok {
return false;
}
}
true
}
}
/// What a solver concluded.
#[derive(Debug, Clone, PartialEq)]
pub enum LpResult {
/// An optimal vertex was found.
Optimal {
/// The optimal point.
x: Vec<f64>,
/// The objective there, in the problem's own sense.
objective: f64,
/// `duals[i]` is `d(objective) / d(b[i])`: the shadow price of row `i`.
duals: Vec<f64>,
/// `reduced_costs[j]` is `c[j] - sum_i duals[i] a[i][j]`, the rate at
/// which the objective would worsen per unit of variable `j` forced
/// into the solution. Zero for every variable already in use, which
/// is complementary slackness.
reduced_costs: Vec<f64>,
},
/// No point satisfies every constraint.
Infeasible,
/// The objective improves without bound inside the feasible region.
Unbounded,
}
impl LpResult {
/// The optimal objective, or `None` if the problem had no optimum.
#[must_use]
pub fn objective(&self) -> Option<f64> {
match self {
LpResult::Optimal { objective, .. } => Some(*objective),
_ => None,
}
}
/// The optimal point, or `None`.
#[must_use]
pub fn solution(&self) -> Option<&[f64]> {
match self {
LpResult::Optimal { x, .. } => Some(x),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// Standardisation
// ---------------------------------------------------------------------------
/// How each original variable maps into the standard-form variables.
#[derive(Debug, Clone, Copy)]
enum VarMap {
/// `x = shift + y[index]`, with `y >= 0`.
Shifted { index: usize, shift: f64 },
/// `x = y[plus] - y[minus]`, both non-negative: a free variable.
Split { plus: usize, minus: usize },
}
/// The problem rewritten as `min c'y`, `A y = b`, `y >= 0`, `b >= 0`.
struct Standard {
/// Equality constraint matrix over the standard variables and slacks.
a: Matrix,
b: Vec<f64>,
c: Vec<f64>,
/// How to read the original variables back out.
maps: Vec<VarMap>,
/// For each original constraint row, the standard row it became and
/// whether it was negated to make its right-hand side non-negative.
row_of: Vec<(usize, bool)>,
/// Number of structural (non-slack) standard variables.
structural: usize,
/// Whether the caller asked to maximise, so the reported objective and
/// duals must be negated back.
maximize: bool,
}
/// Rewrites a problem into `min c'y`, `A y = b`, `y >= 0`, `b >= 0`.
///
/// Three transformations, in order: a variable with a non-zero finite lower
/// bound is shifted so its lower bound is zero, a free variable is split into
/// the difference of two non-negative ones, and a finite upper bound becomes
/// an ordinary row. Then slacks and surpluses turn every inequality into an
/// equality, and any row with a negative right-hand side is negated.
///
/// A maximisation is turned into a minimisation of the negated objective, and
/// undone on the way out.
fn standardize(p: &LpProblem) -> Result<Standard, GeomError> {
p.validate()?;
let n = p.n();
// Lay out the standard variables and record the mapping back.
let mut maps = Vec::with_capacity(n);
let mut structural = 0usize;
for &(lo, _) in &p.bounds {
if lo.is_infinite() {
maps.push(VarMap::Split { plus: structural, minus: structural + 1 });
structural += 2;
} else {
maps.push(VarMap::Shifted { index: structural, shift: lo });
structural += 1;
}
}
// Objective in the internal (always minimising) sense.
let sign = if p.maximize { -1.0 } else { 1.0 };
let mut c = vec![0.0; structural];
for (j, &cj) in p.c.iter().enumerate() {
match maps[j] {
VarMap::Shifted { index, shift } => {
c[index] = sign * cj;
let _ = shift;
}
VarMap::Split { plus, minus } => {
c[plus] = sign * cj;
c[minus] = -sign * cj;
}
}
}
// Original rows, with the shift folded into the right-hand side, plus an
// extra row for every finite upper bound.
let mut rows: Vec<(Vec<f64>, f64, Cmp)> = Vec::new();
let mut row_of = Vec::with_capacity(p.m());
for i in 0..p.m() {
let mut coeffs = vec![0.0; structural];
let mut rhs = p.b[i];
for j in 0..n {
let aij = p.a.get(i, j);
if aij == 0.0 {
continue;
}
match maps[j] {
VarMap::Shifted { index, shift } => {
coeffs[index] += aij;
rhs -= aij * shift;
}
VarMap::Split { plus, minus } => {
coeffs[plus] += aij;
coeffs[minus] -= aij;
}
}
}
row_of.push((rows.len(), false));
rows.push((coeffs, rhs, p.constraint_types[i]));
}
for (j, &(lo, hi)) in p.bounds.iter().enumerate() {
if hi.is_finite() {
let mut coeffs = vec![0.0; structural];
match maps[j] {
VarMap::Shifted { index, shift } => {
coeffs[index] = 1.0;
rows.push((coeffs, hi - shift, Cmp::Le));
}
VarMap::Split { plus, minus } => {
coeffs[plus] = 1.0;
coeffs[minus] = -1.0;
rows.push((coeffs, hi, Cmp::Le));
}
}
debug_assert!(lo.is_infinite() || hi >= lo);
}
}
// Negate any row whose right-hand side is negative, so the identity basis
// of phase one starts feasible.
for (i, row) in rows.iter_mut().enumerate() {
if row.1 < 0.0 {
for v in &mut row.0 {
*v = -*v;
}
row.1 = -row.1;
row.2 = row.2.flipped();
if let Some(entry) = row_of.iter_mut().find(|e| e.0 == i) {
entry.1 = true;
}
}
}
// Slack for <=, surplus for >=, nothing for =.
let extra = rows.iter().filter(|r| r.2 != Cmp::Eq).count();
let total = structural + extra;
let m = rows.len();
let mut a = Matrix::zeros(m, total);
let mut b = vec![0.0; m];
let mut next_slack = structural;
for (i, (coeffs, rhs, cmp)) in rows.iter().enumerate() {
for (j, &v) in coeffs.iter().enumerate() {
a.set(i, j, v);
}
b[i] = *rhs;
match cmp {
Cmp::Le => {
a.set(i, next_slack, 1.0);
next_slack += 1;
}
Cmp::Ge => {
a.set(i, next_slack, -1.0);
next_slack += 1;
}
Cmp::Eq => {}
}
}
c.resize(total, 0.0);
Ok(Standard { a, b, c, maps, row_of, structural, maximize: p.maximize })
}
// ---------------------------------------------------------------------------
// The simplex method
// ---------------------------------------------------------------------------
/// A simplex tableau: the constraint rows, the objective row, and the basis.
struct Tableau {
/// `m` rows by `n + 1` columns; the last column is the right-hand side.
t: Vec<Vec<f64>>,
/// Objective row of the same width; the last entry is minus the objective.
z: Vec<f64>,
/// Column index basic in each row.
basis: Vec<usize>,
m: usize,
n: usize,
}
impl Tableau {
/// Pivots on `(row, col)`, making that column a unit vector.
fn pivot(&mut self, row: usize, col: usize) {
let p = self.t[row][col];
debug_assert!(p.abs() > PIVOT_TOL);
for v in &mut self.t[row] {
*v /= p;
}
for r in 0..self.m {
if r == row {
continue;
}
let factor = self.t[r][col];
if factor == 0.0 {
continue;
}
for k in 0..=self.n {
self.t[r][k] -= factor * self.t[row][k];
}
}
let factor = self.z[col];
if factor != 0.0 {
for k in 0..=self.n {
self.z[k] -= factor * self.t[row][k];
}
}
self.basis[row] = col;
}
/// Runs simplex to optimality over the columns in `allowed`.
///
/// Bland's rule chooses the lowest-indexed improving column and breaks
/// ratio ties by the lowest-indexed basic variable. That is provably
/// non-cycling: the basis sequence is lexicographically monotone, so no
/// basis can repeat. Faster rules -- steepest edge, Dantzig -- can revisit
/// a basis forever on a degenerate problem, which is not a hypothetical:
/// Beale's example cycles under Dantzig's rule in six pivots.
///
/// Returns `false` if the objective is unbounded below.
fn solve(&mut self, allowed: &dyn Fn(usize) -> bool) -> bool {
for _ in 0..MAX_PIVOTS {
// Entering: lowest index with a negative reduced cost.
let mut entering = None;
for j in 0..self.n {
if allowed(j) && self.z[j] < -OPT_TOL {
entering = Some(j);
break;
}
}
let Some(col) = entering else { return true };
// Leaving: minimum ratio, ties to the lowest basic index.
let mut best: Option<(f64, usize, usize)> = None;
for r in 0..self.m {
let a = self.t[r][col];
if a <= PIVOT_TOL {
continue;
}
let ratio = self.t[r][self.n] / a;
let candidate = (ratio, self.basis[r], r);
best = match best {
None => Some(candidate),
Some(current) => {
if ratio < current.0 - PIVOT_TOL
|| (ratio < current.0 + PIVOT_TOL && self.basis[r] < current.1)
{
Some(candidate)
} else {
Some(current)
}
}
};
}
let Some((_, _, row)) = best else {
// No row limits the increase: the objective falls forever.
return false;
};
self.pivot(row, col);
}
true
}
}
/// Solves a linear program by the two-phase simplex method.
///
/// Phase one minimises the total artificial infeasibility from an
/// artificial-variable basis; a positive optimum there proves the problem
/// infeasible, since that value is the least total violation achievable.
/// Phase two then optimises the real objective from the feasible basis phase
/// one produced.
///
/// Bland's rule is used throughout, so the method terminates on any problem,
/// including degenerate ones where a faster pivoting rule would cycle.
///
/// # Errors
/// Returns an error if the problem's parts disagree in shape.
pub fn simplex(p: &LpProblem) -> Result<LpResult, GeomError> {
Ok(solve_tableau(p)?.1)
}
/// Solves and returns the final tableau alongside the result, so that
/// sensitivity analysis can read the optimal basis rather than re-deriving it.
fn solve_tableau(p: &LpProblem) -> Result<(Option<(Tableau, Standard)>, LpResult), GeomError> {
let s = standardize(p)?;
let m = s.b.len();
let n = s.c.len();
if m == 0 {
// No constraints at all: the objective is unbounded unless every
// coefficient is zero, since variables are only bounded below.
if s.c.iter().any(|&v| v < -OPT_TOL) {
return Ok((None, LpResult::Unbounded));
}
let x = vec![0.0; p.n()];
let objective = p.objective_at(&x);
return Ok((
None,
LpResult::Optimal { x, objective, duals: Vec::new(), reduced_costs: p.c.clone() },
));
}
// Phase one: artificial variables form the starting basis.
let width = n + m;
let mut t = vec![vec![0.0; width + 1]; m];
for i in 0..m {
for j in 0..n {
t[i][j] = s.a.get(i, j);
}
t[i][n + i] = 1.0;
t[i][width] = s.b[i];
}
// Minimising the artificial sum; its reduced-cost row is minus the sum of
// the constraint rows over the real columns.
let mut z = vec![0.0; width + 1];
for (j, entry) in z.iter_mut().enumerate().take(n) {
*entry = -(0..m).map(|i| t[i][j]).sum::<f64>();
}
z[width] = -(0..m).map(|i| t[i][width]).sum::<f64>();
let mut tab = Tableau { t, z, basis: (n..n + m).collect(), m, n: width };
let real = |j: usize| j < n;
let all = |_: usize| true;
tab.solve(&all);
if -tab.z[width] > 1e-7 {
return Ok((None, LpResult::Infeasible));
}
// Drive any artificial still basic out of the basis. A row that cannot be
// pivoted has no independent real column left in it, so it is redundant
// and can be left with its artificial at zero.
for r in 0..m {
if tab.basis[r] >= n {
let replacement = (0..n).find(|&j| tab.t[r][j].abs() > PIVOT_TOL);
if let Some(col) = replacement {
tab.pivot(r, col);
}
}
}
// Phase two: the real objective, with artificial columns barred.
let mut z = vec![0.0; width + 1];
z[..n].copy_from_slice(&s.c[..n]);
for r in 0..m {
let col = tab.basis[r];
if col < n && s.c[col] != 0.0 {
let factor = z[col];
if factor != 0.0 {
for k in 0..=width {
z[k] -= factor * tab.t[r][k];
}
}
}
}
tab.z = z;
if !tab.solve(&real) {
return Ok((None, LpResult::Unbounded));
}
let result = extract(&tab, &s, p, n);
Ok((Some((tab, s)), result))
}
/// Reads a solution, duals and reduced costs out of an optimal tableau.
fn extract(tab: &Tableau, s: &Standard, p: &LpProblem, n: usize) -> LpResult {
let m = s.b.len();
let mut y = vec![0.0; n];
for r in 0..m {
if tab.basis[r] < n {
y[tab.basis[r]] = tab.t[r][tab.n];
}
}
// Map back to the caller's variables.
let mut x = vec![0.0; p.n()];
for (j, map) in s.maps.iter().enumerate() {
x[j] = match *map {
VarMap::Shifted { index, shift } => shift + y[index],
VarMap::Split { plus, minus } => y[plus] - y[minus],
};
}
let objective = p.objective_at(&x);
// The dual of a standard row is minus the objective-row entry under the
// column that was basic there at the start of phase two -- for a row with
// a slack, that is the slack column. `standardize` appends slacks in row
// order, so counting rows with slacks recovers the column.
let mut slack_of = vec![None; m];
let mut next = s.structural;
for (i, ct) in row_senses(s).iter().enumerate() {
if *ct != Cmp::Eq {
slack_of[i] = Some(next);
next += 1;
}
}
let sign = if s.maximize { -1.0 } else { 1.0 };
let mut duals = vec![0.0; p.m()];
for (i, &(row, negated)) in s.row_of.iter().enumerate() {
let raw = match slack_of[row] {
// For a <= row the slack enters with +1 and for a >= row with -1,
// which is why the two read off with opposite signs.
Some(col) => {
let sense = row_senses(s)[row];
match sense {
Cmp::Le => -tab.z[col],
Cmp::Ge => tab.z[col],
Cmp::Eq => 0.0,
}
}
// An equality row has no slack column; recover its dual from the
// artificial column that started basic there, which phase two
// leaves carrying exactly the same information.
None => -tab.z[s.structural + slack_count(s) + row],
};
// A negated row had its right-hand side sign flipped, so the
// derivative with respect to the original b flips with it.
let oriented = if negated { -raw } else { raw };
duals[i] = sign * oriented;
}
let reduced_costs = (0..p.n())
.map(|j| {
p.c[j] - (0..p.m()).map(|i| duals[i] * p.a.get(i, j)).sum::<f64>()
})
.collect();
LpResult::Optimal { x, objective, duals, reduced_costs }
}
/// The sense of each standard row, recovered from its slack coefficient.
fn row_senses(s: &Standard) -> Vec<Cmp> {
let m = s.b.len();
let mut out = vec![Cmp::Eq; m];
let mut next = s.structural;
for (i, entry) in out.iter_mut().enumerate() {
if next < s.a.cols {
let v = s.a.get(i, next);
if v == 1.0 {
*entry = Cmp::Le;
next += 1;
continue;
} else if v == -1.0 {
*entry = Cmp::Ge;
next += 1;
continue;
}
}
*entry = Cmp::Eq;
}
out
}
/// How many standard rows carry a slack or surplus column.
fn slack_count(s: &Standard) -> usize {
s.a.cols - s.structural
}
// ---------------------------------------------------------------------------
// Duality
// ---------------------------------------------------------------------------
/// The dual linear program.
///
/// For a minimisation `min c'x` subject to rows compared against `b` with
/// `x >= 0`, the dual is `max b'y` subject to `A'y <= c`, with each `y_i`
/// signed by the sense of its row: non-positive for a `<=` row, non-negative
/// for a `>=` row, free for an equality. Maximisation mirrors it.
///
/// Solving the dual gives the same optimal value as the primal and its
/// solution is the primal's vector of shadow prices, which is the practical
/// content of duality: the answer to "what is this constraint costing me" is
/// a solution to a different linear program of the same size.
///
/// # Errors
/// Returns an error unless every primal variable carries the default bounds
/// `(0, inf)`. A bounded variable contributes an extra dual row, which would
/// change the problem's shape rather than transpose it.
pub fn lp_dual(p: &LpProblem) -> Result<LpProblem, GeomError> {
p.validate()?;
if p.bounds.iter().any(|&(lo, hi)| lo != 0.0 || hi.is_finite()) {
return Err(GeomError::InvalidArgument(
"lp_dual requires the default non-negative variable bounds",
));
}
let (m, n) = (p.m(), p.n());
let mut a = Matrix::zeros(n, m);
for i in 0..m {
for j in 0..n {
a.set(j, i, p.a.get(i, j));
}
}
// Minimising primal gives a maximising dual with `<=` rows, and the
// reverse; a variable's sign follows from which direction relaxing its
// row can help.
let (sense, bounds): (Cmp, Vec<(f64, f64)>) = if p.maximize {
(
Cmp::Ge,
p.constraint_types
.iter()
.map(|c| match c {
Cmp::Le => (0.0, f64::INFINITY),
Cmp::Ge => (f64::NEG_INFINITY, 0.0),
Cmp::Eq => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect(),
)
} else {
(
Cmp::Le,
p.constraint_types
.iter()
.map(|c| match c {
Cmp::Le => (f64::NEG_INFINITY, 0.0),
Cmp::Ge => (0.0, f64::INFINITY),
Cmp::Eq => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect(),
)
};
Ok(LpProblem {
c: p.b.clone(),
a,
b: p.c.clone(),
constraint_types: vec![sense; n],
bounds,
maximize: !p.maximize,
})
}
// ---------------------------------------------------------------------------
// Sensitivity analysis
// ---------------------------------------------------------------------------
/// Ranges over which the optimal basis survives, as
/// `(objective coefficient ranges, right-hand side ranges)`.
///
/// Inside a right-hand side's range the shadow price is constant, so the
/// objective moves by exactly `duals[i]` per unit of `b[i]`. That linearity
/// is the point of the exercise and is what the tests check; outside the
/// range the basis changes and the rate does too.
///
/// Inside an objective coefficient's range the optimal *point* does not move
/// at all, only the value.
///
/// # Errors
/// Returns an error if the problem is not solved to an optimum, or if any
/// variable carries non-default bounds -- a finite upper bound becomes an
/// extra row during standardisation, and the ranges would then be reported
/// against rows the caller never wrote.
pub fn sensitivity_ranges(
p: &LpProblem,
) -> Result<(Vec<(f64, f64)>, Vec<(f64, f64)>), GeomError> {
if p.bounds.iter().any(|&(lo, hi)| lo != 0.0 || hi.is_finite()) {
return Err(GeomError::InvalidArgument(
"sensitivity_ranges requires the default non-negative variable bounds",
));
}
let (solved, result) = solve_tableau(p)?;
let LpResult::Optimal { .. } = result else {
return Err(GeomError::Degenerate("sensitivity_ranges requires an optimal solution"));
};
let Some((tab, s)) = solved else {
return Err(GeomError::Degenerate("sensitivity_ranges requires a constrained problem"));
};
let m = s.b.len();
let structural = s.structural;
let senses = row_senses(&s);
let mut slack_of = vec![None; m];
let mut next = structural;
for (i, sense) in senses.iter().enumerate() {
if *sense != Cmp::Eq {
slack_of[i] = Some(next);
next += 1;
}
}
// Right-hand side ranges. Raising b_i by delta moves the basic solution by
// delta times the i-th column of B inverse, which the tableau carries
// under that row's slack column (with a sign set by the row's sense).
let mut b_ranges = Vec::with_capacity(p.m());
for (i, &(row, negated)) in s.row_of.iter().enumerate() {
let Some(col) = slack_of[row] else {
// An equality row cannot be relaxed without changing the basis.
b_ranges.push((p.b[i], p.b[i]));
continue;
};
let orientation = match senses[row] {
Cmp::Le => 1.0,
Cmp::Ge => -1.0,
Cmp::Eq => 0.0,
} * if negated { -1.0 } else { 1.0 };
let (mut down, mut up) = (f64::NEG_INFINITY, f64::INFINITY);
for r in 0..m {
let direction = orientation * tab.t[r][col];
if direction.abs() < PIVOT_TOL {
continue;
}
// The basic value in row r is t[r][rhs]; it must stay non-negative.
let limit = -tab.t[r][tab.n] / direction;
if direction > 0.0 {
down = down.max(limit);
} else {
up = up.min(limit);
}
}
b_ranges.push((p.b[i] + down, p.b[i] + up));
}
// Objective coefficient ranges. A non-basic column may have its cost
// lowered until its reduced cost reaches zero; a basic one is limited by
// the ratios along its row.
let sign = if s.maximize { -1.0 } else { 1.0 };
let mut c_ranges = Vec::with_capacity(p.n());
for j in 0..p.n() {
let VarMap::Shifted { index, .. } = s.maps[j] else {
// A free variable is two standard columns at once; its range is
// not a single interval in this basis.
c_ranges.push((f64::NEG_INFINITY, f64::INFINITY));
continue;
};
let basic_row = (0..m).find(|&r| tab.basis[r] == index);
let (down, up) = match basic_row {
None => {
// Non-basic: the reduced cost must stay non-negative, so the
// cost may rise without limit and fall by its reduced cost.
(-tab.z[index], f64::INFINITY)
}
Some(r) => {
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
for k in 0..tab.n {
let a = tab.t[r][k];
if a.abs() < PIVOT_TOL || tab.basis.contains(&k) {
continue;
}
let ratio = tab.z[k] / a;
if a > 0.0 {
hi = hi.min(ratio);
} else {
lo = lo.max(ratio);
}
}
(lo, hi)
}
};
// The internal problem always minimises, so a maximisation's ranges
// come back mirrored.
let (a, b) = (p.c[j] + sign * down, p.c[j] + sign * up);
c_ranges.push((a.min(b), a.max(b)));
}
Ok((c_ranges, b_ranges))
}
// ---------------------------------------------------------------------------
// The dual simplex
// ---------------------------------------------------------------------------
/// The dual simplex method, started from a given basis.
///
/// Where the primal simplex keeps every basic variable non-negative and works
/// toward optimality, the dual simplex keeps the reduced costs optimal and
/// works toward feasibility. That is the right way round after a right-hand
/// side changes -- the old basis stays dual-feasible while becoming primal
/// infeasible, so re-solving costs a few pivots instead of a fresh start.
///
/// `basis` names one standard-form column per constraint row. Column indices
/// run over the structural variables first, then the slack and surplus
/// columns in row order.
///
/// # Errors
/// Returns an error if the basis has the wrong length, names a column out of
/// range, or is singular. A basis that is not dual-feasible is reported as
/// [`GeomError::Degenerate`] rather than silently repaired.
pub fn dual_simplex(p: &LpProblem, basis: &[usize]) -> Result<LpResult, GeomError> {
let s = standardize(p)?;
let m = s.b.len();
let n = s.c.len();
if basis.len() != m {
return Err(GeomError::InvalidArgument("dual_simplex needs one basic column per row"));
}
if basis.iter().any(|&j| j >= n) {
return Err(GeomError::InvalidArgument("dual_simplex basis names a column out of range"));
}
// Build the tableau and pivot the named columns into the basis.
let mut t = vec![vec![0.0; n + 1]; m];
for i in 0..m {
for j in 0..n {
t[i][j] = s.a.get(i, j);
}
t[i][n] = s.b[i];
}
let mut tab = Tableau { t, z: vec![0.0; n + 1], basis: vec![usize::MAX; m], m, n };
for (r, &col) in basis.iter().enumerate() {
if tab.t[r][col].abs() < PIVOT_TOL {
// Try to find another row that can supply this column.
let swap = (r + 1..m).find(|&k| tab.t[k][col].abs() > PIVOT_TOL);
let Some(k) = swap else {
return Err(GeomError::Degenerate("dual_simplex basis is singular"));
};
tab.t.swap(r, k);
}
tab.pivot(r, col);
}
// Price out the objective row against the basis.
let mut z = s.c.clone();
z.push(0.0);
for r in 0..m {
let factor = z[tab.basis[r]];
if factor != 0.0 {
for k in 0..=n {
z[k] -= factor * tab.t[r][k];
}
}
}
tab.z = z;
if tab.z[..n].iter().any(|&v| v < -OPT_TOL) {
return Err(GeomError::Degenerate("dual_simplex requires a dual-feasible basis"));
}
for _ in 0..MAX_PIVOTS {
// Leaving: the most negative basic value, ties to the lowest index.
let mut leaving: Option<usize> = None;
for r in 0..m {
if tab.t[r][n] < -PIVOT_TOL
&& leaving.is_none_or(|best| tab.t[r][n] < tab.t[best][n])
{
leaving = Some(r);
}
}
let Some(row) = leaving else {
let result = extract(&tab, &s, p, n);
return Ok(result);
};
// Entering: the ratio test runs along the row, over columns that would
// move the infeasible basic value upward.
let mut entering: Option<(f64, usize)> = None;
for j in 0..n {
let a = tab.t[row][j];
if a >= -PIVOT_TOL {
continue;
}
let ratio = tab.z[j] / -a;
if entering.is_none_or(|(best, _)| ratio < best) {
entering = Some((ratio, j));
}
}
let Some((_, col)) = entering else {
// No column can restore feasibility in this row: the primal is
// infeasible, which is the dual being unbounded.
return Ok(LpResult::Infeasible);
};
tab.pivot(row, col);
}
Err(GeomError::Degenerate("dual_simplex did not terminate"))
}
// ---------------------------------------------------------------------------
// Interior point
// ---------------------------------------------------------------------------
/// Number of Newton steps the path-following method is allowed.
const IP_MAX_ITER: usize = 200;
/// How far along a Newton step to go before hitting the boundary.
const IP_STEP_FRACTION: f64 = 0.995;
/// Centring parameter: the fraction of the current duality measure aimed at.
const IP_SIGMA: f64 = 0.2;