Skip to main content

panproto_mig/solve/
mod.rs

1//! The schema morphism search, posed as a cost function network.
2//!
3//! Finding a schema morphism is a valued constraint satisfaction problem: one
4//! variable per source vertex, one value per kind-compatible target vertex plus
5//! a distinguished `⊥` meaning "this vertex is not in the apex", hard
6//! `⊤`-valued cost functions for kind compatibility and naturality, and soft
7//! cost functions for the quality of a match. Minimising the total cost over
8//! that network is the search.
9//!
10//! `⊥` is what makes the result a span rather than a total morphism. The apex
11//! is `{ v : x_v ≠ ⊥ }` with the induced edge set, so a partial match is a
12//! first-class answer rather than a failure, and the all-`⊥` assignment is
13//! always feasible, so the search never refuses.
14//!
15//! This module owns the public contract of that search: the budget a caller
16//! sets, the outcome it gets back, and the identifiers the outcome is phrased
17//! in. [`cost`] owns the algebra the objective is measured in.
18//!
19//! # The anytime contract
20//!
21//! [`SolveOutcome`] carries a certified lower bound alongside the incumbent, so
22//! an interrupted search returns a solution *and* a proof that nothing better
23//! than `lower_bound` exists. The guarantees, holding at every observation
24//! point:
25//!
26//! 1. `lower_bound ⪯ optimum ⪯ upper_bound`.
27//! 2. `lower_bound` is monotone non-decreasing and `upper_bound` is monotone
28//!    non-increasing.
29//! 3. On termination with no limit hit, `proven_optimal` is true and `best` is
30//!    a true argmin.
31//! 4. `best`, when present, is a real assignment: evaluating it against a
32//!    pristine network reproduces `upper_bound` exactly.
33//! 5. Exact inference always reports `proven_optimal`, whatever the budget,
34//!    because it never prunes and so never consults one.
35//! 6. Identical inputs produce identical `best`, `nodes`, and the whole bound
36//!    trace.
37
38pub mod build;
39pub mod cfn;
40pub mod consistency;
41pub mod cost;
42pub mod dfbb;
43pub mod dispatch;
44pub mod elim;
45pub mod hbfs;
46pub mod mcsplit;
47pub mod oracle;
48pub mod order;
49
50pub use cfn::{Cfn, CfnBuilder, CfnError, CostFunction, Domain, DomainIter, Domains, Variable};
51pub use consistency::{ConsistencyLevel, Network};
52pub use cost::{
53    COST_SCALE, Cost, CostWeights, CostWeightsError, DEFAULT_WEIGHTS, DROP_UNIT,
54    MAX_COVERAGE_RADIX, coverage_radix, quality_units,
55};
56pub use dfbb::{SearchParameters, solve_dfbb};
57pub use dispatch::{DispatchPlan, dispatch_plan, solve, solve_epic, solve_monic};
58pub use elim::{
59    Buckets, COUNT_CEILING, EnumerationTrace, ProductVerdict, all_optima, all_optima_traced,
60    count_solutions, decode, detect_product, eliminate,
61};
62pub use hbfs::{BoundObservation, HbfsOutcome, HbfsParameters, solve_hbfs};
63pub use mcsplit::{
64    ArcDescriptor, HallOutcome, IsoError, TargetId, ValueIndex, arc_descriptor, epic_satisfied,
65    propagate_all_different, solve_iso,
66};
67pub use order::{
68    EliminationCost, Graph, bucket_costs, choose_order, elimination_cost, fits_budget,
69    induced_width, min_fill_order, primal_graph, reverse_source_id_order,
70};
71
72/// A variable of the network, one per source vertex.
73///
74/// Variables are numbered densely from zero in ascending source vertex name
75/// order, so the numbering is a function of the source schema alone and two
76/// runs over the same schema agree on it.
77#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
78pub struct VarId(u32);
79
80impl VarId {
81    /// The variable with this index.
82    #[inline]
83    #[must_use]
84    pub const fn new(index: u32) -> Self {
85        Self(index)
86    }
87
88    /// The index as it is stored.
89    #[inline]
90    #[must_use]
91    pub const fn raw(self) -> u32 {
92        self.0
93    }
94
95    /// The index, for use as a slice offset.
96    #[inline]
97    #[must_use]
98    pub const fn index(self) -> usize {
99        self.0 as usize
100    }
101}
102
103/// A value in a variable's domain.
104///
105/// [`ValId::BOTTOM`] is slot zero and the real target vertices follow it, so
106/// value `i + 1` is the `i`th target vertex in ascending name order. Nothing
107/// bounds how many follow: the numbering is a `u32` and a domain is as many
108/// bitset words as the network needs, so the type carries no capacity at all.
109///
110/// # The domain order is not the numeric order
111///
112/// The search's tie-break is "the lexicographically smallest assignment among
113/// the argmins", read against an order that puts a real image before a dropped
114/// one and orders real images by target vertex name. `⊥` at slot zero is
115/// numerically first and has to sort **last**, so [`Ord`] is written by hand
116/// over [`Self::order_key`] rather than derived. Every comparison of two values
117/// therefore reports the domain order, and so does every domain walk, which
118/// [`DomainIter`] states once.
119#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
120pub struct ValId(u32);
121
122impl ValId {
123    /// `⊥`, meaning the source vertex is left out of the apex.
124    ///
125    /// Slot zero of every domain, so "this variable may still be dropped" is
126    /// the low bit of its first word, and the always-feasible all-`⊥`
127    /// assignment is visible in the representation rather than maintained
128    /// beside it.
129    pub const BOTTOM: Self = Self(0);
130
131    /// The value standing for the target vertex at this index.
132    ///
133    /// Real values start one past `⊥`, so this cannot alias it. The only thing
134    /// it can fail on is an index no `u32` can hold one past, which no schema
135    /// reaches and which would silently wrap.
136    ///
137    /// # Panics
138    ///
139    /// If `index` is `u32::MAX`, which leaves no slot to shift it into.
140    #[inline]
141    #[must_use]
142    pub const fn real(index: u32) -> Self {
143        assert!(
144            index < u32::MAX,
145            "a real value index must leave room for the bottom slot"
146        );
147        Self(index + 1)
148    }
149
150    /// The value at this domain slot, `⊥` included.
151    ///
152    /// The total counterpart of [`Self::real`]: slot zero is [`Self::BOTTOM`]
153    /// rather than a contract violation. It is what a bitset domain needs, since
154    /// a set bit carries no record of which of the two constructors put it
155    /// there.
156    #[inline]
157    #[must_use]
158    pub const fn from_index(index: u32) -> Self {
159        Self(index)
160    }
161
162    /// The slot as it is stored, which is the bit a domain sets for it.
163    #[inline]
164    #[must_use]
165    pub const fn raw(self) -> u32 {
166        self.0
167    }
168
169    /// The real target vertex index, for use as a slice offset.
170    ///
171    /// `⊥` is not a target vertex, so it reads as an index no value list holds
172    /// rather than as a target: a caller that forgets to test
173    /// [`Self::is_bottom`] gets `None` out of the lookup instead of the last
174    /// vertex.
175    #[inline]
176    #[must_use]
177    pub const fn index(self) -> usize {
178        (self.0 as usize).wrapping_sub(1)
179    }
180
181    /// The sort key of the domain order: reals ascending, then `⊥`.
182    ///
183    /// `⊥` is stored first and sorts last, so the key rotates the numbering by
184    /// one. This is the one place that rotation is written, and [`Ord`],
185    /// [`PartialOrd`] and [`DomainIter`] all agree with it.
186    #[inline]
187    #[must_use]
188    pub const fn order_key(self) -> u32 {
189        self.0.wrapping_sub(1)
190    }
191
192    /// Whether this is `⊥`.
193    #[inline]
194    #[must_use]
195    pub const fn is_bottom(self) -> bool {
196        self.0 == Self::BOTTOM.0
197    }
198}
199
200impl Ord for ValId {
201    /// The domain order, which is **not** the order of the stored slots.
202    ///
203    /// Comparing two argmins position by position has to prefer a real image to
204    /// a dropped vertex and the alphabetically earlier target among real
205    /// images. `⊥` is stored at slot zero, so that order is
206    /// [`ValId::order_key`]'s and not the numbering's.
207    #[inline]
208    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
209        self.order_key().cmp(&other.order_key())
210    }
211}
212
213impl PartialOrd for ValId {
214    #[inline]
215    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
216        Some(self.cmp(other))
217    }
218}
219
220/// A total assignment of one value to every variable.
221///
222/// Indexed by [`VarId`], so its length is the number of source vertices the
223/// network was built over.
224#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
225pub struct Assignment(Vec<ValId>);
226
227impl Assignment {
228    /// The assignment leaving every source vertex out of the apex.
229    ///
230    /// Always feasible: every binary constraint is vacuous when both its
231    /// endpoints are `⊥`, and the apex well-formedness constraints are vacuous
232    /// when the vertex they are conditioned on is `⊥`.
233    #[inline]
234    #[must_use]
235    pub fn all_bottom(variables: usize) -> Self {
236        Self(vec![ValId::BOTTOM; variables])
237    }
238
239    /// Wrap a value per variable, in variable order.
240    #[inline]
241    #[must_use]
242    pub const fn from_values(values: Vec<ValId>) -> Self {
243        Self(values)
244    }
245
246    /// The number of variables.
247    #[inline]
248    #[must_use]
249    pub fn len(&self) -> usize {
250        self.0.len()
251    }
252
253    /// Whether there are no variables at all.
254    #[inline]
255    #[must_use]
256    pub fn is_empty(&self) -> bool {
257        self.0.is_empty()
258    }
259
260    /// The value of one variable, or `None` if it is out of range.
261    #[inline]
262    #[must_use]
263    pub fn get(&self, var: VarId) -> Option<ValId> {
264        self.0.get(var.index()).copied()
265    }
266
267    /// Assign one variable.
268    ///
269    /// # Panics
270    ///
271    /// If `var` is out of range for this assignment.
272    #[inline]
273    pub fn set(&mut self, var: VarId, value: ValId) {
274        self.0[var.index()] = value;
275    }
276
277    /// Every value, in variable order.
278    #[inline]
279    #[must_use]
280    pub fn values(&self) -> &[ValId] {
281        &self.0
282    }
283
284    /// Every variable paired with its value.
285    #[inline]
286    pub fn pairs(&self) -> impl Iterator<Item = (VarId, ValId)> + '_ {
287        (0u32..)
288            .zip(self.0.iter().copied())
289            .map(|(index, value)| (VarId::new(index), value))
290    }
291
292    /// The number of source vertices left out of the apex.
293    ///
294    /// This is the drop count of the packed cost encoding, so it is the
295    /// secondary component of the objective.
296    #[inline]
297    #[must_use]
298    pub fn dropped(&self) -> usize {
299        self.0.iter().filter(|value| value.is_bottom()).count()
300    }
301}
302
303/// Which algorithm a component of the network was routed to.
304///
305/// The four paths are exhaustive. Injectivity is not a property of a network,
306/// so the two injective paths are chosen by which entry point the caller calls;
307/// everything else goes through [`solve`], which routes on the
308/// induced width against the budget.
309#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
310pub enum SolverPath {
311    /// Exact bucket elimination in the `(min, ⊕)` semiring.
312    ///
313    /// Chosen when the width is small enough that the message tables fit the
314    /// budget. It never prunes, never consults a node budget, and always
315    /// proves optimality.
316    Eliminate {
317        /// The induced width of the elimination order actually used.
318        width: usize,
319    },
320
321    /// Depth-first branch and bound with soft local consistency maintained at
322    /// every node.
323    ///
324    /// The fallback when elimination would not fit. It carries a node budget
325    /// and can be interrupted, which is what the anytime contract is for.
326    BranchAndBound {
327        /// The induced width of the elimination order actually used, which
328        /// drives the variable ordering rather than an allocation.
329        width: usize,
330    },
331
332    /// The injective path: branch and bound with an all-different constraint.
333    ///
334    /// Injectivity completes the primal graph, so elimination is out by
335    /// construction rather than by budget. Reported by
336    /// [`solve_monic`].
337    Monic,
338
339    /// The maximum common induced sub-schema path.
340    ///
341    /// Injective and edge-reflecting, which is a strictly stronger demand than
342    /// [`Self::Monic`] and a different algorithm. Reported by
343    /// [`solve_iso`].
344    Iso,
345}
346
347/// What stopped a search before it proved optimality.
348#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
349#[non_exhaustive]
350pub enum LimitKind {
351    /// The node budget was exhausted.
352    Nodes,
353    /// The wall-clock budget was exhausted.
354    ///
355    /// A caller that sets one accepts a non-deterministic result, which is why
356    /// there is no default wall-clock budget and why hitting one is reported
357    /// rather than silently folded into the answer.
358    Time,
359    /// [`SearchBudget::op_budget`] was exhausted.
360    ///
361    /// The ceiling that bounds a search's *work* rather than its shape. A node
362    /// is not a unit of work, since filtering one node of a large network costs
363    /// what filtering a small network whole does, so this is the limit that
364    /// makes the time a search takes a function of the budget it was given.
365    /// Deterministic: the count is of elementary operations the filtering
366    /// performed, which is a property of the input and not of the machine.
367    Operations,
368}
369
370/// Something a caller should know about how a search was run.
371///
372/// A warning never means the answer is wrong. It means the search took a route
373/// the caller might not have expected, and each one is observable so that the
374/// question of how often it happens can be answered from data.
375#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
376#[non_exhaustive]
377pub enum SearchWarning {
378    /// Exact inference would not have fit the budget, so the component was
379    /// routed to branch and bound instead.
380    EliminationOutOfBudget {
381        /// The induced width that produced the estimate.
382        width: usize,
383        /// Message table entries exact inference would have allocated.
384        entries: u64,
385        /// Combine operations exact inference would have performed.
386        operations: u64,
387    },
388
389    /// Constraints with no corresponding schema edge raised the induced width.
390    ///
391    /// Recursion points, schema spans and hyper-edge signature cliques
392    /// constrain vertex pairs that need not be joined by an edge, so they can
393    /// add primal graph edges that a measurement over schema edges alone would
394    /// not see. The width is recomputed after they are added and the dispatcher
395    /// reads the recomputed number.
396    WidthRaisedByNonEdgeConstraints {
397        /// The width of the primal graph of the schema's own edges.
398        schema_width: usize,
399        /// The width after the constraints were added.
400        actual_width: usize,
401    },
402
403    /// Surjectivity was requested where it cannot hold.
404    ///
405    /// The vertex map can only cover the target when the source has at least as
406    /// many vertices, so this reports a request that no assignment satisfies.
407    EpicUnsatisfiable {
408        /// Vertices in the source schema.
409        source_vertices: usize,
410        /// Vertices in the target schema.
411        target_vertices: usize,
412    },
413}
414
415/// The memory a search may allocate for exact inference, in bytes.
416///
417/// **calibration:** none. This is an engineering ceiling, not a calibrated
418/// value, and it is not a parameter of the objective: no assignment's cost
419/// depends on it, and exceeding it changes which algorithm runs rather than
420/// which answer is optimal. It is set to a working set an ordinary developer
421/// machine can hold without paging, and the honest reason for the exact figure
422/// is that it is a round number in that range. Do not tune it against
423/// `crates/panproto-lens/tests/autolens_corpus.rs`: that corpus is synthetic
424/// and its expectations were themselves derived from engine behaviour, so
425/// fitting to it is circular. Tune it against the memory the deployment has.
426pub const DEFAULT_MEM_BYTES: usize = 64 * 1024 * 1024;
427
428/// The number of elementary operations one solve may perform.
429///
430/// It bounds both paths, in one currency: exact inference is refused when
431/// [`order::elimination_cost`] exceeds it, and the search
432/// that then runs stops when its filtering has spent it. So a caller sets what
433/// the answer may cost rather than what one algorithm may cost, and the
434/// question "which algorithm ran" does not change the ceiling.
435///
436/// **calibration:** none. This is an engineering ceiling, not a calibrated
437/// value, chosen as the work a single search may do, and no assignment's cost
438/// depends on it. Do not tune it against
439/// `crates/panproto-lens/tests/autolens_corpus.rs`: that corpus is synthetic
440/// and its expectations were themselves derived from engine behaviour, so
441/// fitting to it is circular.
442pub const DEFAULT_OP_BUDGET: u64 = 1_000_000_000;
443
444/// The node budget applied when a component is routed to a search path.
445///
446/// Exact inference ignores it: it never prunes, so it has no nodes to count.
447///
448/// It bounds nodes, which is not the same as bounding work. A node costs what
449/// the bound and the refinement cost at that node, and neither is constant: on
450/// the maximum common sub-schema path this ceiling takes about fifteen seconds
451/// to reach on a nine-vertex pair whose source carries dense annotation maps,
452/// while the same pair answers in milliseconds on the other two routes. A
453/// caller that needs a wall-clock ceiling sets [`SearchBudget::max_millis`],
454/// and takes on the consequence that doing so makes the answer a function of
455/// the machine.
456///
457/// **calibration:** none. This is an engineering ceiling, not a calibrated
458/// value, and exhausting it is reported through
459/// [`SolveOutcome::limit_hit`] rather than absorbed, so it bounds effort
460/// without silently changing the answer. Do not tune it against
461/// `crates/panproto-lens/tests/autolens_corpus.rs`: that corpus is synthetic
462/// and its expectations were themselves derived from engine behaviour, so
463/// fitting to it is circular.
464pub const DEFAULT_SEARCH_NODES: u64 = 10_000_000;
465
466/// What a search may spend.
467#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
468#[non_exhaustive]
469pub struct SearchBudget {
470    /// Nodes a search path may open, or `None` for no node limit.
471    ///
472    /// `None` becomes [`DEFAULT_SEARCH_NODES`] on the paths that count nodes.
473    pub max_nodes: Option<u64>,
474
475    /// Milliseconds of wall clock, or `None` for no time limit.
476    ///
477    /// There is no default. A time limit makes the result depend on the machine
478    /// it ran on, so it is opt-in, and when one is hit the outcome says so.
479    pub max_millis: Option<u64>,
480
481    /// Bytes of table a solve may allocate, read at four sites, two of which
482    /// refuse and two of which fall back.
483    ///
484    /// It refuses at
485    /// [`CfnBuilder::with_mem_bytes`](crate::solve::CfnBuilder::with_mem_bytes),
486    /// which [`build_cfn`](crate::solve::build::build_cfn) poses every network
487    /// through: the figure bounds the *cost* tables, it is checked before
488    /// anything is allocated, and a pair whose tables do not fit comes back as
489    /// [`BuildError::Network`](crate::solve::build::BuildError::Network),
490    /// surfacing as [`SpanError::Build`](crate::SpanError::Build). It refuses
491    /// again on the iso path, where `mcsplit` sizes its dense frames against the
492    /// same figure and reports
493    /// [`IsoError::OverMemoryBudget`].
494    /// Neither refusal has a slower answer behind it, because every search
495    /// entry point takes an already-built `&Cfn`: a network that cannot be held
496    /// cannot be searched either.
497    ///
498    /// It falls back at [`EliminationCost::fits`],
499    /// which bounds the *message* tables bucket elimination would build and
500    /// routes the solve to branch and bound instead, contributing
501    /// [`SearchWarning::EliminationOutOfBudget`]. And it is re-posed, rather
502    /// than read afresh, by `dispatch`'s component decomposition and by
503    /// [`without_bottom`](crate::without_bottom), which rebuild parts of a
504    /// network the same figure already accepted; there the fallback is
505    /// unreachable by construction.
506    ///
507    /// The ordering is what a caller lowering this knob has to know: the build
508    /// ceiling binds first, so a figure below what the pair's cost tables need
509    /// is a refusal and never a slower answer. [`DEFAULT_MEM_BYTES`] is 64 MiB
510    /// and the measured schema corpus needs a few KiB, so on that corpus every
511    /// setting below the build floor refuses and none falls back. The fallback
512    /// is reachable on wide networks, where the message tables outgrow the cost
513    /// tables: an eight-variable clique of width seven routes to branch and
514    /// bound at every ceiling from 32 KiB to 16 MiB with `op_budget` untouched.
515    pub mem_bytes: usize,
516
517    /// Elementary operations the solve may perform, whichever path it takes.
518    ///
519    /// Exact inference is priced against it in advance and refused when it
520    /// would exceed it; the search that then runs is charged against it as it
521    /// goes and stops on [`LimitKind::Operations`] when it has spent it. The
522    /// fallback can therefore not cost more than the exact inference it
523    /// replaced, which is what keeps a refusal from turning into a wait with no
524    /// end in sight.
525    pub op_budget: u64,
526}
527
528impl Default for SearchBudget {
529    fn default() -> Self {
530        Self {
531            max_nodes: None,
532            max_millis: None,
533            mem_bytes: DEFAULT_MEM_BYTES,
534            op_budget: DEFAULT_OP_BUDGET,
535        }
536    }
537}
538
539impl SearchBudget {
540    /// Set the node budget.
541    #[inline]
542    #[must_use]
543    pub const fn with_max_nodes(mut self, max_nodes: Option<u64>) -> Self {
544        self.max_nodes = max_nodes;
545        self
546    }
547
548    /// Set the wall-clock budget.
549    #[inline]
550    #[must_use]
551    pub const fn with_max_millis(mut self, max_millis: Option<u64>) -> Self {
552        self.max_millis = max_millis;
553        self
554    }
555
556    /// Set the memory ceiling for exact inference.
557    #[inline]
558    #[must_use]
559    pub const fn with_mem_bytes(mut self, mem_bytes: usize) -> Self {
560        self.mem_bytes = mem_bytes;
561        self
562    }
563
564    /// Set the operation ceiling for exact inference.
565    #[inline]
566    #[must_use]
567    pub const fn with_op_budget(mut self, op_budget: u64) -> Self {
568        self.op_budget = op_budget;
569        self
570    }
571}
572
573/// What a search found, and what it can prove about it.
574///
575/// The module docs state the six guarantees this type carries.
576#[derive(Clone, Debug, PartialEq, Eq)]
577#[non_exhaustive]
578pub struct SolveOutcome {
579    /// The best assignment found. `None` only if no feasible assignment was
580    /// reached.
581    pub best: Option<Assignment>,
582
583    /// Certified: `lower_bound ⪯ optimum` at every observation point.
584    pub lower_bound: Cost,
585
586    /// The cost of `best`, or [`Cost::TOP_SENTINEL`] if there is no `best`.
587    pub upper_bound: Cost,
588
589    /// Whether the two bounds met, which is the proof of optimality.
590    pub proven_optimal: bool,
591
592    /// Which algorithm produced this.
593    pub path: SolverPath,
594
595    /// The elimination order actually used, when one was.
596    ///
597    /// The tie-break among equally good assignments is relative to this order,
598    /// so it is reported rather than assumed.
599    pub elimination_order: Option<Vec<VarId>>,
600
601    /// Nodes opened. Zero on exact inference, which opens none.
602    pub nodes: u64,
603
604    /// What stopped the search, if anything did.
605    pub limit_hit: Option<LimitKind>,
606
607    /// Anything a caller should know about the route taken.
608    pub warnings: Vec<SearchWarning>,
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn the_bottom_slot_is_the_first_index() {
617        assert_eq!(ValId::BOTTOM, ValId::from_index(0));
618        assert!(ValId::from_index(0).is_bottom());
619        assert!(!ValId::real(0).is_bottom());
620        assert_eq!(ValId::real(0).raw(), 1);
621        assert_eq!(ValId::real(7).index(), 7);
622    }
623
624    #[test]
625    fn bottom_sorts_after_every_real_value() {
626        // The whole canonical tie-break rests on this, and it is no longer a
627        // consequence of the numbering: `⊥` is stored first and must compare
628        // last, however many real values there are.
629        assert!(ValId::real(0) < ValId::BOTTOM);
630        assert!(ValId::real(u32::MAX - 2) < ValId::BOTTOM);
631        assert!(ValId::real(0) < ValId::real(1));
632
633        let mut values = vec![ValId::BOTTOM, ValId::real(2), ValId::real(0)];
634        values.sort_unstable();
635        assert_eq!(values, vec![ValId::real(0), ValId::real(2), ValId::BOTTOM]);
636    }
637
638    #[test]
639    #[should_panic(expected = "a real value index must leave room for the bottom slot")]
640    fn a_real_value_index_cannot_wrap_onto_the_bottom_slot() {
641        // The check must hold in a release build too: `real` wrapping to `⊥`
642        // would hand a caller the drop value under the name of a target vertex,
643        // with nothing to notice it by.
644        let index = std::hint::black_box(u32::MAX);
645        let _ = ValId::real(index);
646    }
647}