use std::time::Instant;
use linkrs_core::value::Value;
use super::histogram::ValueHistogram;
#[derive(Debug, Clone)]
pub struct PropertyCombinationStats {
pub key: String,
pub tag_name: Option<String>,
pub properties: Vec<String>,
pub combined_distinct_values: u64,
pub sample_count: u64,
pub last_updated: Instant,
}
impl PropertyCombinationStats {
pub fn new(key: String, tag_name: Option<String>, properties: Vec<String>) -> Self {
Self {
key,
tag_name,
properties,
combined_distinct_values: 0,
sample_count: 0,
last_updated: Instant::now(),
}
}
pub fn update(&mut self, distinct_values: u64, sample_count: u64) {
if self.sample_count == 0 {
self.combined_distinct_values = distinct_values;
self.sample_count = sample_count;
} else {
let alpha = 0.3; self.combined_distinct_values = ((1.0 - alpha) * self.combined_distinct_values as f64
+ alpha * distinct_values as f64)
as u64;
self.sample_count = self.sample_count.saturating_add(sample_count);
}
self.last_updated = Instant::now();
}
pub fn is_stale(&self) -> bool {
self.last_updated.elapsed().as_secs() > 3600
}
pub fn estimated_cardinality(&self) -> u64 {
self.combined_distinct_values.max(1)
}
}
#[derive(Debug, Clone)]
pub struct PropertyStatistics {
pub property_name: String,
pub tag_name: Option<String>,
pub distinct_values: u64,
pub min_value: Option<Value>,
pub max_value: Option<Value>,
pub histogram: Option<ValueHistogram>,
pub use_histogram: bool,
pub null_count: Option<u64>,
pub total_rows: Option<u64>,
}
impl PropertyStatistics {
pub fn new(property_name: String, tag_name: Option<String>) -> Self {
Self {
property_name,
tag_name,
distinct_values: 0,
min_value: None,
max_value: None,
histogram: None,
use_histogram: false,
null_count: None,
total_rows: None,
}
}
pub fn with_histogram(mut self, histogram: ValueHistogram) -> Self {
self.histogram = Some(histogram);
self.use_histogram = true;
self
}
pub fn should_use_histogram(&self) -> bool {
self.use_histogram && self.histogram.is_some()
}
pub fn null_fraction(&self) -> Option<f64> {
let total = self.total_rows.filter(|&n| n > 0)?;
let nulls = self.null_count?;
Some((nulls.min(total) as f64 / total as f64).clamp(0.0, 1.0))
}
pub fn observe_value(&mut self, value: &Value) {
if order_key(value).is_none() {
return;
}
match &self.min_value {
None => self.min_value = Some(value.clone()),
Some(cur) => {
if let Some(ord) = envelope_compare(value, cur) {
if ord == std::cmp::Ordering::Less {
self.min_value = Some(value.clone());
}
}
}
}
match &self.max_value {
None => self.max_value = Some(value.clone()),
Some(cur) => {
if let Some(ord) = envelope_compare(value, cur) {
if ord == std::cmp::Ordering::Greater {
self.max_value = Some(value.clone());
}
}
}
}
}
}
fn envelope_compare(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
fn as_i64(value: &Value) -> Option<i64> {
match value {
Value::SmallInt(v) => Some(*v as i64),
Value::Int(v) => Some(*v as i64),
Value::BigInt(v) => Some(*v),
_ => None,
}
}
fn as_f64(value: &Value) -> Option<f64> {
match value {
Value::SmallInt(v) => Some(*v as f64),
Value::Int(v) => Some(*v as f64),
Value::BigInt(v) => Some(*v as f64),
Value::Float(v) => Some(*v as f64),
Value::Double(v) => Some(*v),
_ => None,
}
}
if let (Some(x), Some(y)) = (as_i64(a), as_i64(b)) {
return Some(x.cmp(&y));
}
if let (Some(x), Some(y)) = (as_f64(a), as_f64(b)) {
return Some(x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal));
}
match (a, b) {
(Value::String(x), Value::String(y)) => Some(x.cmp(y)),
(Value::FixedString(x), Value::FixedString(y)) => Some(x.cmp(y)),
(Value::String(x), Value::FixedString(y)) => Some(x.as_str().cmp(y.as_str())),
(Value::FixedString(x), Value::String(y)) => Some(x.as_str().cmp(y.as_str())),
_ => None,
}
}
fn order_key(value: &Value) -> Option<(char, String)> {
match value {
Value::Null(_) => None,
Value::SmallInt(i) => Some(('i', format!("{:+020}", *i as i64))),
Value::Int(i) => Some(('i', format!("{i:+020}"))),
Value::BigInt(i) => Some(('i', format!("{i:+020}"))),
Value::Float(f) => Some(('f', ordered_bits(f.to_bits() as u64, 32))),
Value::Double(d) => Some(('f', ordered_bits(d.to_bits(), 64))),
Value::String(s) => Some(('s', s.to_string())),
Value::FixedString(s) => Some(('s', s.clone())),
_ => None,
}
}
fn ordered_bits(bits: u64, width: usize) -> String {
let flipped = if bits >> (width - 1) & 1 == 0 {
bits | (1 << (width - 1))
} else {
!bits
};
format!("{flipped:0width$x}", width = width / 4)
}
impl Default for PropertyStatistics {
fn default() -> Self {
Self::new(String::new(), None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observe_value_tracks_numeric_envelope() {
let mut stat = PropertyStatistics::new("age".into(), Some("person".to_string()));
for v in [
Value::Int(30),
Value::Int(5),
Value::Int(42),
Value::Int(17),
] {
stat.observe_value(&v);
}
assert_eq!(stat.min_value, Some(Value::Int(5)));
assert_eq!(stat.max_value, Some(Value::Int(42)));
}
#[test]
fn observe_value_ignores_null_and_keeps_first_family() {
let mut stat = PropertyStatistics::new("v".into(), None);
stat.observe_value(&Value::Null(linkrs_core::value::NullType::Null));
assert_eq!(stat.min_value, None);
stat.observe_value(&Value::Double(1.5));
stat.observe_value(&Value::Int(100));
assert_eq!(stat.min_value, Some(Value::Double(1.5)));
}
#[test]
fn observe_value_orders_strings_lexicographically() {
let mut stat = PropertyStatistics::new("name".into(), None);
for s in ["mango", "apple", "zebra"] {
stat.observe_value(&Value::string(s));
}
assert_eq!(stat.min_value, Some(Value::string("apple")));
assert_eq!(stat.max_value, Some(Value::string("zebra")));
}
#[test]
fn null_fraction_clamps_and_requires_rows() {
let mut stat = PropertyStatistics::new("age".into(), None);
assert_eq!(stat.null_fraction(), None);
stat.total_rows = Some(0);
stat.null_count = Some(0);
assert_eq!(stat.null_fraction(), None);
stat.total_rows = Some(100);
stat.null_count = Some(5);
assert_eq!(stat.null_fraction(), Some(0.05));
stat.null_count = Some(200);
assert_eq!(stat.null_fraction(), Some(1.0));
}
}