samkhya_core/degree.rs
1//! Provable join ceilings derived from degree statistics.
2//!
3//! # Why this module exists
4//!
5//! A ceiling is only useful if it is *sound*: for every database instance
6//! consistent with the statistics it was handed, the ceiling must be at
7//! least the true output cardinality. Otherwise a correction layer clamped
8//! to it can publish an estimate below the truth, which is exactly the
9//! regression the envelope exists to prevent.
10//!
11//! The bounds in [`crate::lpbound`] that predate this module accept only
12//! *row counts* plus a list of joined relation pairs. That input is not
13//! enough to beat the Cartesian product:
14//!
15//! > **Fact.** Given only per-relation row counts and which pairs of
16//! > relations are joined by equality, the worst-case output cardinality is
17//! > the full product of the row counts — put every row of every relation
18//! > on one single join-key value and the equi-join degenerates to a cross
19//! > product. Any ceiling below that product is therefore unsound.
20//!
21//! To do better you need one more statistic per relation and join
22//! attribute: an upper bound on the **degree** — how many rows can share a
23//! single value of that attribute.
24//!
25//! # The bound
26//!
27//! **Theorem (spanning-tree degree ceiling).** Let `Q` be an equi-join over
28//! relations `R_1 … R_n` whose join graph is `G`, and let `T` be any
29//! spanning tree of a connected component of `G`, rooted at `r`. Then
30//!
31//! ```text
32//! |Q| ≤ |R_r| · Π maxdeg(R_v, a_uv)
33//! (u→v) ∈ T, v ≠ r
34//! ```
35//!
36//! where `maxdeg(R, a)` is the largest number of rows of `R` sharing one
37//! value of attribute `a`.
38//!
39//! *Proof.* Materialise the relations in BFS order from `r`. The partial
40//! result starts at `|R_r|` tuples. Joining child `v` to its parent `u` on
41//! attribute `a`: every partial tuple already fixes a value of `a`
42//! (inherited from `u`), and at most `maxdeg(R_v, a)` rows of `R_v` carry
43//! any single value, so the count multiplies by at most that factor. Join
44//! edges outside `T` only filter and can never add tuples. ∎
45//!
46//! The bound is sound for **bag** semantics — duplicate rows included —
47//! which is what SQL engines actually execute.
48//!
49//! # Where the degrees come from
50//!
51//! Any *over*-estimate of the true maximum degree keeps the ceiling sound.
52//! Three sources, cheapest first:
53//!
54//! 1. **Row count.** `maxdeg ≤ rows`, always. With nothing else the ceiling
55//! degrades to the Cartesian product — sound, useless, never wrong.
56//! 2. **Distinct count:** `maxdeg ≤ rows − distinct + 1`. Spend one value on
57//! each distinct key and pile every remaining row onto one of them. Exact
58//! for a key column (`distinct == rows` ⇒ `maxdeg ≤ 1`), which is why the
59//! foreign-key joins that dominate analytical workloads bound tightly from
60//! statistics samkhya already carries.
61//!
62//! The count must be a *lower* bound on the truth, because the arithmetic
63//! subtracts it. An HLL point estimate is two-sided and will not do; use
64//! [`AttributeDegree::from_hll_floor`], which takes a distinct-count floor.
65//! 3. **Frequency sketch.** A Count-Min sketch never *under*-estimates a
66//! frequency, so its largest counter bounds every key's degree at once —
67//! a far tighter bound than (2) under skew, and derivable without
68//! knowing which key is the hot one. The guarantee holds as long as no
69//! counter has saturated, which the constructor checks. See
70//! [`AttributeDegree::from_count_min`].
71//!
72//! (3) is what makes the ceiling *portable*: the sketch already rides in
73//! the Puffin sidecar, so a bound proved from statistics written by one
74//! engine holds in another, with no shared catalog and no re-scan.
75//!
76//! # Example
77//!
78//! A textbook foreign-key join: 10 orders, 100 line items, 10 distinct
79//! order keys on both sides. The true output is 100 rows.
80//!
81//! ```
82//! use samkhya_core::degree::{AttributeDegree, JoinGraph, JoinRelation};
83//!
84//! const ORDER_KEY: u32 = 0;
85//!
86//! let orders = JoinRelation::new(10)
87//! .with_degree(ORDER_KEY, AttributeDegree::from_distinct(10, 10));
88//! let lineitem = JoinRelation::new(100)
89//! .with_degree(ORDER_KEY, AttributeDegree::from_distinct(100, 10));
90//!
91//! let graph = JoinGraph::new(vec![orders, lineitem])
92//! .with_edge(0, 1, ORDER_KEY);
93//!
94//! // Exactly the true cardinality — and provable, not estimated.
95//! assert_eq!(graph.ceiling(), 100);
96//! ```
97
98use std::collections::BTreeMap;
99
100use crate::lpbound::ProductBound;
101use crate::lpbound::UpperBound;
102
103/// Identifier for an equi-join attribute.
104///
105/// Values are opaque and caller-assigned: two relations share an attribute
106/// exactly when the caller gives them the same `AttributeId`. Adapters
107/// typically derive these from column ordinals, Iceberg field IDs, or a
108/// resolved join-key interner.
109pub type AttributeId = u32;
110
111/// Upper bound on how many rows of one relation can share a single value
112/// of one join attribute.
113///
114/// # Soundness obligation
115///
116/// [`max_degree`](Self::max_degree) must be **at least** the true maximum
117/// degree. Every constructor in this type either derives that guarantee or
118/// documents it as the caller's obligation. Supplying an under-estimate
119/// silently makes the resulting ceiling unsound, which defeats the entire
120/// point of the envelope.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct AttributeDegree {
123 max_degree: u64,
124}
125
126impl AttributeDegree {
127 /// The weakest sound degree: every row could share one value.
128 ///
129 /// Always correct, never informative — a graph built entirely from
130 /// these yields the Cartesian product.
131 ///
132 /// # Examples
133 ///
134 /// ```
135 /// use samkhya_core::degree::AttributeDegree;
136 ///
137 /// assert_eq!(AttributeDegree::unknown(500).max_degree(), 500);
138 /// ```
139 pub const fn unknown(rows: u64) -> Self {
140 Self { max_degree: rows }
141 }
142
143 /// Derive a degree bound from a row count and a distinct-value count:
144 /// `maxdeg ≤ rows − distinct + 1`.
145 ///
146 /// Assign one row to each of the `distinct` values, then pile every
147 /// remaining row onto a single value. Nothing can beat that
148 /// concentration.
149 ///
150 /// # Soundness obligation
151 ///
152 /// `distinct` must be a **lower** bound on the true number of distinct
153 /// values. The arithmetic subtracts it, so an over-stated distinct
154 /// count under-states the degree and yields a ceiling *below* the
155 /// truth — the exact failure this module exists to prevent.
156 ///
157 /// This matters because the obvious source is the wrong one:
158 /// [`HllSketch::estimate`](crate::sketches::HllSketch::estimate) is
159 /// approximately unbiased and two-sided, so it exceeds the truth about
160 /// half the time. Use [`from_hll_floor`](Self::from_hll_floor), which
161 /// takes a value that is never above the truth.
162 ///
163 /// A `distinct` of zero (unknown) or greater than `rows` (an
164 /// inconsistent reading) falls back to [`unknown`](Self::unknown)
165 /// rather than producing an unsound value.
166 ///
167 /// # Examples
168 ///
169 /// ```
170 /// use samkhya_core::degree::AttributeDegree;
171 ///
172 /// // A key column: every value occurs once.
173 /// assert_eq!(AttributeDegree::from_distinct(100, 100).max_degree(), 1);
174 /// // 100 rows over 10 values: at worst 91 share one value.
175 /// assert_eq!(AttributeDegree::from_distinct(100, 10).max_degree(), 91);
176 /// // Unknown distinct count degrades safely.
177 /// assert_eq!(AttributeDegree::from_distinct(100, 0).max_degree(), 100);
178 /// ```
179 pub const fn from_distinct(rows: u64, distinct: u64) -> Self {
180 if distinct == 0 || distinct > rows {
181 return Self::unknown(rows);
182 }
183 // rows >= distinct >= 1, so this cannot underflow.
184 Self {
185 max_degree: rows - distinct + 1,
186 }
187 }
188
189 /// Use a directly measured or sketch-derived upper bound on the degree.
190 ///
191 /// # Soundness obligation
192 ///
193 /// `upper_bound` must be greater than or equal to the true maximum
194 /// degree. A Count-Min sketch satisfies this by construction: its
195 /// frequency estimates never fall below the truth, so the maximum
196 /// estimate over the inserted keys is a sound bound. An exact scan
197 /// obviously satisfies it too. A sampled or averaged statistic does
198 /// **not**.
199 ///
200 /// The value is capped at `rows`, since no relation can have a degree
201 /// above its own row count.
202 ///
203 /// # Examples
204 ///
205 /// ```
206 /// use samkhya_core::degree::AttributeDegree;
207 ///
208 /// assert_eq!(AttributeDegree::from_upper_bound(1_000, 37).max_degree(), 37);
209 /// // Capped at the row count.
210 /// assert_eq!(AttributeDegree::from_upper_bound(20, 999).max_degree(), 20);
211 /// ```
212 pub const fn from_upper_bound(rows: u64, upper_bound: u64) -> Self {
213 Self {
214 max_degree: if upper_bound < rows {
215 upper_bound
216 } else {
217 rows
218 },
219 }
220 }
221
222 /// Derive a sound degree bound from an HLL sketch of the join column.
223 ///
224 /// Uses [`HllSketch::nonzero_registers`](crate::sketches::HllSketch::nonzero_registers),
225 /// a distinct-count floor, rather than the two-sided point estimate —
226 /// see [`from_distinct`](Self::from_distinct) for why that distinction
227 /// decides whether the resulting ceiling is sound.
228 ///
229 /// The floor saturates at the register count, so on a high-cardinality
230 /// column this degrades toward [`unknown`](Self::unknown) rather than
231 /// toward a wrong answer. A Count-Min sketch
232 /// ([`from_count_min`](Self::from_count_min)) bounds far more tightly
233 /// when one is available.
234 ///
235 /// # Examples
236 ///
237 /// ```
238 /// use samkhya_core::degree::AttributeDegree;
239 /// use samkhya_core::sketches::HllSketch;
240 ///
241 /// let mut hll = HllSketch::new(12).unwrap();
242 /// for i in 0..1_000u32 { hll.add(&i.to_le_bytes()); }
243 ///
244 /// let degree = AttributeDegree::from_hll_floor(1_000, &hll);
245 /// // Sound: never below the true maximum degree of 1.
246 /// assert!(degree.max_degree() >= 1);
247 /// assert!(degree.max_degree() <= 1_000);
248 /// ```
249 pub fn from_hll_floor(rows: u64, sketch: &crate::sketches::HllSketch) -> Self {
250 Self::from_distinct(rows, sketch.nonzero_registers())
251 }
252
253 /// Derive a sound degree bound from a Count-Min sketch of the join
254 /// column — the tightest source available without an exact scan.
255 ///
256 /// For any key `k`, `true_freq(k) <= estimate(k) <= max counter`, so
257 /// the sketch's largest counter bounds every key's degree at once.
258 /// Returns [`unknown`](Self::unknown) when the sketch has saturated,
259 /// because that chain of inequalities depends on Count-Min's
260 /// never-undercount property, which `u32` saturation breaks.
261 ///
262 /// This is the link that makes the ceiling *portable*: a Count-Min
263 /// sketch written into a Puffin sidecar by one engine yields a
264 /// provable join ceiling in another, with no shared catalog and no
265 /// re-scan.
266 ///
267 /// # Examples
268 ///
269 /// ```
270 /// use samkhya_core::degree::AttributeDegree;
271 /// use samkhya_core::sketches::CountMinSketch;
272 ///
273 /// let mut cms = CountMinSketch::with_defaults();
274 /// for _ in 0..9 { cms.add(b"hot-key", 1); }
275 /// for _ in 0..2 { cms.add(b"cold-key", 1); }
276 ///
277 /// let degree = AttributeDegree::from_count_min(11, &cms);
278 /// // Bounds the true maximum degree of 9 from above, and beats the
279 /// // row count the caller would otherwise have to assume.
280 /// assert!(degree.max_degree() >= 9);
281 /// assert!(degree.max_degree() <= 11);
282 /// ```
283 pub fn from_count_min(rows: u64, sketch: &crate::sketches::CountMinSketch) -> Self {
284 match sketch.max_frequency_bound() {
285 Some(bound) => Self::from_upper_bound(rows, u64::from(bound)),
286 None => Self::unknown(rows),
287 }
288 }
289
290 /// The bounded maximum degree.
291 pub const fn max_degree(&self) -> u64 {
292 self.max_degree
293 }
294}
295
296/// One relation participating in a join, with its per-attribute degrees.
297#[derive(Debug, Clone)]
298pub struct JoinRelation {
299 rows: u64,
300 degrees: BTreeMap<AttributeId, AttributeDegree>,
301}
302
303impl JoinRelation {
304 /// A relation of `rows` rows with no degree information. Every
305 /// attribute defaults to [`AttributeDegree::unknown`].
306 ///
307 /// # Examples
308 ///
309 /// ```
310 /// use samkhya_core::degree::JoinRelation;
311 ///
312 /// assert_eq!(JoinRelation::new(42).rows(), 42);
313 /// ```
314 pub fn new(rows: u64) -> Self {
315 Self {
316 rows,
317 degrees: BTreeMap::new(),
318 }
319 }
320
321 /// Attach a degree bound for one join attribute.
322 ///
323 /// # Examples
324 ///
325 /// ```
326 /// use samkhya_core::degree::{AttributeDegree, JoinRelation};
327 ///
328 /// let r = JoinRelation::new(100)
329 /// .with_degree(7, AttributeDegree::from_distinct(100, 100));
330 /// assert_eq!(r.degree(7).max_degree(), 1);
331 /// ```
332 pub fn with_degree(mut self, attribute: AttributeId, degree: AttributeDegree) -> Self {
333 self.degrees.insert(attribute, degree);
334 self
335 }
336
337 /// Row count of this relation.
338 pub const fn rows(&self) -> u64 {
339 self.rows
340 }
341
342 /// Degree bound for `attribute`, defaulting to the always-sound
343 /// [`AttributeDegree::unknown`] when none was supplied.
344 ///
345 /// # Examples
346 ///
347 /// ```
348 /// use samkhya_core::degree::JoinRelation;
349 ///
350 /// // No degree registered → falls back to the row count.
351 /// assert_eq!(JoinRelation::new(64).degree(3).max_degree(), 64);
352 /// ```
353 pub fn degree(&self, attribute: AttributeId) -> AttributeDegree {
354 self.degrees
355 .get(&attribute)
356 .copied()
357 .unwrap_or_else(|| AttributeDegree::unknown(self.rows))
358 }
359}
360
361/// An equality predicate binding two relations on one shared attribute.
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct JoinEdge {
364 /// Index of the left relation in [`JoinGraph`]'s relation vector.
365 pub left: usize,
366 /// Index of the right relation.
367 pub right: usize,
368 /// The attribute both sides are compared on.
369 pub attribute: AttributeId,
370}
371
372/// A join query described precisely enough to bound provably.
373///
374/// See the [module documentation](self) for the theorem this implements and
375/// for where the degree statistics come from.
376#[derive(Debug, Clone, Default)]
377pub struct JoinGraph {
378 relations: Vec<JoinRelation>,
379 edges: Vec<JoinEdge>,
380}
381
382impl JoinGraph {
383 /// Build a graph over `relations`, with no predicates yet.
384 pub fn new(relations: Vec<JoinRelation>) -> Self {
385 Self {
386 relations,
387 edges: Vec::new(),
388 }
389 }
390
391 /// Add an equality predicate between two relations on one attribute.
392 ///
393 /// Out-of-range indices and self-edges are dropped: a misbuilt join
394 /// graph must degrade the ceiling, never corrupt or panic it.
395 ///
396 /// # Examples
397 ///
398 /// ```
399 /// use samkhya_core::degree::{JoinGraph, JoinRelation};
400 ///
401 /// let g = JoinGraph::new(vec![JoinRelation::new(5), JoinRelation::new(7)])
402 /// .with_edge(0, 1, 0)
403 /// .with_edge(0, 9, 0); // dropped: index 9 does not exist
404 /// assert_eq!(g.edges().len(), 1);
405 /// ```
406 pub fn with_edge(mut self, left: usize, right: usize, attribute: AttributeId) -> Self {
407 let n = self.relations.len();
408 if left < n && right < n && left != right {
409 self.edges.push(JoinEdge {
410 left,
411 right,
412 attribute,
413 });
414 }
415 self
416 }
417
418 /// The relations in this graph.
419 pub fn relations(&self) -> &[JoinRelation] {
420 &self.relations
421 }
422
423 /// The equality predicates in this graph.
424 pub fn edges(&self) -> &[JoinEdge] {
425 &self.edges
426 }
427
428 /// A provable inclusive ceiling on the join's output cardinality.
429 ///
430 /// Never returns a value below the true cardinality of any database
431 /// instance consistent with the supplied statistics, provided every
432 /// [`AttributeDegree`] honours its soundness obligation.
433 ///
434 /// The ceiling is the minimum of the Cartesian product and the
435 /// spanning-tree degree bound evaluated from every possible root.
436 /// Because *every* spanning tree yields a sound ceiling, the search
437 /// over roots affects only tightness, never correctness.
438 ///
439 /// # Examples
440 ///
441 /// ```
442 /// use samkhya_core::degree::{AttributeDegree, JoinGraph, JoinRelation};
443 ///
444 /// // Three 3-row relations chained on two attributes, every row on the
445 /// // same key: the join really does degenerate to 27 rows, and the
446 /// // ceiling says so rather than pretending otherwise.
447 /// let rel = |n| JoinRelation::new(n);
448 /// let g = JoinGraph::new(vec![rel(3), rel(3), rel(3)])
449 /// .with_edge(0, 1, 0)
450 /// .with_edge(1, 2, 1);
451 /// assert_eq!(g.ceiling(), 27);
452 /// ```
453 pub fn ceiling(&self) -> u64 {
454 if self.relations.is_empty() {
455 return 0;
456 }
457
458 let mut total: u128 = 1;
459 for component in self.components() {
460 let component_ceiling = self.component_ceiling(&component);
461 total = total.saturating_mul(u128::from(component_ceiling));
462 if total >= u128::from(u64::MAX) {
463 return u64::MAX;
464 }
465 }
466 total as u64
467 }
468
469 /// Connected components of the relation graph induced by the edges.
470 /// Every relation index appears in exactly one component; a relation
471 /// with no incident edge forms a singleton.
472 fn components(&self) -> Vec<Vec<usize>> {
473 let n = self.relations.len();
474 let mut parent: Vec<usize> = (0..n).collect();
475
476 fn find(parent: &mut [usize], mut x: usize) -> usize {
477 while parent[x] != x {
478 parent[x] = parent[parent[x]];
479 x = parent[x];
480 }
481 x
482 }
483
484 for edge in &self.edges {
485 let a = find(&mut parent, edge.left);
486 let b = find(&mut parent, edge.right);
487 if a != b {
488 parent[a] = b;
489 }
490 }
491
492 let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
493 for v in 0..n {
494 let root = find(&mut parent, v);
495 groups.entry(root).or_default().push(v);
496 }
497 groups.into_values().collect()
498 }
499
500 /// Best sound ceiling for one connected component.
501 fn component_ceiling(&self, component: &[usize]) -> u64 {
502 // The Cartesian product over the component is always sound.
503 let rows: Vec<u64> = component.iter().map(|&r| self.relations[r].rows).collect();
504 let mut best = ProductBound.ceiling(&rows, &[]);
505
506 if component.len() == 1 {
507 return self.relations[component[0]].rows;
508 }
509
510 for &root in component {
511 let candidate = self.spanning_tree_ceiling(component, root);
512 if candidate < best {
513 best = candidate;
514 }
515 }
516 best
517 }
518
519 /// Grow a spanning tree greedily from `root`, always attaching the
520 /// frontier relation whose degree factor is smallest. Any spanning tree
521 /// gives a sound ceiling, so the greedy choice is a tightness
522 /// heuristic, not a correctness requirement.
523 fn spanning_tree_ceiling(&self, component: &[usize], root: usize) -> u64 {
524 let mut visited: Vec<usize> = vec![root];
525 let mut bound: u128 = u128::from(self.relations[root].rows);
526
527 while visited.len() < component.len() {
528 let mut best: Option<(usize, u64)> = None;
529
530 for edge in &self.edges {
531 // Consider the edge in whichever orientation crosses the
532 // frontier: one endpoint visited, the other not.
533 for (from, to) in [(edge.left, edge.right), (edge.right, edge.left)] {
534 if !visited.contains(&from) || visited.contains(&to) {
535 continue;
536 }
537 if !component.contains(&to) {
538 continue;
539 }
540 let factor = self.relations[to].degree(edge.attribute).max_degree();
541 if best.is_none_or(|(_, current)| factor < current) {
542 best = Some((to, factor));
543 }
544 }
545 }
546
547 let Some((next, factor)) = best else {
548 // Disconnected within the claimed component: fall back to
549 // multiplying in the remaining row counts, which is sound.
550 for &v in component {
551 if !visited.contains(&v) {
552 bound = bound.saturating_mul(u128::from(self.relations[v].rows));
553 visited.push(v);
554 }
555 }
556 break;
557 };
558
559 bound = bound.saturating_mul(u128::from(factor));
560 visited.push(next);
561
562 if bound >= u128::from(u64::MAX) {
563 return u64::MAX;
564 }
565 }
566
567 if bound >= u128::from(u64::MAX) {
568 u64::MAX
569 } else {
570 bound as u64
571 }
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 /// Every witness instance from the v1.1 soundness audit, with the true
580 /// cardinality established by brute force in
581 /// `tests/soundness_degree.rs`.
582 #[test]
583 fn fk_join_is_exactly_tight() {
584 let orders = JoinRelation::new(10).with_degree(0, AttributeDegree::from_distinct(10, 10));
585 let lineitem =
586 JoinRelation::new(100).with_degree(0, AttributeDegree::from_distinct(100, 10));
587 let g = JoinGraph::new(vec![orders, lineitem]).with_edge(0, 1, 0);
588 assert_eq!(g.ceiling(), 100);
589 }
590
591 #[test]
592 fn all_rows_on_one_key_yields_the_product() {
593 let g = JoinGraph::new(vec![JoinRelation::new(4), JoinRelation::new(5)]).with_edge(0, 1, 0);
594 assert_eq!(g.ceiling(), 20);
595 }
596
597 #[test]
598 fn skewed_join_stays_above_truth() {
599 // 20 rows, 5 distinct values, 16 rows piled on one value.
600 // True cardinality is 260; maxdeg bounds at 16 → 20 * 16 = 320.
601 let rel = || JoinRelation::new(20).with_degree(0, AttributeDegree::from_distinct(20, 5));
602 let g = JoinGraph::new(vec![rel(), rel()]).with_edge(0, 1, 0);
603 assert_eq!(g.ceiling(), 320);
604 assert!(g.ceiling() >= 260);
605 }
606
607 #[test]
608 fn star_with_key_hub_is_tight() {
609 // Hub of 2 rows, three spokes of 4 rows each, all on one key value.
610 let hub = JoinRelation::new(2);
611 let spoke = || JoinRelation::new(4);
612 let g = JoinGraph::new(vec![hub, spoke(), spoke(), spoke()])
613 .with_edge(0, 1, 0)
614 .with_edge(0, 2, 1)
615 .with_edge(0, 3, 2);
616 assert_eq!(g.ceiling(), 128);
617 }
618
619 #[test]
620 fn key_star_collapses_to_the_hub() {
621 // A hub joined to three dimension tables on their primary keys:
622 // each spoke contributes a factor of exactly 1.
623 let hub = JoinRelation::new(1_000);
624 let dim = |n| JoinRelation::new(n).with_degree(0, AttributeDegree::from_distinct(n, n));
625 let g = JoinGraph::new(vec![hub, dim(50), dim(60), dim(70)])
626 .with_edge(0, 1, 0)
627 .with_edge(0, 2, 0)
628 .with_edge(0, 3, 0);
629 assert_eq!(g.ceiling(), 1_000);
630 }
631
632 #[test]
633 fn disconnected_components_multiply() {
634 let g = JoinGraph::new(vec![
635 JoinRelation::new(3),
636 JoinRelation::new(4),
637 JoinRelation::new(5),
638 ])
639 .with_edge(0, 1, 0);
640 // Component {0,1} bounds at 3*4 = 12 with no degree info; {2} is 5.
641 assert_eq!(g.ceiling(), 60);
642 }
643
644 #[test]
645 fn empty_graph_is_zero() {
646 assert_eq!(JoinGraph::new(Vec::new()).ceiling(), 0);
647 }
648
649 #[test]
650 fn single_relation_is_its_row_count() {
651 assert_eq!(JoinGraph::new(vec![JoinRelation::new(77)]).ceiling(), 77);
652 }
653
654 #[test]
655 fn ceiling_never_exceeds_the_product() {
656 let rel = |n| JoinRelation::new(n).with_degree(0, AttributeDegree::from_distinct(n, n));
657 let g = JoinGraph::new(vec![rel(10), rel(20), rel(30)])
658 .with_edge(0, 1, 0)
659 .with_edge(1, 2, 0);
660 assert!(g.ceiling() <= 10 * 20 * 30);
661 }
662
663 #[test]
664 fn saturates_instead_of_overflowing() {
665 let huge = || JoinRelation::new(u64::MAX);
666 let g = JoinGraph::new(vec![huge(), huge(), huge()])
667 .with_edge(0, 1, 0)
668 .with_edge(1, 2, 0);
669 assert_eq!(g.ceiling(), u64::MAX);
670 }
671
672 #[test]
673 fn degree_from_distinct_rejects_inconsistent_input() {
674 // distinct > rows cannot happen in a consistent reading; degrade
675 // safely rather than underflowing.
676 assert_eq!(AttributeDegree::from_distinct(10, 50).max_degree(), 10);
677 }
678
679 #[test]
680 fn unknown_degrees_degrade_to_the_product() {
681 let g = JoinGraph::new(vec![JoinRelation::new(6), JoinRelation::new(7)]).with_edge(0, 1, 0);
682 assert_eq!(g.ceiling(), 42);
683 }
684}