use crate::{Error, Result};
pub trait UpperBound {
fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64;
}
pub struct ProductBound;
impl UpperBound for ProductBound {
fn ceiling(&self, relations: &[u64], _eq: &[(usize, usize)]) -> u64 {
relations.iter().fold(1u64, |acc, &n| acc.saturating_mul(n))
}
}
pub struct ChainBound {
pub distinct_counts: Vec<u64>,
}
impl ChainBound {
pub fn new(distinct_counts: Vec<u64>) -> Self {
Self { distinct_counts }
}
}
impl UpperBound for ChainBound {
fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64 {
if relations.is_empty() {
return 0;
}
if equality_predicates.is_empty() {
return ProductBound.ceiling(relations, &[]);
}
degree_graph(relations, equality_predicates, Some(&self.distinct_counts)).ceiling()
}
}
fn degree_graph(
relations: &[u64],
equality_predicates: &[(usize, usize)],
distinct_counts: Option<&[u64]>,
) -> crate::degree::JoinGraph {
use crate::degree::{AttributeDegree, JoinGraph, JoinRelation};
let n = relations.len();
let mut built: Vec<JoinRelation> = relations
.iter()
.map(|&rows| JoinRelation::new(rows))
.collect();
for (attribute, &(i, j)) in equality_predicates.iter().enumerate() {
if i >= n || j >= n || i == j {
continue;
}
let attribute = attribute as u32;
for endpoint in [i, j] {
let rows = relations[endpoint];
let degree = match distinct_counts.and_then(|d| d.get(endpoint).copied()) {
Some(distinct) => AttributeDegree::from_distinct(rows, distinct),
None => AttributeDegree::unknown(rows),
};
built[endpoint] = std::mem::replace(&mut built[endpoint], JoinRelation::new(rows))
.with_degree(attribute, degree);
}
}
let mut graph = JoinGraph::new(built);
for (attribute, &(i, j)) in equality_predicates.iter().enumerate() {
graph = graph.with_edge(i, j, attribute as u32);
}
graph
}
#[deprecated(
since = "1.2.0",
note = "the min*max shortcut was unsound for 3+ relations and now simply returns \
ProductBound; use samkhya_core::degree::JoinGraph for a bound that is both \
provable and tighter"
)]
pub struct AgmBound;
#[allow(deprecated)]
impl UpperBound for AgmBound {
fn ceiling(&self, relations: &[u64], _equality_predicates: &[(usize, usize)]) -> u64 {
ProductBound.ceiling(relations, &[])
}
}
pub fn clamp_estimate(estimate: f64, ceiling: u64) -> Result<u64> {
let clamped = estimate.max(0.0).min(u64::MAX as f64) as u64;
if clamped <= ceiling {
Ok(clamped)
} else {
Err(Error::LpBoundExceeded {
estimate,
ceiling: ceiling as f64,
})
}
}
pub fn saturating_clamp(estimate: f64, ceiling: u64) -> u64 {
let clamped = estimate.max(0.0).min(u64::MAX as f64) as u64;
clamped.min(ceiling)
}
#[cfg(feature = "lp_solver")]
pub struct LpJoinBound {
distinct_counts: Vec<u64>,
}
#[cfg(feature = "lp_solver")]
impl Default for LpJoinBound {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "lp_solver")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HyperRelation {
pub rows: u64,
pub attributes: Vec<u32>,
pub has_private_attributes: bool,
}
#[cfg(feature = "lp_solver")]
impl HyperRelation {
pub fn new(rows: u64, attributes: Vec<u32>) -> Self {
Self {
rows,
attributes,
has_private_attributes: true,
}
}
pub fn projected(rows: u64, attributes: Vec<u32>) -> Self {
Self {
rows,
attributes,
has_private_attributes: false,
}
}
}
#[cfg(feature = "lp_solver")]
impl LpJoinBound {
pub fn new() -> Self {
Self {
distinct_counts: Vec::new(),
}
}
pub fn with_distinct_counts(distinct_counts: Vec<u64>) -> Self {
Self { distinct_counts }
}
pub fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64 {
if relations.is_empty() {
return 0;
}
if equality_predicates.is_empty() {
return ProductBound.ceiling(relations, &[]);
}
degree_graph(relations, equality_predicates, None).ceiling()
}
pub fn ceiling_with_distinct(
&self,
relations: &[u64],
equality_predicates: &[(usize, usize)],
) -> u64 {
if relations.is_empty() {
return 0;
}
if equality_predicates.is_empty() {
return ProductBound.ceiling(relations, &[]);
}
degree_graph(relations, equality_predicates, Some(&self.distinct_counts)).ceiling()
}
pub fn ceiling_hypergraph(&self, relations: &[HyperRelation]) -> u64 {
let rows: Vec<u64> = relations.iter().map(|r| r.rows).collect();
let product = ProductBound.ceiling(&rows, &[]);
if relations.is_empty() {
return 0;
}
if relations.iter().all(|r| r.has_private_attributes) {
return product;
}
match self.solve_hypergraph(relations) {
Some(value) => value.min(product),
None => product,
}
}
fn solve_hypergraph(&self, relations: &[HyperRelation]) -> Option<u64> {
use good_lp::{
Expression, ProblemVariables, Solution, SolverModel, default_solver, variable,
};
let mut vars = ProblemVariables::new();
let mut handles = Vec::with_capacity(relations.len());
let mut objective = Expression::with_capacity(relations.len());
for relation in relations {
let v = vars.add(variable().min(0.0));
handles.push(v);
let size = relation.rows as f64;
let coefficient = if size <= 1.0 { 0.0 } else { size.ln() };
objective.add_mul(coefficient, v);
}
let mut model = vars.minimise(&objective).using(default_solver);
let attributes: std::collections::BTreeSet<u32> = relations
.iter()
.flat_map(|r| r.attributes.iter().copied())
.collect();
for attribute in attributes {
let mut lhs = Expression::with_capacity(relations.len());
let mut covered = false;
for (idx, relation) in relations.iter().enumerate() {
if relation.attributes.contains(&attribute) {
lhs.add_mul(1.0, handles[idx]);
covered = true;
}
}
if covered {
model = model.with(lhs.geq(1.0));
}
}
for (idx, relation) in relations.iter().enumerate() {
if relation.has_private_attributes || relation.attributes.is_empty() {
let lhs: Expression = handles[idx].into();
model = model.with(lhs.geq(1.0));
}
}
let solution = model.solve().ok()?;
let optimum = solution.eval(&objective).exp();
if !optimum.is_finite() || optimum < 0.0 {
return None;
}
let optimum = optimum.max(1.0);
if optimum >= u64::MAX as f64 {
return Some(u64::MAX);
}
let rounded = optimum.round();
let epsilon = 1e-9_f64.max(optimum.abs() * 1e-12);
Some(if (optimum - rounded).abs() <= epsilon {
rounded as u64
} else {
optimum.ceil() as u64
})
}
}
#[cfg(feature = "lp_solver")]
impl UpperBound for LpJoinBound {
fn ceiling(&self, relations: &[u64], equality_predicates: &[(usize, usize)]) -> u64 {
self.ceiling(relations, equality_predicates)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn product_bound_two_relations() {
assert_eq!(ProductBound.ceiling(&[100, 200], &[]), 20_000);
}
#[test]
fn product_bound_overflow_saturates() {
assert_eq!(ProductBound.ceiling(&[u64::MAX, 2], &[]), u64::MAX);
}
#[test]
fn product_bound_empty_relations() {
assert_eq!(ProductBound.ceiling(&[], &[]), 1);
}
#[test]
#[allow(deprecated)]
fn agm_no_predicates_falls_back_to_product() {
assert_eq!(AgmBound.ceiling(&[10, 20, 30], &[]), 10 * 20 * 30);
}
#[test]
#[allow(deprecated)]
fn agm_now_equals_the_product() {
let r = [1_000u64, 1_000_000];
assert_eq!(
AgmBound.ceiling(&r, &[(0, 1)]),
ProductBound.ceiling(&r, &[])
);
assert_eq!(AgmBound.ceiling(&[3, 3, 3], &[(0, 1), (1, 2)]), 27);
}
#[test]
fn clamp_within_ceiling() {
assert_eq!(clamp_estimate(500.0, 1000).unwrap(), 500);
}
#[test]
fn clamp_exceeds_ceiling_errors() {
let err = clamp_estimate(1500.0, 1000).unwrap_err();
match err {
Error::LpBoundExceeded { estimate, ceiling } => {
assert_eq!(estimate, 1500.0);
assert_eq!(ceiling, 1000.0);
}
other => panic!("wrong error variant: {other:?}"),
}
}
#[test]
fn chain_bound_tighter_than_product() {
let r = [1_000u64, 1_000];
let cb = ChainBound::new(vec![100, 100]);
let bound = cb.ceiling(&r, &[(0, 1)]);
assert_eq!(bound, 901_000);
let product = ProductBound.ceiling(&r, &[]);
assert!(bound < product);
}
#[test]
fn chain_bound_is_exact_on_a_foreign_key_join() {
let cb = ChainBound::new(vec![10, 10]);
assert_eq!(cb.ceiling(&[10, 100], &[(0, 1)]), 100);
}
#[test]
fn chain_bound_three_table_chain_stays_below_product() {
let r = [1_000u64, 2_000, 500];
let cb = ChainBound::new(vec![100, 100, 100]);
let bound = cb.ceiling(&r, &[(0, 1), (1, 2)]);
let product = ProductBound.ceiling(&r, &[]);
assert!(
bound < product,
"chain bound {bound} should be below product {product}"
);
assert!(bound > 100_000);
}
#[test]
fn chain_bound_is_sound_under_skew() {
let cb = ChainBound::new(vec![5, 5]);
let bound = cb.ceiling(&[20, 20], &[(0, 1)]);
assert!(
bound >= 260,
"skewed ceiling {bound} is below the true cardinality 260"
);
}
#[test]
fn chain_bound_no_predicates_falls_back() {
let cb = ChainBound::new(vec![10, 20, 30]);
assert_eq!(cb.ceiling(&[10, 20, 30], &[]), 10 * 20 * 30);
}
#[test]
fn chain_bound_missing_distinct_count_defaults_to_one() {
let cb = ChainBound::new(vec![]);
let bound = cb.ceiling(&[100, 100], &[(0, 1)]);
assert_eq!(bound, 10_000); }
#[test]
fn saturating_clamp_saturates() {
assert_eq!(saturating_clamp(500.0, 1000), 500);
assert_eq!(saturating_clamp(2000.0, 1000), 1000);
assert_eq!(saturating_clamp(-5.0, 1000), 0);
assert_eq!(saturating_clamp(f64::NAN, 1000), 0);
}
}
#[cfg(all(test, feature = "lp_solver"))]
mod lp_tests {
use super::*;
#[test]
fn two_table_join_without_degrees_is_the_product() {
let r = [1_000u64, 1_000_000u64];
let lp = LpJoinBound::new();
assert_eq!(lp.ceiling(&r, &[(0, 1)]), ProductBound.ceiling(&r, &[]));
}
#[test]
fn triangle_hypergraph_matches_agm() {
let tri = vec![
HyperRelation::projected(1_000, vec![0, 1]),
HyperRelation::projected(1_000, vec![1, 2]),
HyperRelation::projected(1_000, vec![2, 0]),
];
let bound = LpJoinBound::new().ceiling_hypergraph(&tri);
assert!(
(31_000u64..=32_000u64).contains(&bound),
"expected ≈31_623, got {bound}"
);
assert!(bound < ProductBound.ceiling(&[1_000, 1_000, 1_000], &[]));
}
#[test]
fn triangle_with_private_columns_is_the_product() {
let tri = vec![
HyperRelation::new(1_000, vec![0, 1]),
HyperRelation::new(1_000, vec![1, 2]),
HyperRelation::new(1_000, vec![2, 0]),
];
assert_eq!(
LpJoinBound::new().ceiling_hypergraph(&tri),
ProductBound.ceiling(&[1_000, 1_000, 1_000], &[])
);
}
#[test]
fn square_hypergraph_matches_agm() {
let square = vec![
HyperRelation::projected(100, vec![0, 1]),
HyperRelation::projected(100, vec![1, 2]),
HyperRelation::projected(100, vec![2, 3]),
HyperRelation::projected(100, vec![3, 0]),
];
let bound = LpJoinBound::new().ceiling_hypergraph(&square);
assert!(
(5_000..=15_000).contains(&bound),
"expected ≈10_000, got {bound}"
);
assert!(bound < ProductBound.ceiling(&[100, 100, 100, 100], &[]));
}
#[test]
fn disconnected_components_multiply() {
let graph = vec![
HyperRelation::projected(100, vec![0]),
HyperRelation::projected(200, vec![0]),
HyperRelation::projected(50, vec![1]),
HyperRelation::projected(70, vec![1]),
];
let bound = LpJoinBound::new().ceiling_hypergraph(&graph);
assert!(
(4_900..=5_100).contains(&bound),
"expected ≈5000, got {bound}"
);
}
#[test]
fn isolated_relation_contributes_row_count() {
let graph = vec![
HyperRelation::projected(100, vec![0]),
HyperRelation::projected(200, vec![0]),
HyperRelation::projected(99, Vec::new()),
];
let bound = LpJoinBound::new().ceiling_hypergraph(&graph);
assert!(
(9_800..=10_000).contains(&bound),
"expected ≈9_900, got {bound}"
);
}
#[test]
fn hypergraph_never_exceeds_the_product() {
let graph = vec![
HyperRelation::projected(37, vec![0]),
HyperRelation::new(41, vec![0, 1]),
HyperRelation::projected(43, vec![1]),
];
let bound = LpJoinBound::new().ceiling_hypergraph(&graph);
assert!(bound <= ProductBound.ceiling(&[37, 41, 43], &[]));
}
#[test]
fn lp_bound_dominates_product() {
let r = [37u64, 41, 43, 47, 53];
let preds = [(0usize, 1usize), (1, 2), (2, 3), (3, 4)];
let lp = LpJoinBound::new();
let bound = lp.ceiling(&r, &preds);
let product = ProductBound.ceiling(&r, &preds);
assert!(
bound <= product,
"LP bound {bound} must be ≤ product {product}"
);
}
#[test]
fn empty_relations_zero() {
let lp = LpJoinBound::new();
assert_eq!(lp.ceiling(&[], &[]), 0);
}
#[test]
fn no_predicates_returns_product() {
let lp = LpJoinBound::new();
let r = [10u64, 20, 30];
assert_eq!(lp.ceiling(&r, &[]), 6_000);
}
#[test]
fn ceiling_with_distinct_is_at_most_unconstrained() {
let r = [1_000u64, 1_000];
let preds = [(0usize, 1usize)];
let with_d = LpJoinBound::with_distinct_counts(vec![10, 10]);
let a = with_d.ceiling_with_distinct(&r, &preds);
let b = LpJoinBound::new().ceiling(&r, &preds);
assert!(a <= b, "distinct-aware bound {a} must be tighter than {b}");
assert_eq!(a, 991_000);
}
#[test]
fn ceiling_with_distinct_is_exact_on_a_key_join() {
let bound = LpJoinBound::with_distinct_counts(vec![10, 10]);
assert_eq!(bound.ceiling_with_distinct(&[10, 100], &[(0, 1)]), 100);
}
}