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 counters = hints
22            .iter()
23            .filter(|(kind, _)| *kind != SemanticHintKind::Identifier)
24            .map(|(kind, column)| BindingCounter {
25                binding: SemanticHintBinding {
26                    column: column.to_string(),
27                    kind,
28                    checked_values: 0,
29                    matched_values: 0,
30                    exact: true,
31                },
32            });
33        Self {
34            counters: counters.collect(),
35        }
36    }
37
38    /// Observe one cell using the same null and match predicates as the quality
39    /// calculators and their sample-based binding evidence.
40    pub fn observe(&mut self, column: &str, value: &str) {
41        if is_null_like_token(value.trim()) {
42            return;
43        }
44        for counter in self
45            .counters
46            .iter_mut()
47            .filter(|counter| counter.binding.column == column)
48        {
49            counter.binding.checked_values += 1;
50            if value_matches_hint(value, counter.binding.kind) {
51                counter.binding.matched_values += 1;
52            }
53        }
54    }
55
56    /// Return exact evidence for hints whose columns exist in the source.
57    /// Unknown names remain the schema validator's responsibility.
58    pub fn bindings<'a>(
59        &self,
60        column_names: impl IntoIterator<Item = &'a str>,
61    ) -> Vec<SemanticHintBinding> {
62        let names: std::collections::HashSet<&str> = column_names.into_iter().collect();
63        self.counters
64            .iter()
65            .filter(|counter| names.contains(counter.binding.column.as_str()))
66            .map(|counter| counter.binding.clone())
67            .collect()
68    }
69
70    pub fn merge(&mut self, other: &Self) {
71        for other_counter in &other.counters {
72            if let Some(counter) = self.counters.iter_mut().find(|candidate| {
73                candidate.binding.column == other_counter.binding.column
74                    && candidate.binding.kind == other_counter.binding.kind
75            }) {
76                counter.binding.checked_values += other_counter.binding.checked_values;
77                counter.binding.matched_values += other_counter.binding.matched_values;
78            } else {
79                self.counters.push(other_counter.clone());
80            }
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn accumulates_exact_counts_with_calculator_semantics() {
91        let hints = SemanticHints::new(vec!["amount".to_string()], vec![])
92            .with_temporal_columns(vec!["event".to_string()]);
93        let mut accumulator = ValueHintBindingAccumulator::new(&hints);
94        for value in ["1", " 2", " -inf ", "NULL"] {
95            accumulator.observe("amount", value);
96        }
97        for value in ["2020-01-01", "not-a-date", " 2021-01-01"] {
98            accumulator.observe("event", value);
99        }
100
101        let bindings = accumulator.bindings(["amount", "event"]);
102        assert_eq!(bindings[0].checked_values, 3);
103        assert_eq!(bindings[0].matched_values, 2);
104        assert!(bindings[0].exact);
105        assert_eq!(bindings[1].checked_values, 3);
106        assert_eq!(bindings[1].matched_values, 1);
107        assert!(bindings[1].exact);
108    }
109
110    #[test]
111    fn omits_unknown_columns() {
112        let hints = SemanticHints::new(vec!["missing".to_string()], vec![]);
113        let accumulator = ValueHintBindingAccumulator::new(&hints);
114        assert!(accumulator.bindings(["present"]).is_empty());
115    }
116}