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}