use std::sync::Arc;
use linkrs_core::Value;
#[derive(Debug, Clone, Default)]
pub struct ColumnStatsSnapshot {
pub row_count: u64,
pub null_count: Option<u64>,
pub distinct_count: Option<u64>,
pub hll: Option<crate::stats::HyperLogLog>,
pub min_value: Option<Value>,
pub max_value: Option<Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TableCardinalitySnapshot {
pub live_rows: u64,
pub allocated_slots: u64,
pub shard_count: usize,
}
impl TableCardinalitySnapshot {
pub fn hole_count(&self) -> u64 {
self.allocated_slots.saturating_sub(self.live_rows)
}
pub fn hole_rate(&self) -> f64 {
if self.allocated_slots == 0 || self.live_rows >= self.allocated_slots {
0.0
} else {
1.0 - (self.live_rows as f64 / self.allocated_slots as f64)
}
}
pub fn is_empty(&self) -> bool {
self.live_rows == 0 && self.allocated_slots == 0
}
pub fn absorb(&mut self, other: &TableCardinalitySnapshot) {
self.live_rows += other.live_rows;
self.allocated_slots += other.allocated_slots;
self.shard_count += other.shard_count;
}
}
impl ColumnStatsSnapshot {
pub fn has_envelope(&self) -> bool {
self.min_value.is_some() || self.max_value.is_some()
}
pub fn absorb(&mut self, other: &ColumnStatsSnapshot) {
self.row_count += other.row_count;
if let Some(n) = other.null_count {
*self.null_count.get_or_insert(0) += n;
}
match (self.hll.clone(), other.hll.clone()) {
(Some(mut a), Some(b)) => {
a.merge(&b);
self.distinct_count = Some(a.estimate());
self.hll = Some(a);
}
(Some(a), None) => {
self.distinct_count = Some(a.estimate());
self.hll = Some(a);
}
(None, Some(b)) => {
self.distinct_count = Some(b.estimate());
self.hll = Some(b);
}
(None, None) => {
self.distinct_count = None;
self.hll = None;
}
}
merge_min(&mut self.min_value, other.min_value.clone());
merge_max(&mut self.max_value, other.max_value.clone());
}
pub fn set_hll(&mut self, hll: crate::stats::HyperLogLog) {
self.distinct_count = Some(hll.estimate());
self.hll = Some(hll);
}
}
pub(crate) fn merge_min(cur: &mut Option<Value>, candidate: Option<Value>) {
let Some(v) = candidate else { return };
match cur {
Some(c)
if crate::vertex::column_store::compare_values(c, &v)
!= std::cmp::Ordering::Greater => {}
_ => *cur = Some(v),
}
}
pub(crate) fn merge_max(cur: &mut Option<Value>, candidate: Option<Value>) {
let Some(v) = candidate else { return };
match cur {
Some(c)
if crate::vertex::column_store::compare_values(c, &v) != std::cmp::Ordering::Less => {}
_ => *cur = Some(v),
}
}
pub trait ColumnStatsReader: Send + Sync {
fn vertex_column_stats(
&self,
_space: &str,
_tag: &str,
_column: &str,
) -> Option<Arc<ColumnStatsSnapshot>> {
None
}
fn edge_column_stats(
&self,
_space: &str,
_edge_type: &str,
_column: &str,
) -> Option<Arc<ColumnStatsSnapshot>> {
None
}
fn vertex_table_stats(
&self,
_space: &str,
_tag: &str,
) -> Option<Arc<TableCardinalitySnapshot>> {
None
}
fn edge_table_stats(
&self,
_space: &str,
_edge_type: &str,
) -> Option<Arc<TableCardinalitySnapshot>> {
None
}
fn stats_epoch(&self) -> u64 {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(row_count: u64, min: Option<Value>, max: Option<Value>) -> ColumnStatsSnapshot {
ColumnStatsSnapshot {
row_count,
null_count: None,
distinct_count: None,
hll: None,
min_value: min,
max_value: max,
}
}
fn hll_with(values: &[i32]) -> crate::stats::HyperLogLog {
let mut hll = crate::stats::HyperLogLog::new();
for v in values {
hll.add_value(&Value::Int(*v));
}
hll
}
#[test]
fn absorb_widens_bounds_across_mixed_numeric_kinds() {
let mut acc = snap(10, Some(Value::Int(1)), Some(Value::BigInt(100)));
acc.null_count = Some(2);
acc.set_hll(hll_with(&(0..100).collect::<Vec<_>>()));
let mut other = snap(5, Some(Value::Double(-0.5)), Some(Value::SmallInt(50)));
other.null_count = Some(1);
other.set_hll(hll_with(&(100..150).collect::<Vec<_>>()));
acc.absorb(&other);
assert_eq!(acc.row_count, 15);
assert_eq!(acc.null_count, Some(3));
let est = acc.distinct_count.unwrap() as f64;
assert!((est - 150.0).abs() / 150.0 < 0.2, "estimate={}", est);
assert_eq!(acc.min_value, Some(Value::Double(-0.5)));
assert_eq!(acc.max_value, Some(Value::BigInt(100)));
}
#[test]
fn absorb_drops_partial_distinct_counts() {
let mut acc = snap(4, None, None);
acc.set_hll(hll_with(&[1, 2, 3]));
let expected = acc.distinct_count;
acc.absorb(&snap(2, None, None));
assert_eq!(acc.distinct_count, expected);
}
#[test]
fn absorb_without_hll_registers_degrades_to_unknown() {
let mut acc = snap(4, None, None);
acc.distinct_count = Some(9);
let mut other = snap(2, None, None);
other.distinct_count = Some(3);
acc.absorb(&other);
assert_eq!(acc.distinct_count, None);
}
#[test]
fn absorb_keeps_null_tracking_when_only_one_side_tracks() {
let mut acc = snap(1, None, None);
acc.null_count = None;
let mut other = snap(1, None, None);
other.null_count = Some(1);
acc.absorb(&other);
assert_eq!(acc.null_count, Some(1));
let mut acc = snap(1, None, None);
acc.null_count = Some(1);
acc.absorb(&snap(1, None, None));
assert_eq!(acc.null_count, Some(1));
}
#[test]
fn merge_helpers_compare_cross_type_numerically() {
let mut min = Some(Value::Int(-2));
merge_min(&mut min, Some(Value::Double(-2.5)));
assert_eq!(min, Some(Value::Double(-2.5)));
let mut max = Some(Value::Float(1.5));
merge_max(&mut max, Some(Value::SmallInt(2)));
assert_eq!(max, Some(Value::SmallInt(2)));
let mut max = Some(Value::string("P99"));
merge_max(&mut max, Some(Value::string("P100")));
assert_eq!(max, Some(Value::string("P99")));
}
#[test]
fn table_cardinality_absorb_sums_and_recomputes_hole_rate() {
let mut acc = TableCardinalitySnapshot {
live_rows: 70,
allocated_slots: 100,
shard_count: 2,
};
let other = TableCardinalitySnapshot {
live_rows: 30,
allocated_slots: 50,
shard_count: 1,
};
acc.absorb(&other);
assert_eq!(acc.live_rows, 100);
assert_eq!(acc.allocated_slots, 150);
assert_eq!(acc.shard_count, 3);
assert_eq!(acc.hole_count(), 50);
let rate = acc.hole_rate();
assert!((rate - (1.0 - 100.0 / 150.0)).abs() < 1e-9);
assert!(!acc.is_empty());
let empty = TableCardinalitySnapshot::default();
assert!(empty.is_empty());
assert_eq!(empty.hole_rate(), 0.0);
}
}