1use 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
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 row_completeness: Option<RowCompletenessSummary>,
34}
35
36impl ReportAssembler {
37 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 pub fn columns(mut self, columns: Vec<ColumnProfile>) -> Self {
56 self.columns = columns;
57 self
58 }
59
60 pub fn with_quality_data(mut self, data: HashMap<String, Vec<String>>) -> Self {
62 self.quality_data = Some(data);
63 self
64 }
65
66 pub fn with_confidence(mut self, confidence: MetricConfidence) -> Self {
68 self.confidence = Some(confidence);
69 self
70 }
71
72 pub fn skip_quality(mut self) -> Self {
74 self.skip_quality = true;
75 self
76 }
77
78 pub fn with_requested_dimensions(mut self, dims: Vec<QualityDimension>) -> Self {
80 self.requested_dimensions = Some(dims);
81 self
82 }
83
84 pub fn with_semantic_hints(mut self, hints: SemanticHints) -> Self {
86 self.semantic_hints = hints;
87 self
88 }
89
90 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 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 pub fn with_row_duplicates(mut self, summary: Option<RowDuplicateSummary>) -> Self {
117 self.row_duplicates = summary;
118 self
119 }
120
121 pub fn with_row_completeness(mut self, summary: Option<RowCompletenessSummary>) -> Self {
126 self.row_completeness = summary;
127 self
128 }
129
130 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 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 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 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}