1use std::sync::Arc;
13
14use linkrs_core::Value;
15
16#[derive(Debug, Clone, Default)]
19pub struct ColumnStatsSnapshot {
20 pub row_count: u64,
22 pub null_count: Option<u64>,
24 pub distinct_count: Option<u64>,
27 pub hll: Option<crate::stats::HyperLogLog>,
29 pub min_value: Option<Value>,
32 pub max_value: Option<Value>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct TableCardinalitySnapshot {
45 pub live_rows: u64,
47 pub allocated_slots: u64,
49 pub shard_count: usize,
51}
52
53impl TableCardinalitySnapshot {
54 pub fn hole_count(&self) -> u64 {
56 self.allocated_slots.saturating_sub(self.live_rows)
57 }
58
59 pub fn hole_rate(&self) -> f64 {
61 if self.allocated_slots == 0 || self.live_rows >= self.allocated_slots {
62 0.0
63 } else {
64 1.0 - (self.live_rows as f64 / self.allocated_slots as f64)
65 }
66 }
67
68 pub fn is_empty(&self) -> bool {
70 self.live_rows == 0 && self.allocated_slots == 0
71 }
72
73 pub fn absorb(&mut self, other: &TableCardinalitySnapshot) {
76 self.live_rows += other.live_rows;
77 self.allocated_slots += other.allocated_slots;
78 self.shard_count += other.shard_count;
79 }
80}
81
82impl ColumnStatsSnapshot {
83 pub fn has_envelope(&self) -> bool {
86 self.min_value.is_some() || self.max_value.is_some()
87 }
88
89 pub fn absorb(&mut self, other: &ColumnStatsSnapshot) {
101 self.row_count += other.row_count;
102 if let Some(n) = other.null_count {
103 *self.null_count.get_or_insert(0) += n;
104 }
105 match (self.hll.clone(), other.hll.clone()) {
106 (Some(mut a), Some(b)) => {
107 a.merge(&b);
108 self.distinct_count = Some(a.estimate());
109 self.hll = Some(a);
110 }
111 (Some(a), None) => {
112 self.distinct_count = Some(a.estimate());
113 self.hll = Some(a);
114 }
115 (None, Some(b)) => {
116 self.distinct_count = Some(b.estimate());
117 self.hll = Some(b);
118 }
119 (None, None) => {
120 self.distinct_count = None;
121 self.hll = None;
122 }
123 }
124 merge_min(&mut self.min_value, other.min_value.clone());
125 merge_max(&mut self.max_value, other.max_value.clone());
126 }
127
128 pub fn set_hll(&mut self, hll: crate::stats::HyperLogLog) {
130 self.distinct_count = Some(hll.estimate());
131 self.hll = Some(hll);
132 }
133}
134
135pub(crate) fn merge_min(cur: &mut Option<Value>, candidate: Option<Value>) {
139 let Some(v) = candidate else { return };
140 match cur {
141 Some(c)
142 if crate::vertex::column_store::compare_values(c, &v)
143 != std::cmp::Ordering::Greater => {}
144 _ => *cur = Some(v),
145 }
146}
147
148pub(crate) fn merge_max(cur: &mut Option<Value>, candidate: Option<Value>) {
150 let Some(v) = candidate else { return };
151 match cur {
152 Some(c)
153 if crate::vertex::column_store::compare_values(c, &v) != std::cmp::Ordering::Less => {}
154 _ => *cur = Some(v),
155 }
156}
157
158pub trait ColumnStatsReader: Send + Sync {
163 fn vertex_column_stats(
165 &self,
166 _space: &str,
167 _tag: &str,
168 _column: &str,
169 ) -> Option<Arc<ColumnStatsSnapshot>> {
170 None
171 }
172
173 fn edge_column_stats(
175 &self,
176 _space: &str,
177 _edge_type: &str,
178 _column: &str,
179 ) -> Option<Arc<ColumnStatsSnapshot>> {
180 None
181 }
182
183 fn vertex_table_stats(
185 &self,
186 _space: &str,
187 _tag: &str,
188 ) -> Option<Arc<TableCardinalitySnapshot>> {
189 None
190 }
191
192 fn edge_table_stats(
199 &self,
200 _space: &str,
201 _edge_type: &str,
202 ) -> Option<Arc<TableCardinalitySnapshot>> {
203 None
204 }
205
206 fn stats_epoch(&self) -> u64 {
216 0
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn snap(row_count: u64, min: Option<Value>, max: Option<Value>) -> ColumnStatsSnapshot {
225 ColumnStatsSnapshot {
226 row_count,
227 null_count: None,
228 distinct_count: None,
229 hll: None,
230 min_value: min,
231 max_value: max,
232 }
233 }
234
235 fn hll_with(values: &[i32]) -> crate::stats::HyperLogLog {
236 let mut hll = crate::stats::HyperLogLog::new();
237 for v in values {
238 hll.add_value(&Value::Int(*v));
239 }
240 hll
241 }
242
243 #[test]
244 fn absorb_widens_bounds_across_mixed_numeric_kinds() {
245 let mut acc = snap(10, Some(Value::Int(1)), Some(Value::BigInt(100)));
246 acc.null_count = Some(2);
247 acc.set_hll(hll_with(&(0..100).collect::<Vec<_>>()));
248
249 let mut other = snap(5, Some(Value::Double(-0.5)), Some(Value::SmallInt(50)));
250 other.null_count = Some(1);
251 other.set_hll(hll_with(&(100..150).collect::<Vec<_>>()));
252 acc.absorb(&other);
253
254 assert_eq!(acc.row_count, 15);
255 assert_eq!(acc.null_count, Some(3));
256 let est = acc.distinct_count.unwrap() as f64;
258 assert!((est - 150.0).abs() / 150.0 < 0.2, "estimate={}", est);
259 assert_eq!(acc.min_value, Some(Value::Double(-0.5)));
260 assert_eq!(acc.max_value, Some(Value::BigInt(100)));
261 }
262
263 #[test]
264 fn absorb_drops_partial_distinct_counts() {
265 let mut acc = snap(4, None, None);
266 acc.set_hll(hll_with(&[1, 2, 3]));
267 let expected = acc.distinct_count;
268 acc.absorb(&snap(2, None, None));
269 assert_eq!(acc.distinct_count, expected);
270 }
271
272 #[test]
273 fn absorb_without_hll_registers_degrades_to_unknown() {
274 let mut acc = snap(4, None, None);
276 acc.distinct_count = Some(9);
277 let mut other = snap(2, None, None);
278 other.distinct_count = Some(3);
279 acc.absorb(&other);
280 assert_eq!(acc.distinct_count, None);
281 }
282
283 #[test]
284 fn absorb_keeps_null_tracking_when_only_one_side_tracks() {
285 let mut acc = snap(1, None, None);
286 acc.null_count = None;
287 let mut other = snap(1, None, None);
288 other.null_count = Some(1);
289 acc.absorb(&other);
290 assert_eq!(acc.null_count, Some(1));
291
292 let mut acc = snap(1, None, None);
294 acc.null_count = Some(1);
295 acc.absorb(&snap(1, None, None));
296 assert_eq!(acc.null_count, Some(1));
297 }
298
299 #[test]
300 fn merge_helpers_compare_cross_type_numerically() {
301 let mut min = Some(Value::Int(-2));
302 merge_min(&mut min, Some(Value::Double(-2.5)));
303 assert_eq!(min, Some(Value::Double(-2.5)));
304
305 let mut max = Some(Value::Float(1.5));
306 merge_max(&mut max, Some(Value::SmallInt(2)));
307 assert_eq!(max, Some(Value::SmallInt(2)));
308
309 let mut max = Some(Value::string("P99"));
311 merge_max(&mut max, Some(Value::string("P100")));
312 assert_eq!(max, Some(Value::string("P99")));
313 }
314
315 #[test]
316 fn table_cardinality_absorb_sums_and_recomputes_hole_rate() {
317 let mut acc = TableCardinalitySnapshot {
318 live_rows: 70,
319 allocated_slots: 100,
320 shard_count: 2,
321 };
322 let other = TableCardinalitySnapshot {
323 live_rows: 30,
324 allocated_slots: 50,
325 shard_count: 1,
326 };
327 acc.absorb(&other);
328 assert_eq!(acc.live_rows, 100);
329 assert_eq!(acc.allocated_slots, 150);
330 assert_eq!(acc.shard_count, 3);
331 assert_eq!(acc.hole_count(), 50);
332 let rate = acc.hole_rate();
333 assert!((rate - (1.0 - 100.0 / 150.0)).abs() < 1e-9);
334 assert!(!acc.is_empty());
335 let empty = TableCardinalitySnapshot::default();
336 assert!(empty.is_empty());
337 assert_eq!(empty.hole_rate(), 0.0);
338 }
339}