Skip to main content

dataprof_runtime/
hint_binding.rs

1//! Exact, bounded-memory semantic-hint binding counts for streaming engines.
2
3use dataprof_core::{SemanticHintBinding, SemanticHintKind, SemanticHints};
4use dataprof_metrics::{is_null_like_token, value_matches_hint};
5
6#[derive(Debug, Clone)]
7struct BindingCounter {
8    binding: SemanticHintBinding,
9}
10
11/// Accumulates value-driven hint evidence while an engine scans the full data.
12///
13/// The number of configured hints bounds memory use; no cell values are kept.
14#[derive(Debug, Clone, Default)]
15pub struct ValueHintBindingAccumulator {
16    counters: Vec<BindingCounter>,
17}
18
19impl ValueHintBindingAccumulator {
20    pub fn new(hints: &SemanticHints) -> Self {
21        let positive = hints.positive_columns.iter().map(|column| BindingCounter {
22            binding: SemanticHintBinding {
23                column: column.clone(),
24                kind: SemanticHintKind::Positive,
25                checked_values: 0,
26                matched_values: 0,
27                exact: true,
28            },
29        });
30        let temporal = hints.temporal_columns.iter().map(|column| BindingCounter {
31            binding: SemanticHintBinding {
32                column: column.clone(),
33                kind: SemanticHintKind::Temporal,
34                checked_values: 0,
35                matched_values: 0,
36                exact: true,
37            },
38        });
39        Self {
40            counters: positive.chain(temporal).collect(),
41        }
42    }
43
44    /// Observe one cell using the same null and match predicates as the quality
45    /// calculators and their sample-based binding evidence.
46    pub fn observe(&mut self, column: &str, value: &str) {
47        if is_null_like_token(value.trim()) {
48            return;
49        }
50        for counter in self
51            .counters
52            .iter_mut()
53            .filter(|counter| counter.binding.column == column)
54        {
55            counter.binding.checked_values += 1;
56            if value_matches_hint(value, counter.binding.kind) {
57                counter.binding.matched_values += 1;
58            }
59        }
60    }
61
62    /// Return exact evidence for hints whose columns exist in the source.
63    /// Unknown names remain the schema validator's responsibility.
64    pub fn bindings<'a>(
65        &self,
66        column_names: impl IntoIterator<Item = &'a str>,
67    ) -> Vec<SemanticHintBinding> {
68        let names: std::collections::HashSet<&str> = column_names.into_iter().collect();
69        self.counters
70            .iter()
71            .filter(|counter| names.contains(counter.binding.column.as_str()))
72            .map(|counter| counter.binding.clone())
73            .collect()
74    }
75
76    pub fn merge(&mut self, other: &Self) {
77        for other_counter in &other.counters {
78            if let Some(counter) = self.counters.iter_mut().find(|candidate| {
79                candidate.binding.column == other_counter.binding.column
80                    && candidate.binding.kind == other_counter.binding.kind
81            }) {
82                counter.binding.checked_values += other_counter.binding.checked_values;
83                counter.binding.matched_values += other_counter.binding.matched_values;
84            } else {
85                self.counters.push(other_counter.clone());
86            }
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn accumulates_exact_counts_with_calculator_semantics() {
97        let hints = SemanticHints::new(vec!["amount".to_string()], vec![])
98            .with_temporal_columns(vec!["event".to_string()]);
99        let mut accumulator = ValueHintBindingAccumulator::new(&hints);
100        for value in ["1", " 2", "NULL"] {
101            accumulator.observe("amount", value);
102        }
103        for value in ["2020-01-01", "not-a-date", " 2021-01-01"] {
104            accumulator.observe("event", value);
105        }
106
107        let bindings = accumulator.bindings(["amount", "event"]);
108        assert_eq!(bindings[0].checked_values, 2);
109        assert_eq!(bindings[0].matched_values, 1);
110        assert!(bindings[0].exact);
111        assert_eq!(bindings[1].checked_values, 3);
112        assert_eq!(bindings[1].matched_values, 1);
113        assert!(bindings[1].exact);
114    }
115
116    #[test]
117    fn omits_unknown_columns() {
118        let hints = SemanticHints::new(vec!["missing".to_string()], vec![]);
119        let accumulator = ValueHintBindingAccumulator::new(&hints);
120        assert!(accumulator.bindings(["present"]).is_empty());
121    }
122}