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