Skip to main content

dataprof_runtime/
report_assembler.rs

1//! Centralized report assembly for all profiling engines.
2//!
3//! `ReportAssembler` is the single entry point for constructing a [`ProfileReport`].
4//! It replaces the scattered report construction calls across parsers, engines,
5//! and database connectors, centralizing quality metric calculation and confidence
6//! tracking in one place.
7
8use std::collections::HashMap;
9
10use dataprof_core::{
11    ColumnProfile, DataSource, DataType, ExecutionMetadata, QualityDimension, SemanticHintBinding,
12    SemanticHintKind, SemanticHints,
13};
14use dataprof_metrics::{
15    MetricConfidence, MetricsCalculator, QualityAssessment, RowDuplicateSummary,
16    analysis::metrics::BifurcatedResult, compute_value_hint_bindings,
17};
18
19use crate::ProfileReport;
20
21/// Builder for constructing a [`ProfileReport`].
22pub struct ReportAssembler {
23    source: DataSource,
24    execution: ExecutionMetadata,
25    columns: Vec<ColumnProfile>,
26    quality_data: Option<HashMap<String, Vec<String>>>,
27    confidence: Option<MetricConfidence>,
28    skip_quality: bool,
29    requested_dimensions: Option<Vec<QualityDimension>>,
30    semantic_hints: SemanticHints,
31    exact_value_hint_bindings: Option<Vec<SemanticHintBinding>>,
32    row_duplicates: Option<RowDuplicateSummary>,
33}
34
35impl ReportAssembler {
36    /// Create a new assembler with required source and execution metadata.
37    pub fn new(source: DataSource, execution: ExecutionMetadata) -> Self {
38        Self {
39            source,
40            execution,
41            columns: Vec::new(),
42            quality_data: None,
43            confidence: None,
44            skip_quality: false,
45            requested_dimensions: None,
46            semantic_hints: SemanticHints::default(),
47            exact_value_hint_bindings: None,
48            row_duplicates: None,
49        }
50    }
51
52    /// Set the column profiles for this report.
53    pub fn columns(mut self, columns: Vec<ColumnProfile>) -> Self {
54        self.columns = columns;
55        self
56    }
57
58    /// Provide sample data for quality metric calculation.
59    pub fn with_quality_data(mut self, data: HashMap<String, Vec<String>>) -> Self {
60        self.quality_data = Some(data);
61        self
62    }
63
64    /// Override the default metric confidence level.
65    pub fn with_confidence(mut self, confidence: MetricConfidence) -> Self {
66        self.confidence = Some(confidence);
67        self
68    }
69
70    /// Explicitly skip quality metric calculation.
71    pub fn skip_quality(mut self) -> Self {
72        self.skip_quality = true;
73        self
74    }
75
76    /// Set the quality dimensions to compute.
77    pub fn with_requested_dimensions(mut self, dims: Vec<QualityDimension>) -> Self {
78        self.requested_dimensions = Some(dims);
79        self
80    }
81
82    /// Set semantic hints used by quality metrics.
83    pub fn with_semantic_hints(mut self, hints: SemanticHints) -> Self {
84        self.semantic_hints = hints;
85        self
86    }
87
88    /// Provide full-stream evidence for value-driven semantic hints.
89    ///
90    /// Streaming engines should pass their bounded-memory accumulator output;
91    /// it supersedes evidence recomputed from the retained quality sample.
92    pub fn with_exact_value_hint_bindings(mut self, bindings: Vec<SemanticHintBinding>) -> Self {
93        self.exact_value_hint_bindings = Some(bindings);
94        self
95    }
96
97    /// Provide full-stream row-duplicate counts from an engine's row
98    /// tracker; they supersede the sample-based duplicate scan.
99    pub fn with_row_duplicates(mut self, summary: Option<RowDuplicateSummary>) -> Self {
100        self.row_duplicates = summary;
101        self
102    }
103
104    /// Build the final [`ProfileReport`].
105    pub fn build(self) -> ProfileReport {
106        let quality = if self.skip_quality {
107            None
108        } else if let Some(data) = &self.quality_data {
109            self.compute_quality(data)
110        } else {
111            None
112        };
113        let bindings = self.compute_hint_bindings();
114
115        ProfileReport::new(self.source, self.columns, self.execution, quality)
116            .with_semantic_hint_bindings(bindings)
117    }
118
119    /// Measure how each semantic hint bound to the data.
120    ///
121    /// Identifier binding is structural — the hint coerces the column's type, so
122    /// it is read off the column profiles and is always exact. Positive and
123    /// temporal hints are value-driven. Streaming engines provide exact
124    /// full-stream counts; callers without those accumulators fall back to the
125    /// quality data and tag the result exact only when it covers every row.
126    fn compute_hint_bindings(&self) -> Vec<SemanticHintBinding> {
127        if self.semantic_hints.is_empty() {
128            return Vec::new();
129        }
130
131        let mut bindings = Vec::new();
132        for column in &self.semantic_hints.identifier_columns {
133            if let Some(profile) = self.columns.iter().find(|c| &c.name == column) {
134                let checked = profile.total_count.saturating_sub(profile.null_count);
135                let matched = if profile.data_type == DataType::Identifier {
136                    checked
137                } else {
138                    0
139                };
140                bindings.push(SemanticHintBinding {
141                    column: column.clone(),
142                    kind: SemanticHintKind::Identifier,
143                    checked_values: checked,
144                    matched_values: matched,
145                    exact: true,
146                });
147            }
148        }
149
150        if let Some(exact) = &self.exact_value_hint_bindings {
151            let full_coverage = self.execution.source_exhausted && !self.execution.sampling_applied;
152            bindings.extend(exact.iter().cloned().map(|mut binding| {
153                // Engine accumulators cover every value they processed. That
154                // is every source row for ordinary reservoir-backed streaming,
155                // but not when row-level sampling skipped records or an early
156                // stop left part of the source unread.
157                binding.exact &= full_coverage;
158                binding
159            }));
160        } else if let Some(data) = &self.quality_data {
161            let sample_size = data.values().map(|v| v.len()).max().unwrap_or(0);
162            let exact = !self.is_streaming_context(sample_size);
163            bindings.extend(compute_value_hint_bindings(
164                data,
165                &self.semantic_hints,
166                exact,
167            ));
168        }
169
170        bindings
171    }
172
173    fn compute_quality(&self, data: &HashMap<String, Vec<String>>) -> Option<QualityAssessment> {
174        let sample_size = data.values().map(|v| v.len()).max().unwrap_or(0);
175        let is_streaming = self.is_streaming_context(sample_size);
176
177        if is_streaming {
178            self.compute_bifurcated_quality(data)
179        } else {
180            self.compute_uniform_quality(data)
181        }
182    }
183
184    fn is_streaming_context(&self, sample_size: usize) -> bool {
185        self.execution.sampling_applied
186            || (sample_size > 0 && sample_size < self.execution.rows_processed)
187    }
188
189    fn compute_bifurcated_quality(
190        &self,
191        data: &HashMap<String, Vec<String>>,
192    ) -> Option<QualityAssessment> {
193        let calculator = MetricsCalculator::new();
194        match calculator.calculate_bifurcated_metrics_with_all_semantic_hints(
195            data,
196            &self.columns,
197            self.requested_dimensions.as_deref(),
198            &self.semantic_hints,
199            self.row_duplicates,
200        ) {
201            Ok(result) => {
202                let confidence = self
203                    .confidence
204                    .clone()
205                    .unwrap_or_else(|| self.mixed_confidence(&result));
206                Some(QualityAssessment {
207                    metrics: result.metrics,
208                    confidence,
209                })
210            }
211            Err(error) => {
212                log::warn!("Bifurcated quality metrics calculation failed: {error}");
213                None
214            }
215        }
216    }
217
218    fn compute_uniform_quality(
219        &self,
220        data: &HashMap<String, Vec<String>>,
221    ) -> Option<QualityAssessment> {
222        let calculator = MetricsCalculator::new();
223        match calculator.calculate_comprehensive_metrics_with_all_semantic_hints(
224            data,
225            &self.columns,
226            self.requested_dimensions.as_deref(),
227            &self.semantic_hints,
228            self.row_duplicates,
229        ) {
230            Ok(metrics) => {
231                let confidence = self.confidence.clone().unwrap_or(MetricConfidence::Exact);
232                Some(QualityAssessment {
233                    metrics,
234                    confidence,
235                })
236            }
237            Err(error) => {
238                log::warn!("Quality metrics calculation failed: {error}");
239                None
240            }
241        }
242    }
243
244    fn mixed_confidence(&self, result: &BifurcatedResult) -> MetricConfidence {
245        MetricConfidence::Mixed {
246            exact_dimensions: result.exact_dimensions.clone(),
247            sampled_dimensions: result.sampled_dimensions.clone(),
248            sample_size: result.sample_size,
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use dataprof_core::{FileFormat, TruncationReason};
257
258    fn test_source() -> DataSource {
259        DataSource::File {
260            path: "test.csv".to_string(),
261            format: FileFormat::Csv,
262            size_bytes: 1024,
263            modified_at: None,
264            parquet_metadata: None,
265        }
266    }
267
268    #[test]
269    fn test_basic_report_assembly() {
270        let report =
271            ReportAssembler::new(test_source(), ExecutionMetadata::new(100, 3, 50)).build();
272
273        assert_eq!(report.execution.rows_processed, 100);
274        assert!(report.quality.is_none());
275        assert!(report.column_profiles.is_empty());
276    }
277
278    #[test]
279    fn test_skip_quality() {
280        let mut data = HashMap::new();
281        data.insert("col".to_string(), vec!["a".to_string(), "b".to_string()]);
282
283        let report = ReportAssembler::new(test_source(), ExecutionMetadata::new(2, 1, 10))
284            .with_quality_data(data)
285            .skip_quality()
286            .build();
287
288        assert!(report.quality.is_none());
289    }
290
291    #[test]
292    fn test_batch_produces_exact_confidence() {
293        let mut data = HashMap::new();
294        data.insert("col".to_string(), vec!["a".to_string(), "b".to_string()]);
295
296        let report = ReportAssembler::new(test_source(), ExecutionMetadata::new(2, 1, 10))
297            .with_quality_data(data)
298            .build();
299
300        assert!(report.quality.is_some());
301        let quality = report.quality.unwrap();
302        assert!(matches!(quality.confidence, MetricConfidence::Exact));
303    }
304
305    #[test]
306    fn test_streaming_produces_mixed_confidence() {
307        let mut data = HashMap::new();
308        data.insert("col".to_string(), vec!["a".to_string(), "b".to_string()]);
309
310        let report = ReportAssembler::new(test_source(), ExecutionMetadata::new(1000, 1, 50))
311            .with_quality_data(data)
312            .build();
313
314        assert!(report.quality.is_some());
315        let quality = report.quality.unwrap();
316        match &quality.confidence {
317            MetricConfidence::Mixed {
318                exact_dimensions,
319                sampled_dimensions,
320                sample_size,
321            } => {
322                assert!(exact_dimensions.contains(&"completeness".to_string()));
323                // No key column exists in this fixture, so key_uniqueness
324                // carries no signal and must not be claimed as exact.
325                assert!(!exact_dimensions.contains(&"key_uniqueness".to_string()));
326                assert!(sampled_dimensions.contains(&"consistency".to_string()));
327                assert!(sampled_dimensions.contains(&"accuracy".to_string()));
328                assert!(sampled_dimensions.contains(&"timeliness".to_string()));
329                assert!(sampled_dimensions.contains(&"duplicate_rows".to_string()));
330                assert_eq!(*sample_size, 2);
331            }
332            other => panic!("Expected Mixed confidence, got {:?}", other),
333        }
334    }
335
336    #[test]
337    fn test_streaming_exact_row_duplicates_have_exact_provenance() {
338        let data = HashMap::from([("col".to_string(), vec!["a".to_string(), "b".to_string()])]);
339
340        let report = ReportAssembler::new(test_source(), ExecutionMetadata::new(1000, 1, 50))
341            .with_quality_data(data)
342            .with_row_duplicates(Some(RowDuplicateSummary {
343                duplicate_rows: 25,
344                rows_checked: 1000,
345                approximate: false,
346            }))
347            .build();
348
349        let quality = report.quality.expect("quality assessment");
350        let uniqueness = quality.metrics.uniqueness.expect("uniqueness metrics");
351        assert_eq!(uniqueness.duplicate_rows, 25);
352        assert_eq!(uniqueness.rows_checked, 1000);
353        assert!(!uniqueness.duplicate_rows_approximate);
354        match quality.confidence {
355            MetricConfidence::Mixed {
356                exact_dimensions,
357                sampled_dimensions,
358                ..
359            } => {
360                assert!(exact_dimensions.contains(&"duplicate_rows".to_string()));
361                assert!(!sampled_dimensions.contains(&"duplicate_rows".to_string()));
362            }
363            other => panic!("Expected Mixed confidence, got {other:?}"),
364        }
365    }
366
367    #[test]
368    fn test_streaming_approximate_row_duplicates_have_sampled_provenance() {
369        let data = HashMap::from([("col".to_string(), vec!["a".to_string(), "b".to_string()])]);
370
371        let report = ReportAssembler::new(test_source(), ExecutionMetadata::new(20_000, 1, 50))
372            .with_quality_data(data)
373            .with_row_duplicates(Some(RowDuplicateSummary {
374                duplicate_rows: 500,
375                rows_checked: 20_000,
376                approximate: true,
377            }))
378            .build();
379
380        let quality = report.quality.expect("quality assessment");
381        let uniqueness = quality.metrics.uniqueness.expect("uniqueness metrics");
382        assert!(uniqueness.duplicate_rows_approximate);
383        match quality.confidence {
384            MetricConfidence::Mixed {
385                exact_dimensions,
386                sampled_dimensions,
387                ..
388            } => {
389                assert!(!exact_dimensions.contains(&"duplicate_rows".to_string()));
390                assert!(sampled_dimensions.contains(&"duplicate_rows".to_string()));
391            }
392            other => panic!("Expected Mixed confidence, got {other:?}"),
393        }
394    }
395
396    #[test]
397    fn test_sampling_applied_triggers_bifurcation() {
398        let mut data = HashMap::new();
399        data.insert("col".to_string(), vec!["a".to_string(), "b".to_string()]);
400
401        let execution = ExecutionMetadata::new(2, 1, 10).with_sampling(0.1);
402
403        let report = ReportAssembler::new(test_source(), execution)
404            .with_quality_data(data)
405            .build();
406
407        assert!(report.quality.is_some());
408        let quality = report.quality.unwrap();
409        assert!(matches!(quality.confidence, MetricConfidence::Mixed { .. }));
410    }
411
412    fn positive_binding() -> SemanticHintBinding {
413        SemanticHintBinding {
414            column: "col".to_string(),
415            kind: SemanticHintKind::Positive,
416            checked_values: 2,
417            matched_values: 0,
418            exact: true,
419        }
420    }
421
422    #[test]
423    fn exact_hint_binding_stays_exact_for_exhaustive_stream() {
424        let report = ReportAssembler::new(test_source(), ExecutionMetadata::new(2, 1, 10))
425            .with_semantic_hints(SemanticHints::new(vec!["col".to_string()], vec![]))
426            .with_exact_value_hint_bindings(vec![positive_binding()])
427            .build();
428
429        assert!(report.semantic_hint_bindings[0].exact);
430    }
431
432    #[test]
433    fn exact_hint_binding_is_downgraded_for_sampled_or_truncated_execution() {
434        let executions = [
435            ExecutionMetadata::new(2, 1, 10).with_sampling(0.5),
436            ExecutionMetadata::new(2, 1, 10).with_truncation(TruncationReason::MaxRows(2)),
437        ];
438
439        for execution in executions {
440            let report = ReportAssembler::new(test_source(), execution)
441                .with_semantic_hints(SemanticHints::new(vec!["col".to_string()], vec![]))
442                .with_exact_value_hint_bindings(vec![positive_binding()])
443                .build();
444
445            assert!(!report.semantic_hint_bindings[0].exact);
446            assert!(!report.semantic_hint_bindings[0].is_proven_inert());
447        }
448    }
449}