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}