Skip to main content

samkhya_core/
lpbound.rs

1//! Pessimistic upper-bound envelope for cardinality estimates.
2//!
3//! Inspired by **LpBound** \[Zhang et al., SIGMOD 2025 Best Paper\]. The
4//! envelope provides a *provable ceiling* on the cardinality of a join:
5//! no correction may exceed it, so cold-start plans are bounded by the
6//! native estimate or this ceiling — whichever is tighter — and never
7//! degrade below baseline.
8//!
9//! # Preferred bound
10//!
11//! When the `lp_solver` Cargo feature is enabled, `LpJoinBound` (a real
12//! fractional-edge-cover LP solved with `good_lp`'s pure-Rust `microlp`
13//! backend) is the preferred ceiling. It is provably tighter than the
14//! coarse [`ProductBound`] / [`AgmBound`] / [`ChainBound`] approximations
15//! for any non-trivial cyclic join (triangles, squares, cliques) and
16//! exactly matches the AGM ρ\*-derived bound for all join shapes the
17//! attribute-hypergraph can represent.
18//!
19//! # Scaffolding bounds (always available)
20//!
21//! [`ProductBound`], [`AgmBound`], and [`ChainBound`] remain shipped
22//! without the LP dependency for builds that want a constant-time
23//! ceiling, for unit tests, and as the safety floor when the LP solver
24//! fails (numerical edge cases, malformed join graphs). They are
25//! scaffolding for the full LpBound, not a replacement: prefer
26//! `LpJoinBound` (under the `lp_solver` feature) in any release build that
27//! can afford the `good_lp` dependency.
28//!
29//! # Empirical bound ordering
30//!
31//! The empirical campaign (`bench-results/07_lpbound_tightness.md`,
32//! 1,080 trials across path/star/cycle/clique topologies × n ∈ {3, 5, 7}
33//! × ℓ_p ∈ {1, 2, ∞}) measured the actual partial order:
34//!
35//! ```text
36//!   ProductBound  ≥  { ChainBound,  AgmBound }  ≥  LpJoinBound
37//! ```
38//!
39//! `ChainBound` and `AgmBound` are **not strictly ordered** — `ChainBound`
40//! is routinely the tighter of the two (it divides by every per-edge
41//! distinct count, while AGM uses a fractional-edge-cover shortcut). The
42//! `LpJoinBound ≤ AgmBound` leg holds in 86.4% of trials; size-7
43//! cyclic/clique under uniform ℓ_p=1 exposes an LP-conditioning corner
44//! (~13.6% violation) where the LP-derived ceiling overshoots AGM's
45//! `min × max` shortcut. The query optimizer should evaluate all three
46//! scaffolding bounds and take the minimum rather than assuming a strict
47//! chain.
48
49use crate::{Error, Result};
50
51/// Trait every upper-bound provider implements.
52///
53/// Implementations return an *inclusive* row-count ceiling that the join
54/// can never exceed. A correction layer must never produce an estimate
55/// above this number.
56///
57/// # Examples
58///
59/// ```
60/// use samkhya_core::lpbound::{ProductBound, UpperBound};
61///
62/// // Cartesian product (sound but very loose).
63/// let bound = ProductBound.ceiling(&[100, 200], &[]);
64/// assert_eq!(bound, 20_000);
65/// ```
66pub trait UpperBound {
67    /// Compute the inclusive ceiling for a join.
68    ///
69    /// * `relations`           — input row counts for each base relation
70    /// * `equality_predicates` — pairs of relation indices joined by `=`
71    fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64;
72}
73
74/// Cartesian-product upper bound. Sound but very loose.
75///
76/// # Examples
77///
78/// ```
79/// use samkhya_core::lpbound::{ProductBound, UpperBound};
80///
81/// // Empty predicate list: the bound is the unconstrained product.
82/// assert_eq!(ProductBound.ceiling(&[10, 20, 30], &[]), 6000);
83/// // Overflow saturates to u64::MAX rather than wrapping.
84/// assert_eq!(ProductBound.ceiling(&[u64::MAX, 2], &[]), u64::MAX);
85/// ```
86pub struct ProductBound;
87
88impl UpperBound for ProductBound {
89    fn ceiling(&self, relations: &[u64], _eq: &[(usize, usize)]) -> u64 {
90        relations.iter().fold(1u64, |acc, &n| acc.saturating_mul(n))
91    }
92}
93
94/// Degree-derived chain-join upper bound.
95///
96/// Takes a per-relation distinct-key count and converts it into a sound
97/// bound on the relation's maximum join degree,
98/// `maxdeg_i ≤ |R_i| − D_i + 1`, then applies the spanning-tree degree
99/// ceiling from [`crate::degree`]. Falls back to [`ProductBound`] when no
100/// equality predicates are supplied.
101///
102/// # Caller obligation
103///
104/// `distinct_counts[i]` must be the distinct-value count of the *join key*
105/// relation `i` carries, and it must not over-state the truth — an HLL
106/// reading that comes back high would relax the derived degree bound in the
107/// unsafe direction. [`crate::sketches::HllSketch`] readings should be used
108/// at or below their estimate, not above it.
109///
110/// # Soundness note (changed in 1.2.0)
111///
112/// Through v1.1 this bound divided the Cartesian product by
113/// `max(D_i, D_j)` per predicate. That formula is a uniform-distribution
114/// *estimate*, not an upper bound: under skew it lands below the true
115/// cardinality. Concretely, two 20-row relations with 5 distinct keys each
116/// and 16 rows piled on one key join to 260 rows, while the old formula
117/// returned 80. The bound now returns 320 for that instance — larger, and
118/// actually provable. See `crate::degree` for the theorem.
119///
120/// # Examples
121///
122/// ```
123/// use samkhya_core::lpbound::{ChainBound, UpperBound};
124///
125/// // A foreign-key join: 10 orders, 100 line items, 10 distinct keys on
126/// // both sides. Bounds exactly at the true output of 100 rows.
127/// let cb = ChainBound::new(vec![10, 10]);
128/// assert_eq!(cb.ceiling(&[10, 100], &[(0, 1)]), 100);
129/// ```
130pub struct ChainBound {
131    pub distinct_counts: Vec<u64>,
132}
133
134impl ChainBound {
135    /// Construct a chain-join bound from per-relation distinct-key counts.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use samkhya_core::lpbound::{ChainBound, UpperBound};
141    ///
142    /// // Two 1000-row relations over a key with 100 distinct values: at
143    /// // worst 901 rows share one value, so the ceiling is 1000 * 901.
144    /// let cb = ChainBound::new(vec![100, 100]);
145    /// assert_eq!(cb.ceiling(&[1_000, 1_000], &[(0, 1)]), 901_000);
146    /// ```
147    pub fn new(distinct_counts: Vec<u64>) -> Self {
148        Self { distinct_counts }
149    }
150}
151
152impl UpperBound for ChainBound {
153    fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64 {
154        if relations.is_empty() {
155            return 0;
156        }
157        if equality_predicates.is_empty() {
158            return ProductBound.ceiling(relations, &[]);
159        }
160        degree_graph(relations, equality_predicates, Some(&self.distinct_counts)).ceiling()
161    }
162}
163
164/// Build the [`crate::degree::JoinGraph`] implied by the legacy
165/// `(row counts, predicate pairs)` surface.
166///
167/// Each predicate is treated as introducing its own join attribute, and a
168/// relation's degree on every attribute it touches is derived from its
169/// single supplied distinct count. With no distinct counts the degrees are
170/// unknown and the ceiling collapses to the Cartesian product — sound, and
171/// the honest answer for that input.
172fn degree_graph(
173    relations: &[u64],
174    equality_predicates: &[(usize, usize)],
175    distinct_counts: Option<&[u64]>,
176) -> crate::degree::JoinGraph {
177    use crate::degree::{AttributeDegree, JoinGraph, JoinRelation};
178
179    let n = relations.len();
180    let mut built: Vec<JoinRelation> = relations
181        .iter()
182        .map(|&rows| JoinRelation::new(rows))
183        .collect();
184
185    for (attribute, &(i, j)) in equality_predicates.iter().enumerate() {
186        if i >= n || j >= n || i == j {
187            continue;
188        }
189        let attribute = attribute as u32;
190        for endpoint in [i, j] {
191            let rows = relations[endpoint];
192            let degree = match distinct_counts.and_then(|d| d.get(endpoint).copied()) {
193                Some(distinct) => AttributeDegree::from_distinct(rows, distinct),
194                None => AttributeDegree::unknown(rows),
195            };
196            built[endpoint] = std::mem::replace(&mut built[endpoint], JoinRelation::new(rows))
197                .with_degree(attribute, degree);
198        }
199    }
200
201    let mut graph = JoinGraph::new(built);
202    for (attribute, &(i, j)) in equality_predicates.iter().enumerate() {
203        graph = graph.with_edge(i, j, attribute as u32);
204    }
205    graph
206}
207
208/// Cartesian-product bound retained under its historical name.
209///
210/// # Soundness note (changed in 1.2.0)
211///
212/// Through v1.1 this returned `min(product, |R_min| · |R_max|)`. That
213/// shortcut is not an AGM bound and is unsound for three or more relations:
214/// three 3-row relations chained on one shared key value join to 27 rows,
215/// while the shortcut returned 9. Given only row counts and which pairs are
216/// joined, the Cartesian product is the *only* sound ceiling — every row of
217/// every relation may share a single key value. This type therefore now
218/// returns exactly [`ProductBound`].
219///
220/// To do better, supply degree statistics via [`crate::degree::JoinGraph`],
221/// which bounds the same foreign-key join at 100 rows instead of 1000.
222///
223/// # Examples
224///
225/// ```
226/// use samkhya_core::lpbound::{AgmBound, ProductBound, UpperBound};
227///
228/// let r = [1_000u64, 1_000_000];
229/// assert_eq!(
230///     AgmBound.ceiling(&r, &[(0, 1)]),
231///     ProductBound.ceiling(&r, &[])
232/// );
233/// ```
234#[deprecated(
235    since = "1.2.0",
236    note = "the min*max shortcut was unsound for 3+ relations and now simply returns \
237            ProductBound; use samkhya_core::degree::JoinGraph for a bound that is both \
238            provable and tighter"
239)]
240pub struct AgmBound;
241
242#[allow(deprecated)]
243impl UpperBound for AgmBound {
244    fn ceiling(&self, relations: &[u64], _equality_predicates: &[(usize, usize)]) -> u64 {
245        ProductBound.ceiling(relations, &[])
246    }
247}
248
249/// Clamp an estimate to a ceiling. Returns [`Error::LpBoundExceeded`]
250/// if the estimate exceeds the ceiling — this signals a correction-layer
251/// bug, since corrections must respect the envelope.
252///
253/// # Examples
254///
255/// ```
256/// use samkhya_core::lpbound::clamp_estimate;
257///
258/// // Within the ceiling → Ok(value).
259/// assert_eq!(clamp_estimate(500.0, 1000).unwrap(), 500);
260/// // Exceeding the ceiling → Err signalling a corrector violation.
261/// assert!(clamp_estimate(1500.0, 1000).is_err());
262/// ```
263pub fn clamp_estimate(estimate: f64, ceiling: u64) -> Result<u64> {
264    let clamped = estimate.max(0.0).min(u64::MAX as f64) as u64;
265    if clamped <= ceiling {
266        Ok(clamped)
267    } else {
268        Err(Error::LpBoundExceeded {
269            estimate,
270            ceiling: ceiling as f64,
271        })
272    }
273}
274
275/// Clamp without erroring; saturates to `ceiling`. Use this in production
276/// paths where a misbehaving corrector must never crash the engine.
277///
278/// # Examples
279///
280/// ```
281/// use samkhya_core::lpbound::saturating_clamp;
282///
283/// assert_eq!(saturating_clamp(500.0, 1000), 500);
284/// assert_eq!(saturating_clamp(2000.0, 1000), 1000);   // clamps to ceiling
285/// assert_eq!(saturating_clamp(-5.0, 1000), 0);        // negative → 0
286/// assert_eq!(saturating_clamp(f64::NAN, 1000), 0);    // NaN is treated as 0
287/// ```
288pub fn saturating_clamp(estimate: f64, ceiling: u64) -> u64 {
289    let clamped = estimate.max(0.0).min(u64::MAX as f64) as u64;
290    clamped.min(ceiling)
291}
292
293// =============================================================================
294// LpJoinBound — real fractional-edge-cover LP (the v0.5.0 deliverable).
295// =============================================================================
296
297/// Real fractional-edge-cover LP join bound — the principled AGM / LpBound
298/// construction the coarse [`AgmBound`] / [`ChainBound`] approximate.
299///
300/// # Formulation
301///
302/// Build the join's *attribute hypergraph*:
303///
304/// * one variable `x_r ≥ 0` per relation `r`;
305/// * each equality predicate `(i, j)` contributes one shared attribute
306///   `a` covered by both `R_i` and `R_j`;
307/// * for every shared attribute `a` we add a fractional-cover constraint
308///
309///   ```text
310///   sum_{r : a ∈ schema(r)} x_r ≥ 1
311///   ```
312///
313/// * the objective is to minimise the log-cardinality of the join,
314///
315///   ```text
316///   minimise   sum_r x_r * log|R_r|
317///   ```
318///
319/// The provable join-cardinality ceiling is `exp(minimum)`. This is the
320/// classical **Atserias–Grohe–Marx fractional-edge-cover bound** that
321/// LpBound (Zhang et al., SIGMOD 2025) extends to ℓp-norm degree
322/// sequences; the AGM bound is the p=∞ specialisation and is exactly
323/// what we ship here.
324///
325/// # Per-component decomposition
326///
327/// Equality predicates partition the relations into connected
328/// components. Variables in distinct components share no constraint, so
329/// the LP decomposes: the bound on the whole join graph is the
330/// **product** of the bounds on each connected component. We exploit
331/// this by solving one (small) LP per component instead of one big LP.
332///
333/// # Tightness vs the coarse bounds
334///
335/// * 2-relation single-predicate join: LP returns `min(|R_i|, |R_j|)`
336///   (the real AGM bound for a single shared attribute), which is
337///   strictly tighter than [`AgmBound`]'s `|R_min| * |R_max|`
338///   approximation whenever both relations are non-empty.
339/// * Triangle (3 relations, 3 predicates each on a distinct attribute):
340///   LP returns `(|R_0| * |R_1| * |R_2|)^{1/2}`, the famous AGM triangle
341///   bound. Strictly tighter than [`ChainBound`] and [`ProductBound`]
342///   for any non-trivial relation sizes.
343/// * Disconnected components: LP returns the product of the
344///   per-component bounds, matching the trivial decomposition.
345///
346/// # Solver
347///
348/// Backed by [`good_lp`] with the pure-Rust `microlp` backend
349/// (no system libraries, no C/C++ toolchain — compiles cleanly on any
350/// Rust 1.94+ host). The LP is small (one variable per relation, one
351/// constraint per shared attribute) so solve time is negligible.
352#[cfg(feature = "lp_solver")]
353pub struct LpJoinBound {
354    /// Optional per-relation distinct-count hint. When provided, the
355    /// objective coefficient for relation `r` is `log(min(|R_r|, D_r))`
356    /// rather than `log|R_r|`, which can only tighten the bound (the
357    /// join output on a key column cannot exceed the column's distinct
358    /// support). Empty by default.
359    distinct_counts: Vec<u64>,
360}
361
362#[cfg(feature = "lp_solver")]
363impl Default for LpJoinBound {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369/// One relation described as a hyperedge: its row count, the join
370/// attributes it exposes, and whether it also carries columns nothing else
371/// covers.
372///
373/// The `has_private_attributes` flag is what makes a fractional edge cover
374/// well defined. A relation contributing any column that no other relation
375/// supplies must take a full unit of cover weight, because the output
376/// projected onto that relation's columns is a subset of the relation
377/// itself. Defaulting the flag to `true` keeps the bound sound for callers
378/// that have not thought about it — the honest default for a safety
379/// envelope.
380#[cfg(feature = "lp_solver")]
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct HyperRelation {
383    /// Row count of the relation.
384    pub rows: u64,
385    /// Join attributes this relation exposes. Two relations share an
386    /// attribute exactly when the same identifier appears in both lists.
387    pub attributes: Vec<u32>,
388    /// Whether the relation contributes output columns no other relation
389    /// covers. `true` is the safe default.
390    pub has_private_attributes: bool,
391}
392
393#[cfg(feature = "lp_solver")]
394impl HyperRelation {
395    /// A relation that carries private columns in addition to its join
396    /// attributes — the ordinary `SELECT *` case.
397    ///
398    /// # Examples
399    ///
400    /// ```
401    /// use samkhya_core::lpbound::HyperRelation;
402    ///
403    /// let r = HyperRelation::new(1_000, vec![0, 1]);
404    /// assert!(r.has_private_attributes);
405    /// ```
406    pub fn new(rows: u64, attributes: Vec<u32>) -> Self {
407        Self {
408            rows,
409            attributes,
410            has_private_attributes: true,
411        }
412    }
413
414    /// A relation already projected down to its join attributes, so nothing
415    /// outside the cover needs charging.
416    ///
417    /// Declare this only when it is true — a semi-join-reduced input, a
418    /// pure bridge table, or a query that projects to join keys. Declaring
419    /// it falsely makes the ceiling unsound.
420    ///
421    /// # Examples
422    ///
423    /// ```
424    /// use samkhya_core::lpbound::HyperRelation;
425    ///
426    /// let r = HyperRelation::projected(1_000, vec![0, 1]);
427    /// assert!(!r.has_private_attributes);
428    /// ```
429    pub fn projected(rows: u64, attributes: Vec<u32>) -> Self {
430        Self {
431            rows,
432            attributes,
433            has_private_attributes: false,
434        }
435    }
436}
437
438#[cfg(feature = "lp_solver")]
439impl LpJoinBound {
440    /// Construct a bound with no distinct-count overrides. The objective
441    /// uses `log|R_r|` for every relation.
442    pub fn new() -> Self {
443        Self {
444            distinct_counts: Vec::new(),
445        }
446    }
447
448    /// Construct a bound that uses the supplied per-relation distinct
449    /// counts to tighten the objective coefficients.
450    pub fn with_distinct_counts(distinct_counts: Vec<u64>) -> Self {
451        Self { distinct_counts }
452    }
453
454    /// Same semantics as [`UpperBound::ceiling`]; surfaced here so
455    /// callers can avoid importing the trait when they already hold an
456    /// `&LpJoinBound`.
457    ///
458    /// # Soundness note (changed in 1.2.0)
459    ///
460    /// Row counts plus a list of joined relation *pairs* do not determine a
461    /// fractional edge cover: the pair list says nothing about the columns
462    /// each relation contributes to the output, and every relation that
463    /// carries a column no other relation covers must take a full unit of
464    /// cover weight. Through v1.1 this method solved an LP with one
465    /// constraint per predicate and no private-attribute constraints, which
466    /// bounded a 10-row ⋈ 100-row foreign-key join at 10 rows — the join
467    /// really returns 100.
468    ///
469    /// This entry point now delegates to the degree-derived ceiling in
470    /// [`crate::degree`], which is provable on the same input. Use
471    /// [`Self::ceiling_hypergraph`] when the attribute schema is known and
472    /// the fractional-edge-cover LP is genuinely applicable — that path
473    /// still returns the AGM `n^1.5` bound for a triangle.
474    pub fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64 {
475        if relations.is_empty() {
476            return 0;
477        }
478        if equality_predicates.is_empty() {
479            return ProductBound.ceiling(relations, &[]);
480        }
481        degree_graph(relations, equality_predicates, None).ceiling()
482    }
483
484    /// Like [`Self::ceiling`] but folds the distinct counts supplied to
485    /// [`Self::with_distinct_counts`] into a sound per-relation degree
486    /// bound (`maxdeg ≤ rows − distinct + 1`). Missing or inconsistent
487    /// entries fall back to the row count.
488    ///
489    /// # Examples
490    ///
491    /// ```
492    /// use samkhya_core::lpbound::LpJoinBound;
493    ///
494    /// // 10 orders, 100 line items, 10 distinct order keys: bounds exactly.
495    /// let bound = LpJoinBound::with_distinct_counts(vec![10, 10]);
496    /// assert_eq!(bound.ceiling_with_distinct(&[10, 100], &[(0, 1)]), 100);
497    /// ```
498    pub fn ceiling_with_distinct(
499        &self,
500        relations: &[u64],
501        equality_predicates: &[(usize, usize)],
502    ) -> u64 {
503        if relations.is_empty() {
504            return 0;
505        }
506        if equality_predicates.is_empty() {
507            return ProductBound.ceiling(relations, &[]);
508        }
509        degree_graph(relations, equality_predicates, Some(&self.distinct_counts)).ceiling()
510    }
511
512    /// Solve the genuine fractional-edge-cover LP over an explicit
513    /// attribute hypergraph.
514    ///
515    /// This is the AGM bound as Atserias, Grohe and Marx define it: one
516    /// cover constraint per *attribute*, not per predicate. For a triangle
517    /// whose three relations expose only their join attributes it returns
518    /// `(|R₀|·|R₁|·|R₂|)^(1/2)`; for relations carrying private columns it
519    /// correctly charges each of them a full unit of cover weight and
520    /// degrades toward the Cartesian product.
521    ///
522    /// The result is capped at [`ProductBound`] and falls back to it if the
523    /// solver fails — the envelope must never crash the engine, and must
524    /// never return below the product's guarantee.
525    ///
526    /// # Examples
527    ///
528    /// ```
529    /// use samkhya_core::lpbound::{HyperRelation, LpJoinBound};
530    ///
531    /// // Triangle R(a,b), S(b,c), T(c,a): no private columns anywhere.
532    /// let tri = vec![
533    ///     HyperRelation::projected(100, vec![0, 1]),
534    ///     HyperRelation::projected(100, vec![1, 2]),
535    ///     HyperRelation::projected(100, vec![2, 0]),
536    /// ];
537    /// assert_eq!(LpJoinBound::new().ceiling_hypergraph(&tri), 1_000);
538    ///
539    /// // The same shape where each relation also carries its own columns:
540    /// // every cover weight is forced to 1, so the ceiling is the product.
541    /// let wide = vec![
542    ///     HyperRelation::new(100, vec![0, 1]),
543    ///     HyperRelation::new(100, vec![1, 2]),
544    ///     HyperRelation::new(100, vec![2, 0]),
545    /// ];
546    /// assert_eq!(LpJoinBound::new().ceiling_hypergraph(&wide), 1_000_000);
547    /// ```
548    pub fn ceiling_hypergraph(&self, relations: &[HyperRelation]) -> u64 {
549        let rows: Vec<u64> = relations.iter().map(|r| r.rows).collect();
550        let product = ProductBound.ceiling(&rows, &[]);
551        if relations.is_empty() {
552            return 0;
553        }
554        // Any relation with private columns must be fully covered, so if
555        // every relation has them the LP optimum is the product outright.
556        if relations.iter().all(|r| r.has_private_attributes) {
557            return product;
558        }
559        match self.solve_hypergraph(relations) {
560            Some(value) => value.min(product),
561            None => product,
562        }
563    }
564
565    /// Build and solve the attribute-level cover LP. Returns `None` when
566    /// the solver fails or produces a non-finite objective.
567    fn solve_hypergraph(&self, relations: &[HyperRelation]) -> Option<u64> {
568        use good_lp::{
569            Expression, ProblemVariables, Solution, SolverModel, default_solver, variable,
570        };
571
572        let mut vars = ProblemVariables::new();
573        let mut handles = Vec::with_capacity(relations.len());
574        let mut objective = Expression::with_capacity(relations.len());
575
576        for relation in relations {
577            let v = vars.add(variable().min(0.0));
578            handles.push(v);
579            let size = relation.rows as f64;
580            let coefficient = if size <= 1.0 { 0.0 } else { size.ln() };
581            objective.add_mul(coefficient, v);
582        }
583
584        let mut model = vars.minimise(&objective).using(default_solver);
585
586        // One cover constraint per distinct attribute.
587        let attributes: std::collections::BTreeSet<u32> = relations
588            .iter()
589            .flat_map(|r| r.attributes.iter().copied())
590            .collect();
591        for attribute in attributes {
592            let mut lhs = Expression::with_capacity(relations.len());
593            let mut covered = false;
594            for (idx, relation) in relations.iter().enumerate() {
595                if relation.attributes.contains(&attribute) {
596                    lhs.add_mul(1.0, handles[idx]);
597                    covered = true;
598                }
599            }
600            if covered {
601                model = model.with(lhs.geq(1.0));
602            }
603        }
604
605        // Private columns force a full unit of cover on their relation. A
606        // relation exposing no join attribute at all is in the same
607        // position: nothing can cover it, and under bag semantics it
608        // multiplies the output by its own row count.
609        for (idx, relation) in relations.iter().enumerate() {
610            if relation.has_private_attributes || relation.attributes.is_empty() {
611                let lhs: Expression = handles[idx].into();
612                model = model.with(lhs.geq(1.0));
613            }
614        }
615
616        let solution = model.solve().ok()?;
617        let optimum = solution.eval(&objective).exp();
618        if !optimum.is_finite() || optimum < 0.0 {
619            return None;
620        }
621        let optimum = optimum.max(1.0);
622        if optimum >= u64::MAX as f64 {
623            return Some(u64::MAX);
624        }
625        // `exp(ln(n))` drifts; snap to the nearest integer when the value is
626        // within a relative epsilon of it, otherwise round up.
627        let rounded = optimum.round();
628        let epsilon = 1e-9_f64.max(optimum.abs() * 1e-12);
629        Some(if (optimum - rounded).abs() <= epsilon {
630            rounded as u64
631        } else {
632            optimum.ceil() as u64
633        })
634    }
635}
636
637#[cfg(feature = "lp_solver")]
638impl UpperBound for LpJoinBound {
639    fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64 {
640        self.ceiling(relations, equality_predicates)
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn product_bound_two_relations() {
650        assert_eq!(ProductBound.ceiling(&[100, 200], &[]), 20_000);
651    }
652
653    #[test]
654    fn product_bound_overflow_saturates() {
655        assert_eq!(ProductBound.ceiling(&[u64::MAX, 2], &[]), u64::MAX);
656    }
657
658    #[test]
659    fn product_bound_empty_relations() {
660        assert_eq!(ProductBound.ceiling(&[], &[]), 1);
661    }
662
663    #[test]
664    #[allow(deprecated)]
665    fn agm_no_predicates_falls_back_to_product() {
666        assert_eq!(AgmBound.ceiling(&[10, 20, 30], &[]), 10 * 20 * 30);
667    }
668
669    /// Since 1.2.0 the deprecated shortcut simply is the product: given
670    /// only row counts and a predicate list there is nothing sound to
671    /// gain, and the old `min * max` answer was below the truth for three
672    /// or more relations.
673    #[test]
674    #[allow(deprecated)]
675    fn agm_now_equals_the_product() {
676        let r = [1_000u64, 1_000_000];
677        assert_eq!(
678            AgmBound.ceiling(&r, &[(0, 1)]),
679            ProductBound.ceiling(&r, &[])
680        );
681        // The instance that exposed the defect: three 3-row relations on
682        // one shared key value really do join to 27 rows.
683        assert_eq!(AgmBound.ceiling(&[3, 3, 3], &[(0, 1), (1, 2)]), 27);
684    }
685
686    #[test]
687    fn clamp_within_ceiling() {
688        assert_eq!(clamp_estimate(500.0, 1000).unwrap(), 500);
689    }
690
691    #[test]
692    fn clamp_exceeds_ceiling_errors() {
693        let err = clamp_estimate(1500.0, 1000).unwrap_err();
694        match err {
695            Error::LpBoundExceeded { estimate, ceiling } => {
696                assert_eq!(estimate, 1500.0);
697                assert_eq!(ceiling, 1000.0);
698            }
699            other => panic!("wrong error variant: {other:?}"),
700        }
701    }
702
703    #[test]
704    fn chain_bound_tighter_than_product() {
705        // Two relations of 1000 rows each over a key with 100 distinct
706        // values. At worst 1000 - 100 + 1 = 901 rows share one value, so
707        // the ceiling is 1000 * 901 — below the product, and provable.
708        let r = [1_000u64, 1_000];
709        let cb = ChainBound::new(vec![100, 100]);
710        let bound = cb.ceiling(&r, &[(0, 1)]);
711        assert_eq!(bound, 901_000);
712        let product = ProductBound.ceiling(&r, &[]);
713        assert!(bound < product);
714    }
715
716    #[test]
717    fn chain_bound_is_exact_on_a_foreign_key_join() {
718        // The shape that dominates analytical workloads: a key side and a
719        // fact side. maxdeg on the key side is 1, so the ceiling is the
720        // fact table's row count exactly.
721        let cb = ChainBound::new(vec![10, 10]);
722        assert_eq!(cb.ceiling(&[10, 100], &[(0, 1)]), 100);
723    }
724
725    #[test]
726    fn chain_bound_three_table_chain_stays_below_product() {
727        // R0(1000) ⋈ R1(2000) ⋈ R2(500), 100 distinct join keys each.
728        let r = [1_000u64, 2_000, 500];
729        let cb = ChainBound::new(vec![100, 100, 100]);
730        let bound = cb.ceiling(&r, &[(0, 1), (1, 2)]);
731        let product = ProductBound.ceiling(&r, &[]);
732        assert!(
733            bound < product,
734            "chain bound {bound} should be below product {product}"
735        );
736        // Sanity: still far above the old, unsound 100_000.
737        assert!(bound > 100_000);
738    }
739
740    /// Regression guard for the v1.1 soundness defect. Two 20-row relations
741    /// with 5 distinct keys and 16 rows piled on one of them really do join
742    /// to 260 rows; the pre-1.2 formula returned 80.
743    #[test]
744    fn chain_bound_is_sound_under_skew() {
745        let cb = ChainBound::new(vec![5, 5]);
746        let bound = cb.ceiling(&[20, 20], &[(0, 1)]);
747        assert!(
748            bound >= 260,
749            "skewed ceiling {bound} is below the true cardinality 260"
750        );
751    }
752
753    #[test]
754    fn chain_bound_no_predicates_falls_back() {
755        let cb = ChainBound::new(vec![10, 20, 30]);
756        assert_eq!(cb.ceiling(&[10, 20, 30], &[]), 10 * 20 * 30);
757    }
758
759    #[test]
760    fn chain_bound_missing_distinct_count_defaults_to_one() {
761        // No distinct count entry → defaults to 1, meaning no reduction.
762        let cb = ChainBound::new(vec![]);
763        let bound = cb.ceiling(&[100, 100], &[(0, 1)]);
764        assert_eq!(bound, 10_000); // 100 * 100 / max(1, 1) = 10_000
765    }
766
767    #[test]
768    fn saturating_clamp_saturates() {
769        assert_eq!(saturating_clamp(500.0, 1000), 500);
770        assert_eq!(saturating_clamp(2000.0, 1000), 1000);
771        assert_eq!(saturating_clamp(-5.0, 1000), 0);
772        assert_eq!(saturating_clamp(f64::NAN, 1000), 0);
773    }
774}
775
776#[cfg(all(test, feature = "lp_solver"))]
777mod lp_tests {
778    use super::*;
779
780    /// A 2-table join described only by row counts and "these two are
781    /// joined" cannot be bounded below the product: every row of both
782    /// relations may carry the same key. The pre-1.2 LP returned 1000 here,
783    /// which a 1000 x 1_000_000 foreign-key join exceeds by three orders of
784    /// magnitude.
785    #[test]
786    fn two_table_join_without_degrees_is_the_product() {
787        let r = [1_000u64, 1_000_000u64];
788        let lp = LpJoinBound::new();
789        assert_eq!(lp.ceiling(&r, &[(0, 1)]), ProductBound.ceiling(&r, &[]));
790    }
791
792    /// With the attribute schema declared, the fractional-edge-cover LP is
793    /// well posed and returns the textbook AGM triangle bound.
794    #[test]
795    fn triangle_hypergraph_matches_agm() {
796        let tri = vec![
797            HyperRelation::projected(1_000, vec![0, 1]),
798            HyperRelation::projected(1_000, vec![1, 2]),
799            HyperRelation::projected(1_000, vec![2, 0]),
800        ];
801        let bound = LpJoinBound::new().ceiling_hypergraph(&tri);
802        // sqrt(1e9) = 31_622.77...
803        assert!(
804            (31_000u64..=32_000u64).contains(&bound),
805            "expected ≈31_623, got {bound}"
806        );
807        assert!(bound < ProductBound.ceiling(&[1_000, 1_000, 1_000], &[]));
808    }
809
810    /// The same triangle where each relation also carries its own columns.
811    /// Every cover weight is forced to 1, so the honest answer is the
812    /// product — this is the case the pre-1.2 LP silently got wrong.
813    #[test]
814    fn triangle_with_private_columns_is_the_product() {
815        let tri = vec![
816            HyperRelation::new(1_000, vec![0, 1]),
817            HyperRelation::new(1_000, vec![1, 2]),
818            HyperRelation::new(1_000, vec![2, 0]),
819        ];
820        assert_eq!(
821            LpJoinBound::new().ceiling_hypergraph(&tri),
822            ProductBound.ceiling(&[1_000, 1_000, 1_000], &[])
823        );
824    }
825
826    /// Square (4-cycle) over a projected hypergraph: AGM ρ* = 2, so equal
827    /// relation sizes N give N².
828    #[test]
829    fn square_hypergraph_matches_agm() {
830        let square = vec![
831            HyperRelation::projected(100, vec![0, 1]),
832            HyperRelation::projected(100, vec![1, 2]),
833            HyperRelation::projected(100, vec![2, 3]),
834            HyperRelation::projected(100, vec![3, 0]),
835        ];
836        let bound = LpJoinBound::new().ceiling_hypergraph(&square);
837        assert!(
838            (5_000..=15_000).contains(&bound),
839            "expected ≈10_000, got {bound}"
840        );
841        assert!(bound < ProductBound.ceiling(&[100, 100, 100, 100], &[]));
842    }
843
844    /// A disconnected hypergraph decomposes: the LP optimum is the product
845    /// of the per-component bounds.
846    #[test]
847    fn disconnected_components_multiply() {
848        let graph = vec![
849            HyperRelation::projected(100, vec![0]),
850            HyperRelation::projected(200, vec![0]),
851            HyperRelation::projected(50, vec![1]),
852            HyperRelation::projected(70, vec![1]),
853        ];
854        let bound = LpJoinBound::new().ceiling_hypergraph(&graph);
855        assert!(
856            (4_900..=5_100).contains(&bound),
857            "expected ≈5000, got {bound}"
858        );
859    }
860
861    /// A relation exposing no join attribute cannot be covered by anything,
862    /// so it must contribute its full row count.
863    #[test]
864    fn isolated_relation_contributes_row_count() {
865        let graph = vec![
866            HyperRelation::projected(100, vec![0]),
867            HyperRelation::projected(200, vec![0]),
868            HyperRelation::projected(99, Vec::new()),
869        ];
870        let bound = LpJoinBound::new().ceiling_hypergraph(&graph);
871        assert!(
872            (9_800..=10_000).contains(&bound),
873            "expected ≈9_900, got {bound}"
874        );
875    }
876
877    /// The hypergraph LP is capped at the Cartesian product in every case.
878    #[test]
879    fn hypergraph_never_exceeds_the_product() {
880        let graph = vec![
881            HyperRelation::projected(37, vec![0]),
882            HyperRelation::new(41, vec![0, 1]),
883            HyperRelation::projected(43, vec![1]),
884        ];
885        let bound = LpJoinBound::new().ceiling_hypergraph(&graph);
886        assert!(bound <= ProductBound.ceiling(&[37, 41, 43], &[]));
887    }
888
889    /// The LP bound must never exceed the trivial product bound.
890    #[test]
891    fn lp_bound_dominates_product() {
892        let r = [37u64, 41, 43, 47, 53];
893        let preds = [(0usize, 1usize), (1, 2), (2, 3), (3, 4)];
894        let lp = LpJoinBound::new();
895        let bound = lp.ceiling(&r, &preds);
896        let product = ProductBound.ceiling(&r, &preds);
897        assert!(
898            bound <= product,
899            "LP bound {bound} must be ≤ product {product}"
900        );
901    }
902
903    /// Empty relations → bound 0.
904    #[test]
905    fn empty_relations_zero() {
906        let lp = LpJoinBound::new();
907        assert_eq!(lp.ceiling(&[], &[]), 0);
908    }
909
910    /// No predicates → product bound (sanity passthrough).
911    #[test]
912    fn no_predicates_returns_product() {
913        let lp = LpJoinBound::new();
914        let r = [10u64, 20, 30];
915        assert_eq!(lp.ceiling(&r, &[]), 6_000);
916    }
917
918    /// Distinct counts turn into a sound degree bound, so
919    /// `ceiling_with_distinct` is tighter than the degree-free ceiling
920    /// while staying above the truth.
921    #[test]
922    fn ceiling_with_distinct_is_at_most_unconstrained() {
923        let r = [1_000u64, 1_000];
924        let preds = [(0usize, 1usize)];
925        let with_d = LpJoinBound::with_distinct_counts(vec![10, 10]);
926        let a = with_d.ceiling_with_distinct(&r, &preds);
927        let b = LpJoinBound::new().ceiling(&r, &preds);
928        assert!(a <= b, "distinct-aware bound {a} must be tighter than {b}");
929        // 1000 rows over 10 distinct values: at worst 991 share one value.
930        assert_eq!(a, 991_000);
931    }
932
933    /// A key column collapses the ceiling to the other side's row count.
934    #[test]
935    fn ceiling_with_distinct_is_exact_on_a_key_join() {
936        let bound = LpJoinBound::with_distinct_counts(vec![10, 10]);
937        assert_eq!(bound.ceiling_with_distinct(&[10, 100], &[(0, 1)]), 100);
938    }
939}