Skip to main content

dataprof_core/
semantic.rs

1use serde::{Deserialize, Serialize};
2
3use crate::errors::DataProfilerError;
4
5/// The kind of a semantic hint, used for diagnostics and binding evidence.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum SemanticHintKind {
9    /// A [`SemanticHints::positive_columns`] entry.
10    Positive,
11    /// An [`SemanticHints::identifier_columns`] entry.
12    Identifier,
13    /// A [`SemanticHints::temporal_columns`] entry.
14    Temporal,
15}
16
17impl SemanticHintKind {
18    /// The public configuration field name that carries this hint kind.
19    pub fn field_name(self) -> &'static str {
20        match self {
21            Self::Positive => "positive_columns",
22            Self::Identifier => "identifier_columns",
23            Self::Temporal => "temporal_columns",
24        }
25    }
26}
27
28/// Per-column evidence of whether a semantic hint actually bound to any value.
29///
30/// A hint expresses user domain knowledge about a column; this records how much
31/// of the column that knowledge matched. `matched_values` counts the values of
32/// the kind the hint expects (numeric for `positive`, date-parseable for
33/// `temporal`, every non-null value for `identifier`, which coerces the column).
34///
35/// `exact` distinguishes a full-data count from a sampled one: a hint that
36/// matched nothing (`matched_values == 0`) is only *proven* inert when the count
37/// covers every row. A sampled zero is recorded but never treated as an error,
38/// because absence in a sample is not proof of absence in the data.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct SemanticHintBinding {
41    /// The hinted column this evidence is for.
42    pub column: String,
43    /// Which hint list named the column.
44    pub kind: SemanticHintKind,
45    /// Non-null values considered.
46    pub checked_values: usize,
47    /// Values that matched the hint's expected kind.
48    pub matched_values: usize,
49    /// Whether the counts cover every row (`true`) or a sample (`false`).
50    pub exact: bool,
51}
52
53impl SemanticHintBinding {
54    /// Whether this hint is *proven* inert: it was assessed over the full data
55    /// (`exact`), had values to consider (`checked_values > 0`), yet matched
56    /// none of them.
57    pub fn is_proven_inert(&self) -> bool {
58        self.exact && self.checked_values > 0 && self.matched_values == 0
59    }
60}
61
62/// User-supplied semantic hints that affect profiling and quality metrics.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct SemanticHints {
66    pub positive_columns: Vec<String>,
67    pub identifier_columns: Vec<String>,
68    pub temporal_columns: Vec<String>,
69}
70
71impl SemanticHints {
72    pub fn new(positive_columns: Vec<String>, identifier_columns: Vec<String>) -> Self {
73        Self {
74            positive_columns,
75            identifier_columns,
76            temporal_columns: Vec::new(),
77        }
78    }
79
80    pub fn with_temporal_columns(mut self, temporal_columns: Vec<String>) -> Self {
81        self.temporal_columns = temporal_columns;
82        self
83    }
84
85    pub fn is_empty(&self) -> bool {
86        self.positive_columns.is_empty()
87            && self.identifier_columns.is_empty()
88            && self.temporal_columns.is_empty()
89    }
90
91    pub fn is_identifier_column(&self, column_name: &str) -> bool {
92        self.identifier_columns
93            .iter()
94            .any(|candidate| candidate == column_name)
95    }
96
97    pub fn is_positive_column(&self, column_name: &str) -> bool {
98        self.positive_columns
99            .iter()
100            .any(|candidate| candidate == column_name)
101    }
102
103    pub fn is_temporal_column(&self, column_name: &str) -> bool {
104        self.temporal_columns
105            .iter()
106            .any(|candidate| candidate == column_name)
107    }
108
109    /// Every `(kind, column_name)` pair across all three hint lists.
110    pub fn iter(&self) -> impl Iterator<Item = (SemanticHintKind, &str)> {
111        let positive = self
112            .positive_columns
113            .iter()
114            .map(|c| (SemanticHintKind::Positive, c.as_str()));
115        let identifier = self
116            .identifier_columns
117            .iter()
118            .map(|c| (SemanticHintKind::Identifier, c.as_str()));
119        let temporal = self
120            .temporal_columns
121            .iter()
122            .map(|c| (SemanticHintKind::Temporal, c.as_str()));
123        positive.chain(identifier).chain(temporal)
124    }
125
126    /// Fail loudly when a hint names a column that is not in the schema.
127    ///
128    /// A hint is the user's chosen alternative to overconfident inference, so a
129    /// hint that cannot bind must not vanish: a typo'd or stale column name is a
130    /// mistake the profiler should surface, not silently discard.
131    pub fn validate_names(&self, column_names: &[&str]) -> Result<(), DataProfilerError> {
132        let mut unknown: Vec<(SemanticHintKind, &str)> = self
133            .iter()
134            .filter(|(_, name)| !column_names.contains(name))
135            .collect();
136        if unknown.is_empty() {
137            return Ok(());
138        }
139        // Stable, readable ordering regardless of hint-list order.
140        unknown.sort_unstable_by(|a, b| a.1.cmp(b.1).then(a.0.field_name().cmp(b.0.field_name())));
141
142        let details = unknown
143            .iter()
144            .map(|(kind, name)| format!("'{}' ({})", name, kind.field_name()))
145            .collect::<Vec<_>>()
146            .join(", ");
147        let mut available: Vec<&str> = column_names.to_vec();
148        available.sort_unstable();
149        let available = available
150            .iter()
151            .map(|c| format!("'{c}'"))
152            .collect::<Vec<_>>()
153            .join(", ");
154
155        Err(DataProfilerError::InvalidSemanticHint {
156            message: format!("semantic hint names not found in the data: {details}"),
157            suggestion: format!(
158                "Available columns: [{available}]. Check spelling and case, or remove the hint."
159            ),
160        })
161    }
162
163    /// Reject value-driven hints when the Quality metric pack did not run.
164    ///
165    /// Positive and temporal hints only affect quality calculators. Accepting
166    /// them without a quality assessment would make the configuration look
167    /// effective while producing neither metrics nor binding evidence.
168    pub fn validate_quality_usage(&self, quality_computed: bool) -> Result<(), DataProfilerError> {
169        if quality_computed
170            || (self.positive_columns.is_empty() && self.temporal_columns.is_empty())
171        {
172            return Ok(());
173        }
174
175        Err(DataProfilerError::InvalidSemanticHint {
176            message: "positive_columns and temporal_columns require the Quality metric pack"
177                .to_string(),
178            suggestion: "Include MetricPack::Quality (\"quality\" in Python), or remove the value-driven hint. Identifier hints remain usable without quality because they affect column typing."
179                .to_string(),
180        })
181    }
182
183    /// Fail loudly when a hint names a real column but bound to nothing.
184    ///
185    /// Only bindings that were assessed over the full data
186    /// ([`SemanticHintBinding::is_proven_inert`]) raise; sampled evidence is
187    /// carried in the report but never treated as proof of absence.
188    pub fn validate_bindings(
189        &self,
190        bindings: &[SemanticHintBinding],
191    ) -> Result<(), DataProfilerError> {
192        let inert: Vec<&SemanticHintBinding> =
193            bindings.iter().filter(|b| b.is_proven_inert()).collect();
194        if inert.is_empty() {
195            return Ok(());
196        }
197
198        let details = inert
199            .iter()
200            .map(|b| {
201                format!(
202                    "'{}' ({}): 0 of {} value(s) matched",
203                    b.column,
204                    b.kind.field_name(),
205                    b.checked_values
206                )
207            })
208            .collect::<Vec<_>>()
209            .join("; ");
210
211        Err(DataProfilerError::InvalidSemanticHint {
212            message: format!("semantic hint(s) bound to no matching value: {details}"),
213            suggestion: "positive_columns need numeric values; temporal_columns need date values. \
214                 Fix the column choice or remove the hint."
215                .to_string(),
216        })
217    }
218}