pounce_sensitivity/index.rs
1//! Scoped index newtypes for the two variable spaces this crate mixes.
2//!
3//! # Why these exist, and why they are not everywhere
4//!
5//! gh#764 item 3 proposed `VarX`/`FullX`/`KktRow`/`UserG` across the
6//! crate, then measured the cost: ~342 touch points across 12 files,
7//! `pub trait SensBacksolver` re-exported from the crate root (so
8//! changing `solve_released` is a breaking API change), and 31 sites in
9//! `pounce-py` where indices cross into Python as `i64`. That sweep is
10//! not worth its price, and this module is not it.
11//!
12//! What survived the measurement is a much sharper rule:
13//!
14//! > **Table lookup fails loudly; direct array indexing fails
15//! > silently.**
16//!
17//! A released row resolved through `rows.iter().find(|b| b.row == r)?`
18//! turns a space-swap into `SensComputationFailed`, immediately and
19//! every time. A direct index into a `Vec` does not: it returns a
20//! *neighbouring variable's* answer, which is in range, plausible, and
21//! wrong. That is gh#450, and gh#672 finding 1 shipped it again as
22//! `report.var_status.get(var_row)` — a var-x row indexing a full-x
23//! array.
24//!
25//! So the newtype earns its cost at exactly one shape: **a conversion
26//! whose result feeds a direct array index, in a scope where the other
27//! space is also live.** Measured over `pounce-sensitivity`, that is
28//! two sites, both in `solver.rs`:
29//!
30//! * the kappa computation's `var_sigma` read, whose `unwrap_or(0.0)`
31//! turns a miss into a zero that silently drops the row from the
32//! engaged set;
33//! * `weakly_active_bounds`, whose loop body indexes `ctx.x_curr` /
34//! `ctx.lo` / `ctx.hi` by var-x **and** `report.var_status` by
35//! full-x, with a bound row `b.row` in scope as a third space.
36//!
37//! The other nine conversion sites do not earn it, and are left alone
38//! deliberately:
39//!
40//! * `activity.rs` has four scatter loops (`var_full[full_of(i)] = e`)
41//! where the index is converted at the point of use and only one
42//! space is live — there is nothing to mix up.
43//! * `algorithm_backsolver.rs:871` resolves through a checked
44//! `get_mut(..).ok_or_else(..)` and then sweeps for an unfilled NaN
45//! sentinel; it fails loudly twice over.
46//! * `solver.rs:1686` and the `full_x_to_var_x` accessors *produce*
47//! indices for a caller rather than indexing with them.
48//!
49//! # What this does and does not buy
50//!
51//! On the typed path the swap is not merely discouraged, it is
52//! **unrepresentable**: [`FullX`] has no public constructor, so the
53//! only way to obtain one is to put a [`VarX`] through [`VarToFull`],
54//! and `FullXSlice::at` accepts nothing else.
55//!
56//! It does **not** follow that the swap is gone. `ActivityReport`'s
57//! `Vec` fields are public and must stay so, and nothing stops a
58//! caller writing `report.var_status[row.get()]` — which compiles, and
59//! is caught by leg 3 rather than by the compiler. A newtype closes
60//! the path that goes through it; it cannot close one whose public API
61//! is a bare `Vec`.
62//!
63//! The guard that closes the known site is still
64//! `sens_invariance_legs.rs` leg 3, whose fixture puts a fixed variable
65//! *ahead* of the kink so the two spaces actually diverge. This module
66//! is what makes the *next* such site a compile error instead of a
67//! fixture someone has to think to write.
68//!
69//! # Mutation evidence
70//!
71//! What the type actually closes, measured by reintroducing the swap
72//! at each converted site rather than by argument:
73//!
74//! | mutation | outcome |
75//! |---|---|
76//! | mint a `FullX` from the var-x row at the kappa read | **compile error**, `E0624: associated function 'new' is private` |
77//! | the same at `weakly_active_bounds` | **compile error**, same |
78//! | bypass the type: `report.var_status.get(row.get())` | **compiles.** Caught at runtime instead, by `sens_invariance_legs.rs` leg 3 -- 3 legs go red (`leg_fixed_the_weak_set_...`, `leg_fixed_the_directional_derivative_...`, `the_legs_compose_at_the_fixed_and_scaled_corner`) |
79//!
80//! The third row is the honest limit and the reason leg 3 is not
81//! retired: the public `Vec` fields are still reachable, so the type
82//! covers the typed path and the leg fences the untyped one. Neither
83//! alone covers this site.
84//!
85//! The first draft of this module failed its own first mutation --
86//! `FullX::new` was `pub`, so `FullX::new(var_row.get())` typechecked
87//! and the swap was one short line away. That is why the constructor
88//! is private now, and why the table above exists at all: a newtype
89//! whose guarantee is not mutation-checked is a comment with a
90//! `struct` around it.
91
92use pounce_common::types::Index;
93
94/// A row in the algorithm's **free-variable** space: the primal block
95/// the solver actually optimizes, with `make_parameter`-removed
96/// variables absent.
97///
98/// This is the space of `BoundMultiplier::var_row`, `WeakBound::var_row`,
99/// and every array whose length is the primal block width.
100///
101/// It coincides with [`FullX`] until the first removed variable and
102/// diverges after it — which is why a corpus of fixtures with no fixed
103/// variables cannot see a swap, and why leg 3 puts one ahead of the
104/// kink on purpose.
105///
106/// ```
107/// use pounce_sensitivity::index::VarX;
108/// assert_eq!(VarX::new(3).get(), 3);
109/// ```
110#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
111pub struct VarX(usize);
112
113/// An index into the **model's** variable space, fixed variables
114/// included: the space of every `ActivityReport` per-variable array,
115/// and the length [`crate::solver::Solver::n_full_x`] reports.
116///
117/// A `FullX` is obtainable only by converting a [`VarX`] through
118/// [`VarToFull`] -- there is no public constructor, by design:
119///
120/// ```
121/// use pounce_sensitivity::index::{VarToFull, VarX};
122/// // full-x 0 is `make_parameter`-removed, so var-x k is full-x k+1
123/// let map = VarToFull::build(3, |v| v.get() + 1);
124/// assert_eq!(map.full_of(VarX::new(0)).unwrap().get(), 1);
125/// ```
126#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
127pub struct FullX(usize);
128
129impl VarX {
130 /// Name a var-x row.
131 #[must_use]
132 pub const fn new(row: usize) -> Self {
133 Self(row)
134 }
135
136 /// The raw row. Calling this is the explicit act of leaving the
137 /// typed path — at a direct array index, check which space the
138 /// array is in before you do.
139 #[must_use]
140 pub const fn get(self) -> usize {
141 self.0
142 }
143
144 /// The raw row as the FFI-facing [`Index`].
145 #[must_use]
146 pub const fn as_index(self) -> Index {
147 self.0 as Index
148 }
149}
150
151impl FullX {
152 /// Name a full-x index.
153 ///
154 /// **Deliberately private to this module.** A `FullX` is obtainable
155 /// only by converting a [`VarX`] through [`VarToFull`], so the
156 /// assertion "this number is in full-x" is made once, where the
157 /// map is built, instead of at every read. Making this `pub` is
158 /// what made the first draft weaker than its own documentation:
159 /// `FullX::new(var_row.get())` typechecked, so the swap the module
160 /// exists to prevent was one short line away.
161 #[must_use]
162 const fn new(idx: usize) -> Self {
163 Self(idx)
164 }
165
166 /// The raw index. See [`VarX::get`] on what calling it means.
167 #[must_use]
168 pub const fn get(self) -> usize {
169 self.0
170 }
171}
172
173/// The var-x → full-x map, materialized once so a loop does not
174/// re-borrow the NLP per row.
175///
176/// Built with [`VarToFull::build`] at the point of use, from the
177/// NLP's own `var_x_to_full_x`. Lookups are bounds-checked and return
178/// [`None`] rather than a neighbouring row's index — the whole point
179/// of the exercise.
180#[derive(Clone, Debug)]
181pub struct VarToFull {
182 full_of: Vec<FullX>,
183}
184
185impl VarToFull {
186 /// Build the map by converting every var-x row, `0..n_var_x`.
187 ///
188 /// `to_full` is the **one** place a raw index is asserted to be in
189 /// full-x. In the solver it is `nlp.var_x_to_full_x`; keeping it to
190 /// a single call site per map is the whole protection this type
191 /// offers, so do not add a second way to mint a [`FullX`].
192 #[must_use]
193 pub fn build(n_var_x: usize, mut to_full: impl FnMut(VarX) -> usize) -> Self {
194 Self {
195 full_of: (0..n_var_x)
196 .map(|r| FullX::new(to_full(VarX::new(r))))
197 .collect(),
198 }
199 }
200
201 /// The full-x index of a var-x row, or [`None`] if the row is
202 /// outside the primal block.
203 #[must_use]
204 pub fn full_of(&self, row: VarX) -> Option<FullX> {
205 self.full_of.get(row.get()).copied()
206 }
207
208 /// Width of the primal block.
209 #[must_use]
210 pub fn n_var_x(&self) -> usize {
211 self.full_of.len()
212 }
213
214 /// Every var-x row, in order — so a loop is typed from the start
215 /// rather than by converting a bare `usize` at the first use.
216 pub fn rows(&self) -> impl Iterator<Item = VarX> + '_ {
217 (0..self.full_of.len()).map(VarX::new)
218 }
219}
220
221/// A per-full-x slice that can only be read with a [`FullX`].
222///
223/// Used for the `ActivityReport` arrays at the two sites where a var-x
224/// row is live in the same scope. It borrows rather than owns, so it
225/// costs nothing and does not duplicate the report.
226#[derive(Clone, Copy, Debug)]
227pub struct FullXSlice<'a, T> {
228 inner: &'a [T],
229}
230
231impl<'a, T: Copy> FullXSlice<'a, T> {
232 /// Wrap a slice asserted to be in full-x order.
233 #[must_use]
234 pub fn new(inner: &'a [T]) -> Self {
235 Self { inner }
236 }
237
238 /// Read one entry, or [`None`] when the index is past the end.
239 #[must_use]
240 pub fn at(&self, idx: FullX) -> Option<T> {
241 self.inner.get(idx.get()).copied()
242 }
243
244 /// Length, in full-x entries.
245 #[must_use]
246 pub fn len(&self) -> usize {
247 self.inner.len()
248 }
249
250 /// Whether the slice is empty.
251 #[must_use]
252 pub fn is_empty(&self) -> bool {
253 self.inner.is_empty()
254 }
255}
256
257/// The two spaces do not convert implicitly, in either direction.
258///
259/// These are the evidence that the newtype does the one job it exists
260/// for. Each `compile_fail` is paired with the passing twin directly
261/// below it, because a `compile_fail` that fails for the *wrong*
262/// reason — a typo, an unresolved import — is a test that passes
263/// vacuously, which is the failure mode this crate cares most about.
264///
265/// A var-x row cannot be used where a full-x index is expected:
266///
267/// ```compile_fail
268/// use pounce_sensitivity::index::{FullX, VarX};
269/// fn full_only(_: FullX) {}
270/// full_only(VarX::new(0));
271/// ```
272///
273/// and the same call typechecks with the right space, so the failure
274/// above is about the types and not about the spelling:
275///
276/// ```
277/// use pounce_sensitivity::index::{FullX, VarToFull, VarX};
278/// fn full_only(_: FullX) {}
279/// let map = VarToFull::build(1, |v| v.get());
280/// full_only(map.full_of(VarX::new(0)).unwrap());
281/// ```
282///
283/// And a `FullX` cannot be minted from a raw row at all, which is what
284/// keeps the guarantee above from being one short line wide:
285///
286/// ```compile_fail
287/// use pounce_sensitivity::index::{FullX, VarX};
288/// let _ = FullX::new(VarX::new(0).get());
289/// ```
290///
291/// A full-x index cannot be used where a var-x row is expected:
292///
293/// ```compile_fail
294/// use pounce_sensitivity::index::{VarToFull, VarX};
295/// fn var_only(_: VarX) {}
296/// let map = VarToFull::build(1, |v| v.get());
297/// var_only(map.full_of(VarX::new(0)).unwrap());
298/// ```
299///
300/// ```
301/// use pounce_sensitivity::index::{VarToFull, VarX};
302/// fn var_only(_: VarX) {}
303/// let map = VarToFull::build(1, |v| v.get());
304/// var_only(VarX::new(0));
305/// let _ = map.full_of(VarX::new(0)).unwrap();
306/// ```
307///
308/// A full-x slice cannot be read with a var-x row — this is gh#672
309/// finding 1's exact shape, `report.var_status.get(var_row)`, as a
310/// type error:
311///
312/// ```compile_fail
313/// use pounce_sensitivity::index::{FullXSlice, VarX};
314/// let status = [0i8, 1, 2];
315/// let full = FullXSlice::new(&status);
316/// full.at(VarX::new(1));
317/// ```
318///
319/// ```
320/// use pounce_sensitivity::index::{FullXSlice, VarToFull, VarX};
321/// let status = [0i8, 1, 2];
322/// let full = FullXSlice::new(&status);
323/// let map = VarToFull::build(2, |v| v.get() + 1);
324/// assert_eq!(full.at(map.full_of(VarX::new(0)).unwrap()), Some(1));
325/// assert_eq!(full.at(map.full_of(VarX::new(1)).unwrap()), Some(2));
326/// ```
327///
328/// And a raw `usize` reaches neither, so an untyped index cannot drift
329/// in from a caller:
330///
331/// ```compile_fail
332/// use pounce_sensitivity::index::FullXSlice;
333/// let status = [0i8, 1, 2];
334/// FullXSlice::new(&status).at(1usize);
335/// ```
336#[cfg(doctest)]
337pub struct IndexSpacesDoNotConvert;
338
339/// A miss returns `None`, never a neighbour.
340///
341/// The defect this module exists for is not an out-of-bounds panic —
342/// it is an in-range read of the wrong row. The bounds check below is
343/// the cheap half; the type is the half that matters.
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use pounce_common::types::Number;
348
349 #[test]
350 fn a_lookup_past_the_end_is_none_not_a_neighbour() {
351 let map = VarToFull::build(2, |v| [0usize, 2][v.get()]);
352 assert_eq!(map.full_of(VarX::new(0)), Some(FullX::new(0)));
353 assert_eq!(map.full_of(VarX::new(1)), Some(FullX::new(2)));
354 assert_eq!(map.full_of(VarX::new(2)), None);
355 assert_eq!(map.n_var_x(), 2);
356 }
357
358 /// The map is where the divergence lives: with a fixed variable
359 /// ahead of the rows, var-x `k` is full-x `k + 1`, so reading one
360 /// as the other returns a neighbour. This is leg 3's fixture shape
361 /// in miniature.
362 #[test]
363 fn a_fixed_variable_ahead_makes_the_spaces_diverge() {
364 // full-x 0 is `make_parameter`-removed, so var-x 0,1,2 are
365 // full-x 1,2,3.
366 let map = VarToFull::build(3, |v| v.get() + 1);
367 for row in map.rows() {
368 let full = map.full_of(row).expect("in range");
369 assert_eq!(
370 full.get(),
371 row.get() + 1,
372 "the spaces must diverge by the fixed variable"
373 );
374 }
375
376 // and the untyped read that gh#672 finding 1 shipped would have
377 // returned the neighbour rather than failing
378 let sigma = [10.0, 20.0, 30.0, 40.0];
379 let slice = FullXSlice::new(&sigma);
380 let row = VarX::new(1);
381 let correct = slice.at(map.full_of(row).unwrap());
382 let untyped_swap = slice.at(FullX::new(row.get()));
383 assert_eq!(correct, Some(30.0));
384 assert_eq!(untyped_swap, Some(20.0));
385 assert_ne!(
386 correct, untyped_swap,
387 "if these agree the fixture has no fixed variable and proves nothing",
388 );
389 }
390
391 #[test]
392 fn rows_are_typed_from_the_start() {
393 let map = VarToFull::build(2, |v| v.get() + 1);
394 let rows: Vec<VarX> = map.rows().collect();
395 assert_eq!(rows, vec![VarX::new(0), VarX::new(1)]);
396 }
397
398 #[test]
399 fn a_full_x_slice_reports_its_own_length() {
400 let v = [1.0, 2.0, 3.0];
401 let s = FullXSlice::new(&v);
402 assert_eq!(s.len(), 3);
403 assert!(!s.is_empty());
404 let empty: [Number; 0] = [];
405 assert!(FullXSlice::new(&empty).is_empty());
406 }
407}