1use 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
21pub 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 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 pub fn columns(mut self, columns: Vec<ColumnProfile>) -> Self {
54 self.columns = columns;
55 self
56 }
57
58 pub fn with_quality_data(mut self, data: HashMap<String, Vec<String>>) -> Self {
60 self.quality_data = Some(data);
61 self
62 }
63
64 pub fn with_confidence(mut self, confidence: MetricConfidence) -> Self {
66 self.confidence = Some(confidence);
67 self
68 }
69
70 pub fn skip_quality(mut self) -> Self {
72 self.skip_quality = true;
73 self
74 }
75
76 pub fn with_requested_dimensions(mut self, dims: Vec<QualityDimension>) -> Self {
78 self.requested_dimensions = Some(dims);
79 self
80 }
81
82 pub fn with_semantic_hints(mut self, hints: SemanticHints) -> Self {
84 self.semantic_hints = hints;
85 self
86 }
87
88 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 pub fn with_row_duplicates(mut self, summary: Option<RowDuplicateSummary>) -> Self {
100 self.row_duplicates = summary;
101 self
102 }
103
104 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 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 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 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}