Skip to main content

pounce_sens_core/
barrier.rs

1//! What the corrector and the activity classification read off a converged
2//! barrier iterate, beyond the back-solve [`SensBacksolver`] provides.
3//!
4//! Both work in the compound layout every engine here shares with pounce's
5//! interior-point solver: one flat vector holding eight blocks in the order
6//!
7//! ```text
8//! x, s, y_c, y_d, z_l, z_u, v_l, v_u
9//! ```
10//!
11//! where `s` is the slack of the inequality rows, `y_c` and `y_d` are the
12//! multipliers of the equality and inequality rows, `z_l` and `z_u` the
13//! multipliers of the finite variable bounds, and `v_l` and `v_u` those of
14//! the finite inequality-row bounds. The signs follow Ipopt's:
15//!
16//! ```text
17//! ∇ₓL = ∇f + J_cᵀ y_c + J_dᵀ y_d − P_xl z_l + P_xu z_u
18//! ∇ₛL = −y_d − P_dl v_l + P_du v_u
19//! ```
20//!
21//! An engine that implements [`BarrierIterate`] gets [`crate::corrector`] and
22//! [`crate::activity`] over its own converged point. pounce's interior-point
23//! solver implements it in `pounce-sensitivity`, and an engine outside pounce
24//! implements it from its own final iterate, its own model callbacks and its
25//! own factorization of the KKT matrix.
26
27use std::rc::Rc;
28
29use pounce_common::types::Number;
30use pounce_linalg::{Matrix, SymMatrix};
31
32use crate::backsolver::SensBacksolver;
33
34/// A converged iterate of a barrier method, with the evaluations the
35/// corrector and the activity classification need.
36///
37/// Two frames appear below. The **natural** frame is the model as the user
38/// wrote it, and it is the frame [`SensBacksolver::solve`] answers in. The
39/// **engine** frame is whatever the engine iterated in, which differs from the
40/// natural one when it scaled the model. [`SensBacksolver::natural_units_factor`]
41/// carries a quantity from the engine frame into the natural one, and
42/// [`Self::scaled_rhs_factor`] carries a right-hand side the other way. An
43/// engine that did not scale returns `None` from both and has one frame.
44pub trait BarrierIterate: SensBacksolver {
45    /// Lengths of the eight blocks, in the order `x, s, y_c, y_d, z_l, z_u,
46    /// v_l, v_u`. They sum to [`SensBacksolver::dim`].
47    fn block_dims(&self) -> [usize; 8];
48
49    /// Where each block starts in the flat vector, with the total length in
50    /// the last entry.
51    fn offsets(&self) -> [usize; 9] {
52        let d = self.block_dims();
53        let mut o = [0usize; 9];
54        for i in 0..8 {
55            o[i + 1] = o[i] + d[i];
56        }
57        o
58    }
59
60    /// The barrier parameter the converged point sits on, as the level of
61    /// the complementarity products of the slacks and multipliers this engine
62    /// reports. These are the products [`Self::barrier_residual`] writes, at
63    /// the point [`Self::converged_iterate`] writes, in the engine frame.
64    ///
65    /// An engine reports the multipliers and `μ` in the frame its own
66    /// iteration ran in, and sets [`ActivityData::obj_scale`] to the factor it
67    /// multiplied the objective by. The classification's ratios do not depend
68    /// on that frame, but its band edges come from `√μ`, which does. In that
69    /// frame an engine classifies as pounce does. Ipopt
70    /// multiplies the objective, and with it every bound multiplier, by a
71    /// scaling factor `f` it prints, and its `.sol` reports the multipliers
72    /// divided by `f` again. An engine built from Ipopt's result therefore
73    /// multiplies those multipliers by `f`, returns the `μ` Ipopt printed, and
74    /// sets `obj_scale` to `f`.
75    ///
76    /// Reporting the unscaled multipliers with `μ / f` instead is consistent,
77    /// since each product then sits at `μ / f`. But the band edges move by
78    /// `1/√f` from pounce's, and the corrector measures its residual in a
79    /// different frame.
80    fn barrier_mu(&self) -> Number;
81
82    /// Writes the converged iterate into `out`, in the engine frame. The error
83    /// says why there is none, or that `out` is the wrong length.
84    fn converged_iterate(&self, out: &mut [Number]) -> Result<(), String>;
85
86    /// Per-row factor `E` that takes a natural-units right-hand side into the
87    /// engine frame, indexed like the compound vector. `None` is the
88    /// identity.
89    ///
90    /// The corrector reads its residual in the engine frame and hands it to
91    /// a solve that expects natural units, so it divides by this first.
92    fn scaled_rhs_factor(&self) -> Option<&[Number]> {
93        None
94    }
95
96    /// Writes the barrier KKT residual at `point` into `out`, block by block
97    /// in the compound layout and in the engine frame. `point` is in the
98    /// engine frame too.
99    ///
100    /// The eight blocks are `∇ₓL`, `∇ₛL`, `c(x)`, `d(x) − s`, and the four
101    /// complementarity products `(x − x_l) z_l`, `(x_u − x) z_u`,
102    /// `(s − d_l) v_l` and `(d_u − s) v_u`, each over the bounds that exist.
103    /// The products carry no barrier shift. The corrector subtracts `μ`
104    /// itself, and it moves the equality rows a parametric perturbation
105    /// pinned, so `c(x)` here is measured against the unperturbed
106    /// right-hand side.
107    ///
108    /// A point outside the domain of the model's functions writes non-finite
109    /// entries rather than failing. The error is for a malformed call.
110    fn barrier_residual(&self, point: &[Number], out: &mut [Number]) -> Result<(), String>;
111
112    /// The Newton operator of the barrier system at a predicted point, for
113    /// the corrector to iterate against.
114    ///
115    /// `predicted` is a compound vector in natural units. The operator is
116    /// the Jacobian of the residual [`Self::barrier_residual`] describes,
117    /// evaluated there, with two changes:
118    ///
119    /// * Each `(row, add)` in `pinned` names a primal row, a row of the `x`
120    ///   or `s` block, whose diagonal gets `add` on top of what the barrier
121    ///   puts there. These are bounds the step brought into the active set.
122    /// * With `exact_hessian` the Hessian of the Lagrangian is evaluated at
123    ///   `predicted`, with its multipliers. Without it the engine keeps the
124    ///   Hessian it last held, which is all a quasi-Newton engine has.
125    ///
126    /// A bound the corrector took out of the active set needs nothing from
127    /// the engine. Its multiplier is zero at `predicted` and the corrector
128    /// zeroes its row of every right-hand side, so with a positive slack the
129    /// bound adds nothing to the diagonal and its row returns a zero step.
130    ///
131    /// The returned operator answers in natural units like
132    /// [`SensBacksolver::solve`]. An engine whose evaluations depend on state
133    /// it changed to build the operator restores that state when the operator
134    /// is dropped.
135    fn corrector_operator<'a>(
136        &'a self,
137        predicted: &[Number],
138        pinned: &[(usize, Number)],
139        exact_hessian: bool,
140    ) -> Result<Box<dyn KktOperator + 'a>, String>;
141
142    /// Everything the activity classification reads off the converged point.
143    fn activity_data(&self) -> Result<ActivityData, String>;
144
145    /// How the engine's variables and rows correspond to the model's.
146    fn model_maps(&self) -> Result<ModelMaps, String>;
147
148    /// The scaling the engine applied to the model.
149    /// [`EngineScaling::none`] is the answer for an engine that applied none.
150    fn scaling(&self) -> EngineScaling;
151
152    /// The bounds the engine iterated against over the primal prefix of the
153    /// compound vector, `x` then `s`, in the engine frame: the variables'
154    /// bounds, then the inequality rows' limits. An entry with no bound on a
155    /// side holds an infinity there.
156    ///
157    /// These are the bounds the slacks of [`Self::activity_data`] are
158    /// measured from, so they carry whatever relaxation the engine applied.
159    fn primal_bounds(&self) -> Result<(Vec<Number>, Vec<Number>), String>;
160
161    /// The perturbations `(δ_x, δ_s, δ_c, δ_d)` the engine added to its KKT
162    /// matrix to correct its inertia, as its factor holds them. `None` when
163    /// the engine does not report them, which is the default.
164    ///
165    /// All four zero means every back-solve inverts the exact KKT matrix.
166    /// Anything else perturbs every sensitivity read off the factor, the
167    /// covariance in particular.
168    fn kkt_perturbations(&self) -> Option<[Number; 4]> {
169        None
170    }
171
172    /// An operator for the system with the bounds in `released`, compound
173    /// bound-multiplier rows, taken out of the active set, for a calculation
174    /// that solves against one released system many times.
175    ///
176    /// Each solve answers as [`SensBacksolver::solve_released`] does. The
177    /// default calls it on every solve. An engine that can factor the
178    /// released system once and back-solve against it thereafter overrides
179    /// this, and the answers do not change.
180    fn released_operator<'a>(
181        &'a self,
182        released: &[usize],
183    ) -> Result<Box<dyn KktOperator + 'a>, String> {
184        Ok(Box::new(ReleasedBySolve {
185            bs: self,
186            released: released.to_vec(),
187        }))
188    }
189}
190
191/// [`BarrierIterate::released_operator`]'s default: every solve is a call
192/// to [`SensBacksolver::solve_released`].
193struct ReleasedBySolve<'a, B: SensBacksolver + ?Sized> {
194    bs: &'a B,
195    released: Vec<usize>,
196}
197
198impl<B: SensBacksolver + ?Sized> KktOperator for ReleasedBySolve<'_, B> {
199    fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
200        self.bs.solve_released(&self.released, rhs, lhs)
201    }
202}
203
204/// A factored KKT operator, solved against in natural units. The corrector
205/// iterates against one (see [`BarrierIterate::corrector_operator`]), and
206/// the bound-aware steps solve against a released one (see
207/// [`BarrierIterate::released_operator`]).
208pub trait KktOperator {
209    /// Solves `K · lhs = rhs` in natural units. Returns `false` on failure.
210    ///
211    /// The solve has to be refined against the matrix, as Ipopt's and
212    /// pounce's are. A bound held on its constraint gives `K` a diagonal entry
213    /// many orders above the rest, and an unrefined solve loses the digits
214    /// that entry costs. Where a step direction is roundoff-sized, that can
215    /// change its sign, and the corrector's iterations then end differently
216    /// from pounce's.
217    fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool;
218}
219
220/// The bounds on one side of one block: which entries carry a finite bound,
221/// and at each of them the multiplier and the slack, in that order.
222///
223/// `pos` indexes the block the bound constrains, the `x` block for a
224/// variable bound and the `s` block for an inequality-row bound. `mult[k]`
225/// and `slack[k]` belong to entry `pos[k]`. The slack is the distance to the
226/// bound the engine iterated against, which is the declared bound widened by
227/// whatever relaxation the engine applied.
228#[derive(Debug, Clone, Default)]
229pub struct BoundSide {
230    /// Entry of the constrained block, per bound.
231    pub pos: Vec<usize>,
232    /// The bound's multiplier, per bound.
233    pub mult: Vec<Number>,
234    /// The distance from the entry to its bound, per bound.
235    pub slack: Vec<Number>,
236}
237
238/// How an engine's variables and rows correspond to the model's.
239///
240/// An engine may drop variables whose bounds coincide, and it splits the
241/// model's rows into equality rows, the `y_c` block, and inequality rows, the
242/// `s` and `y_d` blocks, so neither numbering is the model's. Every result a
243/// caller reads in the model's indices goes through these maps.
244#[derive(Debug, Clone, PartialEq)]
245pub struct ModelMaps {
246    /// The number of variables the model declares, counting any the engine
247    /// removed because their bounds coincide.
248    pub n_full_x: usize,
249    /// The model's index of each variable the engine kept.
250    pub var_x_to_full_x: Vec<usize>,
251    /// For each of the model's rows, its position among the equality rows,
252    /// or `None` for an inequality row.
253    pub full_g_to_c: Vec<Option<usize>>,
254    /// For each of the model's rows, its position among the inequality rows,
255    /// or `None` for an equality row.
256    pub full_g_to_d: Vec<Option<usize>>,
257}
258
259impl ModelMaps {
260    /// The number of rows the model declares.
261    pub fn n_full_g(&self) -> usize {
262        self.full_g_to_c.len()
263    }
264
265    /// The engine's index of each of the model's variables, `None` for one
266    /// the engine removed.
267    pub fn full_x_to_var_x(&self) -> Vec<Option<usize>> {
268        let mut out = vec![None; self.n_full_x];
269        for (i, &f) in self.var_x_to_full_x.iter().enumerate() {
270            if let Some(slot) = out.get_mut(f) {
271                *slot = Some(i);
272            }
273        }
274        out
275    }
276
277    /// Checks the maps against the engine's block lengths: one model
278    /// variable per engine variable, each named once, and each model row in
279    /// exactly one of the two row blocks, filling each block once.
280    pub fn check(&self, dims: &[usize; 8]) -> Result<(), String> {
281        let (n, m_c, m_d) = (dims[0], dims[2], dims[3]);
282        if self.var_x_to_full_x.len() != n {
283            return Err(format!(
284                "model maps: {} entries in the variable map for {n} variables",
285                self.var_x_to_full_x.len()
286            ));
287        }
288        let mut seen = vec![false; self.n_full_x];
289        for &i in &self.var_x_to_full_x {
290            match seen.get_mut(i) {
291                Some(slot) if !*slot => *slot = true,
292                _ => {
293                    return Err(format!(
294                        "model maps: the variable map names variable {i} twice or out of {}",
295                        self.n_full_x
296                    ));
297                }
298            }
299        }
300        if self.full_g_to_d.len() != self.full_g_to_c.len() {
301            return Err(format!(
302                "model maps: {} rows in the equality map and {} in the inequality map",
303                self.full_g_to_c.len(),
304                self.full_g_to_d.len()
305            ));
306        }
307        let mut seen_c = vec![false; m_c];
308        let mut seen_d = vec![false; m_d];
309        for (g, (c, d)) in self.full_g_to_c.iter().zip(&self.full_g_to_d).enumerate() {
310            let (block, seen, p) = match (c, d) {
311                (Some(p), None) => ("equality", &mut seen_c, *p),
312                (None, Some(p)) => ("inequality", &mut seen_d, *p),
313                _ => {
314                    return Err(format!(
315                        "model maps: row {g} is not in exactly one of the two row blocks"
316                    ));
317                }
318            };
319            match seen.get_mut(p) {
320                Some(slot) if !*slot => *slot = true,
321                _ => {
322                    return Err(format!(
323                        "model maps: the row map names {block} row {p} twice or out of {}",
324                        seen.len()
325                    ));
326                }
327            }
328        }
329        if seen_c.iter().chain(&seen_d).any(|s| !s) {
330            return Err("model maps: the row map leaves an engine row unnamed".into());
331        }
332        Ok(())
333    }
334}
335
336/// The scaling an engine applied to the model, as the factors it multiplied
337/// each quantity by.
338#[derive(Debug, Clone, PartialEq)]
339pub struct EngineScaling {
340    /// The factor the engine multiplied the objective by, `1.0` when it did
341    /// not, and negative for a maximization it turned into a minimization.
342    pub objective: Number,
343    /// The engine's change of variables `x̃ = d ⊙ x`, one factor per
344    /// variable the engine kept, when it made one.
345    pub variables: Option<Vec<Number>>,
346    /// The factor the engine multiplied each equality row by, when it did.
347    pub equality_rows: Option<Vec<Number>>,
348    /// The factor the engine multiplied each inequality row by, when it did.
349    pub inequality_rows: Option<Vec<Number>>,
350}
351
352impl EngineScaling {
353    /// No scaling at all.
354    pub fn none() -> Self {
355        Self {
356            objective: 1.0,
357            variables: None,
358            equality_rows: None,
359            inequality_rows: None,
360        }
361    }
362
363    /// Checks each factor vector against the block it scales.
364    pub fn check(&self, dims: &[usize; 8]) -> Result<(), String> {
365        for (what, v, len) in [
366            ("variable", &self.variables, dims[0]),
367            ("equality row", &self.equality_rows, dims[2]),
368            ("inequality row", &self.inequality_rows, dims[3]),
369        ] {
370            if let Some(v) = v
371                && v.len() != len
372            {
373                return Err(format!(
374                    "engine scaling: {} {what} scale factors for {len} entries",
375                    v.len()
376                ));
377            }
378        }
379        Ok(())
380    }
381}
382
383/// The converged quantities the activity classification reads, in the
384/// engine frame. See [`crate::activity`] for what it does with them. The
385/// barrier parameter comes from [`BarrierIterate::barrier_mu`], and the maps
386/// and scaling from [`BarrierIterate::model_maps`] and
387/// [`BarrierIterate::scaling`].
388pub struct ActivityData {
389    /// Lower variable bounds.
390    pub x_l: BoundSide,
391    /// Upper variable bounds.
392    pub x_u: BoundSide,
393    /// Lower bounds of the inequality rows.
394    pub d_l: BoundSide,
395    /// Upper bounds of the inequality rows.
396    pub d_u: BoundSide,
397    /// The barrier diagonal of the `x` block, one entry per variable the
398    /// engine kept, both sides summed.
399    ///
400    /// An engine that bounds the diagonal in no way of its own writes
401    /// `mult / slack` from [`Self::x_l`] and [`Self::x_u`], summed. pounce
402    /// writes the diagonal it factored instead, which differs from that sum
403    /// where the gh#737 ceiling capped an entry or a crossover point took the
404    /// declared-bound frame (gh#654).
405    pub sigma_x: Vec<Number>,
406    /// The barrier diagonal of the `s` block, one entry per inequality row,
407    /// from [`Self::d_l`] and [`Self::d_u`] as for [`Self::sigma_x`].
408    pub sigma_s: Vec<Number>,
409    /// The exact Hessian of the Lagrangian at the converged point.
410    pub hessian: Rc<dyn SymMatrix>,
411    /// The Jacobian of the equality rows at the converged point.
412    pub jac_c: Rc<dyn Matrix>,
413    /// The Jacobian of the inequality rows at the converged point.
414    pub jac_d: Rc<dyn Matrix>,
415}
416
417impl ActivityData {
418    /// Checks the lengths agree with each other, so the classification can
419    /// index without bounds failures on data an outside engine supplied.
420    pub fn check(&self) -> Result<(), String> {
421        let n = self.sigma_x.len();
422        let m_d = self.sigma_s.len();
423        let side = |name: &str, s: &BoundSide, len: usize| -> Result<(), String> {
424            if s.mult.len() != s.pos.len() || s.slack.len() != s.pos.len() {
425                return Err(format!(
426                    "activity data: {name} has {} positions, {} multipliers and {} slacks",
427                    s.pos.len(),
428                    s.mult.len(),
429                    s.slack.len()
430                ));
431            }
432            if let Some(&p) = s.pos.iter().find(|&&p| p >= len) {
433                return Err(format!(
434                    "activity data: {name} names entry {p} of a block of {len}"
435                ));
436            }
437            Ok(())
438        };
439        side("x_l", &self.x_l, n)?;
440        side("x_u", &self.x_u, n)?;
441        side("d_l", &self.d_l, m_d)?;
442        side("d_u", &self.d_u, m_d)?;
443        if self.hessian.n_rows() as usize != n || self.hessian.n_cols() as usize != n {
444            return Err(format!(
445                "activity data: the Hessian is {}x{}, expected {n}x{n}",
446                self.hessian.n_rows(),
447                self.hessian.n_cols()
448            ));
449        }
450        if self.jac_d.n_rows() as usize != m_d || self.jac_d.n_cols() as usize != n {
451            return Err(format!(
452                "activity data: the inequality Jacobian is {}x{}, expected {m_d}x{n}",
453                self.jac_d.n_rows(),
454                self.jac_d.n_cols()
455            ));
456        }
457        if self.jac_c.n_cols() as usize != n {
458            return Err(format!(
459                "activity data: the equality Jacobian has {} columns, expected {n}",
460                self.jac_c.n_cols()
461            ));
462        }
463        Ok(())
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use pounce_linalg::triplet::{GenTMatrix, GenTMatrixSpace, SymTMatrix, SymTMatrixSpace};
471
472    /// Two variables, one equality row, one inequality row, a lower bound on
473    /// each variable and an upper bound on the inequality row: consistent in
474    /// every length.
475    fn data() -> ActivityData {
476        let hess = SymTMatrix::new(SymTMatrixSpace::new(2, vec![1, 2], vec![1, 2]));
477        let jac = |m| GenTMatrix::new(GenTMatrixSpace::new(m, 2, vec![1, 1], vec![1, 2]));
478        let side = |pos: Vec<usize>| BoundSide {
479            mult: vec![1.0; pos.len()],
480            slack: vec![1.0; pos.len()],
481            pos,
482        };
483        ActivityData {
484            x_l: side(vec![0, 1]),
485            x_u: side(vec![]),
486            d_l: side(vec![]),
487            d_u: side(vec![0]),
488            sigma_x: vec![1.0, 1.0],
489            sigma_s: vec![1.0],
490            hessian: Rc::new(hess),
491            jac_c: Rc::new(jac(1)),
492            jac_d: Rc::new(jac(1)),
493        }
494    }
495
496    /// The block lengths of [`data`]'s engine: two variables, one slack,
497    /// one equality and one inequality multiplier, two variable bounds and
498    /// one row bound.
499    const DIMS: [usize; 8] = [2, 1, 1, 1, 2, 0, 0, 1];
500
501    /// Three model variables, of which the engine dropped the second, and
502    /// two model rows, an inequality then an equality.
503    fn maps() -> ModelMaps {
504        ModelMaps {
505            n_full_x: 3,
506            var_x_to_full_x: vec![0, 2],
507            full_g_to_c: vec![None, Some(0)],
508            full_g_to_d: vec![Some(0), None],
509        }
510    }
511
512    #[test]
513    fn consistent_data_passes() {
514        assert_eq!(data().check(), Ok(()));
515        assert_eq!(maps().check(&DIMS), Ok(()));
516        assert_eq!(EngineScaling::none().check(&DIMS), Ok(()));
517    }
518
519    #[test]
520    fn a_bound_side_with_mismatched_lengths_is_refused() {
521        let mut d = data();
522        d.x_l.slack.pop();
523        assert!(d.check().unwrap_err().contains("x_l"));
524        let mut d = data();
525        d.d_u.mult.push(1.0);
526        assert!(d.check().unwrap_err().contains("d_u"));
527    }
528
529    #[test]
530    fn a_bound_outside_its_block_is_refused() {
531        let mut d = data();
532        d.x_l.pos[1] = 2;
533        assert!(d.check().unwrap_err().contains("entry 2"));
534        let mut d = data();
535        d.d_u.pos[0] = 1;
536        assert!(d.check().unwrap_err().contains("entry 1"));
537    }
538
539    #[test]
540    fn matrices_of_the_wrong_shape_are_refused() {
541        let mut d = data();
542        d.hessian = Rc::new(SymTMatrix::new(SymTMatrixSpace::new(3, vec![], vec![])));
543        assert!(d.check().unwrap_err().contains("Hessian"));
544        let mut d = data();
545        d.jac_d = Rc::new(GenTMatrix::new(GenTMatrixSpace::new(1, 3, vec![], vec![])));
546        assert!(d.check().unwrap_err().contains("inequality Jacobian"));
547        let mut d = data();
548        d.jac_c = Rc::new(GenTMatrix::new(GenTMatrixSpace::new(1, 3, vec![], vec![])));
549        assert!(d.check().unwrap_err().contains("equality Jacobian"));
550    }
551
552    #[test]
553    fn scale_factors_of_the_wrong_length_are_refused() {
554        for (scaling, what) in [
555            (
556                EngineScaling {
557                    variables: Some(vec![1.0]),
558                    ..EngineScaling::none()
559                },
560                "variable scale",
561            ),
562            (
563                EngineScaling {
564                    equality_rows: Some(vec![1.0, 1.0]),
565                    ..EngineScaling::none()
566                },
567                "equality row scale",
568            ),
569            (
570                EngineScaling {
571                    inequality_rows: Some(vec![]),
572                    ..EngineScaling::none()
573                },
574                "inequality row scale",
575            ),
576        ] {
577            assert!(scaling.check(&DIMS).unwrap_err().contains(what), "{what}");
578        }
579    }
580
581    #[test]
582    fn a_variable_map_that_repeats_overruns_or_has_the_wrong_length_is_refused() {
583        let mut m = maps();
584        m.var_x_to_full_x.pop();
585        assert!(m.check(&DIMS).unwrap_err().contains("variable map for"));
586        let mut m = maps();
587        m.var_x_to_full_x[1] = 3;
588        assert!(m.check(&DIMS).unwrap_err().contains("names variable 3"));
589        let mut m = maps();
590        m.var_x_to_full_x[1] = 0;
591        assert!(
592            m.check(&DIMS)
593                .unwrap_err()
594                .contains("names variable 0 twice")
595        );
596    }
597
598    #[test]
599    fn a_row_map_that_repeats_skips_splits_or_overruns_is_refused() {
600        let mut m = maps();
601        m.full_g_to_d = vec![Some(0), Some(0)];
602        assert!(m.check(&DIMS).unwrap_err().contains("exactly one"));
603        let mut m = maps();
604        m.full_g_to_d = vec![Some(1), None];
605        assert!(m.check(&DIMS).unwrap_err().contains("twice or out of"));
606        let mut m = maps();
607        m.full_g_to_c = vec![None, Some(0), Some(0)];
608        assert!(m.check(&DIMS).unwrap_err().contains("equality map"));
609        let mut m = maps();
610        m.full_g_to_c = vec![None, None];
611        m.full_g_to_d = vec![Some(0), None];
612        assert!(m.check(&DIMS).unwrap_err().contains("exactly one"));
613        let mut m = maps();
614        m.full_g_to_c = vec![Some(0), Some(0)];
615        m.full_g_to_d = vec![None, None];
616        assert!(m.check(&DIMS).unwrap_err().contains("twice or out of"));
617        let m = ModelMaps {
618            full_g_to_c: vec![Some(0)],
619            full_g_to_d: vec![None],
620            ..maps()
621        };
622        assert!(m.check(&DIMS).unwrap_err().contains("unnamed"));
623    }
624
625    #[test]
626    fn the_inverse_variable_map_marks_a_dropped_variable() {
627        assert_eq!(maps().full_x_to_var_x(), vec![Some(0), None, Some(1)]);
628        assert_eq!(maps().n_full_g(), 2);
629    }
630}