Skip to main content

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}