Skip to main content

dataprof_runtime/
profile_report.rs

1use dataprof_core::{
2    ColumnProfile, DataSource, ExecutionMetadata, QualityScoreWeights, SemanticHintBinding,
3    TextLengthUnit,
4};
5use dataprof_metrics::{
6    AccuracyMetrics, CompletenessMetrics, ConsistencyMetrics, MetricConfidence, PrecisionMetrics,
7    QualityAssessment, QualityMetrics, TimelinessMetrics, ValidityMetrics,
8};
9
10/// Version of the serialized `ProfileReport` schema written by this build.
11///
12/// This is intentionally independent of the package version: the document
13/// format only changes when the schema itself changes, not on every release.
14///
15/// Compatibility policy for readers:
16/// - Documents without a `schema_version` field are legacy pre-0.10 reports
17///   and deserialize with `schema_version == 0`.
18/// - Unknown *additive* fields written by a newer dataprof are ignored, so a
19///   reader accepts any document whose `schema_version` is at most this
20///   constant.
21/// - A document with a `schema_version` greater than this constant fails to
22///   deserialize with an explicit error instead of being partially decoded
23///   into a plausible-but-wrong report.
24pub const REPORT_SCHEMA_VERSION: u32 = 1;
25
26/// Why a report does or does not carry a quality assessment.
27///
28/// `quality` alone cannot answer that. A run that never asked for quality
29/// metrics and a run whose quality computation failed both leave it `None`,
30/// so absence itself became the plausible value that hid the failure. This
31/// names the difference: a consumer deciding on a report can tell "you did
32/// not ask for this" from "this broke" from "there was nothing to measure".
33#[derive(
34    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
35)]
36#[serde(tag = "state", rename_all = "snake_case")]
37pub enum QualityAnalysisStatus {
38    /// Computed: `quality` carries the assessment.
39    Computed,
40    /// Not requested for this run — the quality pack was deselected.
41    NotRequested,
42    /// Requested, but no quality sample was supplied to the assembler.
43    /// An empty sample is analyzed and reports `Computed`, even without columns.
44    NoData,
45    /// Requested, but every requested dimension measures whole rows and the
46    /// run profiled a subset of columns. Completeness and uniqueness mean
47    /// something else after projection, and the report cannot label only
48    /// their row-level fields as projected, so they are withheld rather than
49    /// published under full-row names.
50    WithheldByProjection,
51    /// Requested and attempted; the computation failed. `quality` is absent
52    /// because the computation broke, not because nothing was asked for.
53    Failed {
54        /// The error the metrics calculator reported.
55        error: String,
56    },
57    /// Written by a release that did not record this. Only reachable by
58    /// deserializing a document from before the field existed that carries no
59    /// quality assessment; a stored assessment is read back as `Computed`.
60    Unrecorded,
61}
62
63/// How the measurements in a report were defined.
64///
65/// `schema_version` answers whether a document validates. It cannot answer
66/// whether two valid documents measured the same way: text lengths counted
67/// UTF-8 bytes through 0.11 and count Unicode scalar values since, with no
68/// change to the document's shape. This records each intentionally changed
69/// definition by name, so a comparison can check exactly the ones both
70/// reports declare.
71///
72/// Every field is optional. A field missing from a stored report means the
73/// release that wrote it did not record that definition: unknown, never the
74/// current one. Later releases add fields, which older readers ignore.
75#[derive(
76    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
77)]
78#[non_exhaustive]
79pub struct MetricSemantics {
80    /// Unit of `min_length`, `max_length` and `avg_length` on text columns.
81    ///
82    /// Absent means not recorded; an explicit null is malformed, so neither
83    /// the reader nor the schema accepts one.
84    #[serde(
85        default,
86        skip_serializing_if = "Option::is_none",
87        deserialize_with = "present_value"
88    )]
89    #[schemars(with = "TextLengthUnit")]
90    pub text_length_unit: Option<TextLengthUnit>,
91}
92
93/// Decode a field that may be absent (serde's `default` covers that) but,
94/// when present, must carry a value: `Option<T>` alone reads `null` as `None`.
95fn present_value<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
96where
97    D: serde::Deserializer<'de>,
98    T: serde::Deserialize<'de>,
99{
100    T::deserialize(deserializer).map(Some)
101}
102
103impl MetricSemantics {
104    /// The definitions this build measures with.
105    pub fn current() -> Self {
106        Self {
107            text_length_unit: Some(TextLengthUnit::UnicodeScalar),
108        }
109    }
110}
111
112/// Complete profiling report for a data source.
113///
114/// Contains column-level statistics, execution metadata, and an optional
115/// Quality assessment informed by ISO 8000/25012 concepts. This is the primary output of all
116/// profiling operations (`Profiler::analyze_file`, `Profiler::analyze_source`,
117/// `Profiler::profile_stream`, etc.).
118#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
119pub struct ProfileReport {
120    /// Version of the serialized report schema (see [`REPORT_SCHEMA_VERSION`]).
121    ///
122    /// `0` means the document predates schema versioning (a 0.9-era report).
123    /// Deserialization rejects versions newer than [`REPORT_SCHEMA_VERSION`].
124    #[schemars(schema_with = "schema_version_schema")]
125    pub schema_version: u32,
126    /// Unique identifier for this report (UUID v4)
127    pub id: String,
128    /// Timestamp when the report was generated (ISO 8601 / RFC 3339)
129    pub timestamp: String,
130    /// Data source metadata (file, query, etc.)
131    pub data_source: DataSource,
132    /// Column-level profiling results
133    pub column_profiles: Vec<ColumnProfile>,
134    /// Execution metadata (timing, rows processed, truncation info, etc.)
135    pub execution: ExecutionMetadata,
136    /// Data quality assessment (optional — partial analysis may skip quality)
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub quality: Option<QualityAssessment>,
139    /// What happened to the quality computation. Always present: it is the
140    /// reason `quality` is or is not there, and a report must never leave a
141    /// failed computation to a log line. Additive field — documents written
142    /// before it deserialize as [`QualityAnalysisStatus::Unrecorded`], or as
143    /// `Computed` when they carry an assessment.
144    ///
145    /// This and `quality` are two halves of one fact:
146    /// [`Computed`](QualityAnalysisStatus::Computed) accompanies an assessment
147    /// and every other status accompanies its absence. Construction goes
148    /// through [`ReportAssembler`](crate::ReportAssembler), which derives one
149    /// from the other, and deserialization rejects a document that pairs them
150    /// any other way. Assigning either field directly can still break the
151    /// pair, and such a report serializes into a document this crate will
152    /// refuse to read back.
153    pub quality_status: QualityAnalysisStatus,
154    /// Per-column evidence of how each semantic hint bound to the data.
155    ///
156    /// Empty when no hints were supplied. Recorded for provenance: a hint proven
157    /// inert over the full data is rejected before a report is returned, so a
158    /// successful report only carries bindings that matched something or whose
159    /// evidence was sampled. Additive field — older readers ignore it.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub semantic_hint_bindings: Vec<SemanticHintBinding>,
162    /// How this report's measurements were defined; see [`MetricSemantics`].
163    ///
164    /// Every report this build produces records [`MetricSemantics::current`].
165    /// `None` is a document written before dataprof recorded it, and its
166    /// definitions are unknown. Loading and saving keep the value the
167    /// document had rather than the reader's. Additive field.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    #[schemars(with = "MetricSemantics")]
170    pub metric_semantics: Option<MetricSemantics>,
171}
172
173fn schema_version_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
174    schemars::json_schema!({
175        "type": "integer",
176        "const": REPORT_SCHEMA_VERSION,
177        "minimum": 0
178    })
179}
180
181/// The canonical persisted report and the legacy Python compatibility summary.
182///
183/// New JSON saves in both languages use `ProfileReport`. Python's `to_dict()`
184/// remains a convenience projection, produced by `PythonProfileReportDocument`
185/// below. Existing flat documents cannot acquire the source/quality provenance
186/// they never recorded, so the v1 union remains an intentional compatibility
187/// surface for loading and resaving those documents.
188#[allow(dead_code)]
189#[derive(serde::Serialize, schemars::JsonSchema)]
190#[serde(untagged)]
191#[schemars(title = "ProfileReport")]
192enum SerializedProfileReport {
193    Rust(Box<ProfileReport>),
194    Python(Box<PythonProfileReportDocument>),
195}
196
197#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
198struct PythonProfileReportDocument {
199    #[schemars(schema_with = "schema_version_schema")]
200    schema_version: u32,
201    source: String,
202    source_type: PythonSourceType,
203    execution: PythonExecutionDocument,
204    columns: Vec<PythonColumnDocument>,
205    quality: Option<PythonQualityDocument>,
206    quality_status: QualityAnalysisStatus,
207    #[serde(skip_serializing_if = "Vec::is_empty")]
208    semantic_hint_bindings: Vec<SemanticHintBinding>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    #[schemars(with = "MetricSemantics")]
211    metric_semantics: Option<MetricSemantics>,
212}
213
214#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
215#[serde(rename_all = "lowercase")]
216enum PythonSourceType {
217    File,
218    Query,
219    Dataframe,
220    Stream,
221    Bytes,
222}
223
224#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
225struct PythonExecutionDocument {
226    engine: Option<String>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    recovery_events: Option<Vec<dataprof_core::execution::RecoveryEvent>>,
229    rows_processed: usize,
230    columns_detected: usize,
231    scan_time_ms: u128,
232    source_exhausted: bool,
233    truncation_reason: Option<String>,
234    bytes_consumed: Option<u64>,
235    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
236    throughput_rows_sec: Option<f64>,
237    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
238    memory_peak_mb: Option<f64>,
239    error_count: usize,
240    ragged_row_count: usize,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    unterminated_quote: Option<bool>,
243    sampling_applied: bool,
244    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
245    sampling_ratio: Option<f64>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    sampled_row_ranges: Option<Vec<[u64; 2]>>,
248}
249
250#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
251struct PythonColumnDocument {
252    name: String,
253    data_type: PythonDataType,
254    total_count: usize,
255    null_count: usize,
256    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
257    null_percentage: Option<f64>,
258    unique_count: Option<usize>,
259    unique_count_is_approximate: Option<bool>,
260    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
261    uniqueness_ratio: Option<f64>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    invalid_count: Option<usize>,
264    /// Counts per lexical class. The Python dialect writes them as a plain
265    /// mapping, but it is the same four-key object the Rust dialect writes, so
266    /// it is described by the same definition: a reader that accepted a partial
267    /// mapping here would validate a document the Python loader then discards
268    /// as incomplete rather than inventing the missing counts.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    type_homogeneity: Option<dataprof_core::TypeHomogeneity>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    locale_number_count: Option<usize>,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    stats: Option<PythonColumnStatsDocument>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    patterns: Option<Vec<PythonPatternDocument>>,
277}
278
279#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
280#[serde(rename_all = "lowercase")]
281enum PythonDataType {
282    String,
283    Identifier,
284    Integer,
285    Float,
286    Date,
287    Boolean,
288    Nested,
289}
290
291#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
292struct PythonColumnStatsDocument {
293    #[serde(skip_serializing_if = "Option::is_none")]
294    min: Option<f64>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    max: Option<f64>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    mean: Option<f64>,
299    // Outer None means this statistic does not apply to the column. An inner
300    // None is a numeric spread that overflowed: keep its explicit JSON null.
301    #[serde(
302        default,
303        deserialize_with = "deserialize_present_nullable_stat",
304        skip_serializing_if = "Option::is_none"
305    )]
306    #[schemars(with = "Option<f64>")]
307    std_dev: Option<Option<f64>>,
308    #[serde(
309        default,
310        deserialize_with = "deserialize_present_nullable_stat",
311        skip_serializing_if = "Option::is_none"
312    )]
313    #[schemars(with = "Option<f64>")]
314    variance: Option<Option<f64>>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    median: Option<f64>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    mode: Option<f64>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    skewness: Option<f64>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    kurtosis: Option<f64>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    coefficient_of_variation: Option<f64>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    quartiles: Option<dataprof_core::Quartiles>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    is_approximate: Option<bool>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    outlier_count: Option<usize>,
331    /// Shortest value, in Unicode scalar values. Not UTF-8 bytes and not
332    /// grapheme clusters: ASCII text is unaffected by that distinction, while a
333    /// combining sequence counts each scalar, so the decomposed and precomposed
334    /// spellings of the same word report different lengths.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    min_length: Option<usize>,
337    /// Longest value, in Unicode scalar values. See `min_length`.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    max_length: Option<usize>,
340    /// Mean length, in Unicode scalar values. See `min_length`.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    avg_length: Option<f64>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    true_count: Option<usize>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    false_count: Option<usize>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    true_ratio: Option<f64>,
349}
350
351fn deserialize_present_nullable_stat<'de, D>(
352    deserializer: D,
353) -> Result<Option<Option<f64>>, D::Error>
354where
355    D: serde::Deserializer<'de>,
356{
357    serde::Deserialize::deserialize(deserializer).map(Some)
358}
359
360#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
361struct PythonPatternDocument {
362    name: String,
363    regex: String,
364    match_count: usize,
365    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
366    match_percentage: f64,
367    category: dataprof_core::PatternCategory,
368    #[serde(serialize_with = "dataprof_core::serde_helpers::round_4")]
369    confidence: f64,
370}
371
372#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
373struct PythonQualityDocument {
374    /// Null when no dimension was assessable; `assessed_dimensions` is then
375    /// empty and every entry in `dimension_scores` is null.
376    overall_score: Option<f64>,
377    assessed_dimensions: Vec<PythonQualityDimension>,
378    dimension_scores: std::collections::BTreeMap<PythonQualityDimension, Option<f64>>,
379    low_sample_warning: bool,
380    /// Metric components computed from a retained quality sample rather than
381    /// from every scanned row. Additive field: a document written before it
382    /// existed omits it, and its absence is preserved on reload because
383    /// unknown coverage is not the same statement as "nothing was sampled".
384    /// Uniqueness appears here as `key_uniqueness` and `duplicate_rows`.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    sampled_dimensions: Option<Vec<String>>,
387    /// Where the scores over every scanned row lie, when some were computed over a
388    /// retained sample. Additive field, absent when every score is exact and
389    /// on documents written before it existed.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    score_bounds: Option<dataprof_metrics::ScoreBounds>,
392    /// Weights behind `overall_score`. Additive field, written as the
393    /// canonical document writes it: default weights are omitted. Summaries
394    /// written before the field existed dropped custom weights too, so its
395    /// absence there does not show the defaults applied.
396    #[serde(default, skip_serializing_if = "QualityScoreWeights::is_default")]
397    score_weights: QualityScoreWeights,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    completeness: Option<CompletenessMetrics>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    consistency: Option<ConsistencyMetrics>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    uniqueness: Option<PythonUniquenessDocument>,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    accuracy: Option<AccuracyMetrics>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    timeliness: Option<TimelinessMetrics>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    validity: Option<ValidityMetrics>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    precision: Option<PrecisionMetrics>,
412}
413
414#[derive(
415    Debug,
416    Clone,
417    PartialEq,
418    Eq,
419    PartialOrd,
420    Ord,
421    Hash,
422    serde::Serialize,
423    serde::Deserialize,
424    schemars::JsonSchema,
425)]
426#[serde(rename_all = "lowercase")]
427enum PythonQualityDimension {
428    Completeness,
429    Consistency,
430    Uniqueness,
431    Accuracy,
432    Timeliness,
433    Validity,
434    Precision,
435}
436
437#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
438struct PythonUniquenessDocument {
439    duplicate_rows: usize,
440    key_uniqueness: f64,
441    high_cardinality_warning: bool,
442    rows_checked: usize,
443    key_column: Option<String>,
444    duplicate_rows_approximate: bool,
445}
446
447impl PythonProfileReportDocument {
448    fn from_report(report: &ProfileReport) -> serde_json::Result<Self> {
449        use dataprof_core::{ColumnStats, DataType, TruncationReason};
450
451        // Reuse the serialized metric blocks: their Serde implementations own
452        // rounding and omission, rather than repeating those rules in Python.
453        let columns = report
454            .column_profiles
455            .iter()
456            .map(|column| {
457                let stats = match &column.stats {
458                    ColumnStats::Numeric(stats) => Some(serde_json::to_value(stats)?),
459                    ColumnStats::Text(stats) if column.total_count > 0 => {
460                        Some(serde_json::to_value(stats)?)
461                    }
462                    ColumnStats::Boolean(stats) => Some(serde_json::to_value(stats)?),
463                    _ => None,
464                }
465                .map(serde_json::from_value)
466                .transpose()?;
467                Ok(PythonColumnDocument {
468                    name: column.name.clone(),
469                    data_type: match column.data_type {
470                        DataType::String => PythonDataType::String,
471                        DataType::Identifier => PythonDataType::Identifier,
472                        DataType::Integer => PythonDataType::Integer,
473                        DataType::Float => PythonDataType::Float,
474                        DataType::Date => PythonDataType::Date,
475                        DataType::Boolean => PythonDataType::Boolean,
476                        DataType::Nested => PythonDataType::Nested,
477                    },
478                    total_count: column.total_count,
479                    null_count: column.null_count,
480                    null_percentage: (column.total_count > 0)
481                        .then(|| column.null_count as f64 / column.total_count as f64 * 100.0),
482                    unique_count: column.unique_count,
483                    unique_count_is_approximate: column.unique_count_is_approximate,
484                    uniqueness_ratio: column
485                        .unique_count
486                        .filter(|_| column.total_count > 0)
487                        .map(|count| count as f64 / column.total_count as f64),
488                    invalid_count: column.invalid_count,
489                    type_homogeneity: column.type_homogeneity,
490                    locale_number_count: column.locale_number_count,
491                    stats,
492                    patterns: column.patterns.as_ref().map(|patterns| {
493                        patterns
494                            .iter()
495                            .map(|pattern| PythonPatternDocument {
496                                name: pattern.name.clone(),
497                                regex: pattern.regex.clone(),
498                                match_count: pattern.match_count,
499                                match_percentage: pattern.match_percentage,
500                                category: pattern.category.clone(),
501                                confidence: pattern.confidence,
502                            })
503                            .collect()
504                    }),
505                })
506            })
507            .collect::<serde_json::Result<Vec<_>>>()?;
508
509        let mut execution = serde_json::to_value(&report.execution)?;
510        execution["truncation_reason"] =
511            serde_json::to_value(report.execution.truncation_reason.as_ref().map(|reason| {
512                match reason {
513                    TruncationReason::MaxRows(n) => format!("max_rows({n})"),
514                    TruncationReason::MaxBytes(n) => format!("max_bytes({n})"),
515                    TruncationReason::MemoryPressure => "memory_pressure".to_string(),
516                    TruncationReason::StopCondition(s) => format!("stop_condition({s})"),
517                    TruncationReason::StreamClosed => "stream_closed".to_string(),
518                    TruncationReason::Timeout => "timeout".to_string(),
519                }
520            }))?;
521
522        let quality = report
523            .quality
524            .as_ref()
525            .map(|assessment| {
526                let metrics = &assessment.metrics;
527                let mut document = serde_json::to_value(metrics)?;
528                let scores = serde_json::to_value(assessment.scores())?;
529                document["overall_score"] = scores["overall_score"].clone();
530                // A document from a later v1 release may retain scores for a
531                // dimension this build does not know. The canonical document
532                // keeps them; the summary lists only the dimensions it types.
533                let known: Vec<String> = dataprof_core::QualityDimension::all()
534                    .into_iter()
535                    .map(|dimension| dimension.to_string())
536                    .collect();
537                document["dimension_scores"] = scores["dimension_scores"]
538                    .as_object()
539                    .expect("dimension scores serialize as an object")
540                    .iter()
541                    .filter(|(name, _)| known.contains(name))
542                    .map(|(name, score)| (name.clone(), score.clone()))
543                    .collect();
544                document["assessed_dimensions"] =
545                    serde_json::to_value(metrics.assessed_dimensions())?;
546                document["low_sample_warning"] =
547                    serde_json::Value::Bool(metrics.low_sample_warning);
548                if let Some(sampled) = assessment.sampled_dimensions() {
549                    document["sampled_dimensions"] = serde_json::to_value(sampled)?;
550                }
551                if let Some(bounds) = assessment.score_bounds() {
552                    document["score_bounds"] = serde_json::to_value(bounds)?;
553                }
554                // The summary has always included this boolean, even when false.
555                if let Some(uniqueness) = document.get_mut("uniqueness") {
556                    uniqueness["duplicate_rows_approximate"] = serde_json::Value::Bool(
557                        metrics
558                            .uniqueness
559                            .as_ref()
560                            .expect("serialized uniqueness is present")
561                            .duplicate_rows_approximate,
562                    );
563                }
564                serde_json::from_value(document)
565            })
566            .transpose()?;
567
568        Ok(Self {
569            schema_version: report.schema_version,
570            source: report.data_source.identifier(),
571            source_type: match report.data_source {
572                DataSource::File { .. } => PythonSourceType::File,
573                DataSource::Query { .. } => PythonSourceType::Query,
574                DataSource::DataFrame { .. } => PythonSourceType::Dataframe,
575                DataSource::Stream { .. } => PythonSourceType::Stream,
576                DataSource::Bytes { .. } => PythonSourceType::Bytes,
577            },
578            execution: serde_json::from_value(execution)?,
579            columns,
580            quality,
581            quality_status: report.quality_status.clone(),
582            semantic_hint_bindings: report.semantic_hint_bindings.clone(),
583            metric_semantics: report.metric_semantics.clone(),
584        })
585    }
586}
587
588/// Generate the JSON Schema 2020-12 document for the current serialized report.
589///
590/// This is primarily used by the repository's schema generator and drift tests.
591/// The committed, versioned document under `docs/schema/` is the public
592/// interoperability contract.
593#[doc(hidden)]
594pub fn profile_report_schema_document() -> serde_json::Value {
595    let settings = schemars::generate::SchemaSettings::draft2020_12().for_serialize();
596    let schema = settings
597        .into_generator()
598        .into_root_schema_for::<SerializedProfileReport>();
599    let mut document =
600        serde_json::to_value(schema).expect("a Schemars schema must serialize to a JSON document");
601    let object = document
602        .as_object_mut()
603        .expect("a root Schemars schema must be a JSON object");
604    object.insert(
605        "$id".to_string(),
606        serde_json::Value::String(format!(
607            "https://andreabozzo.github.io/dataprof/schema/profile-report.v{REPORT_SCHEMA_VERSION}.schema.json"
608        )),
609    );
610    make_compatibility_defaults_optional(&mut document);
611    allow_additive_properties(&mut document);
612    canonicalize_key_order(&mut document);
613    document
614}
615
616/// Sort every object's keys so the committed schema has one canonical byte
617/// layout.
618///
619/// Key order carries no meaning in JSON Schema, but the committed document is a
620/// reviewed artifact: it must not churn because `serde_json` switched its map
621/// backing (`preserve_order`) or because Schemars changed the order in which it
622/// builds a schema. Arrays such as `required` and `oneOf` keep their order,
623/// which *is* meaningful.
624fn canonicalize_key_order(value: &mut serde_json::Value) {
625    match value {
626        serde_json::Value::Object(object) => {
627            for child in object.values_mut() {
628                canonicalize_key_order(child);
629            }
630            let mut sorted: Vec<(String, serde_json::Value)> =
631                std::mem::take(object).into_iter().collect();
632            sorted.sort_by(|(left, _), (right, _)| left.cmp(right));
633            object.extend(sorted);
634        }
635        serde_json::Value::Array(items) => {
636            for item in items {
637                canonicalize_key_order(item);
638            }
639        }
640        _ => {}
641    }
642}
643
644/// Drop fields that this build always writes but a reader supplies a default
645/// for, from the schema's `required` lists.
646///
647/// A document written under an earlier v1 build does not carry them. Declaring
648/// them required would fail validation for documents the readers accept, which
649/// is the opposite of what an additive widening means.
650fn make_compatibility_defaults_optional(document: &mut serde_json::Value) {
651    for (pointer, field) in [
652        ("/$defs/ExecutionMetadata/required", "ragged_row_count"),
653        ("/$defs/ProfileReport/required", "quality_status"),
654        (
655            "/$defs/PythonProfileReportDocument/required",
656            "quality_status",
657        ),
658        (
659            "/$defs/PythonQualityDocument/required",
660            "sampled_dimensions",
661        ),
662        ("/$defs/QualityAssessment/required", "scores"),
663        // Written only when some score was sampled.
664        ("/$defs/QualityAssessment/required", "score_bounds"),
665    ] {
666        if let Some(required) = document
667            .pointer_mut(pointer)
668            .and_then(serde_json::Value::as_array_mut)
669        {
670            required.retain(|declared| declared.as_str() != Some(field));
671        }
672    }
673}
674
675fn allow_additive_properties(value: &mut serde_json::Value) {
676    match value {
677        serde_json::Value::Object(object) => {
678            if object.get("additionalProperties") == Some(&serde_json::Value::Bool(false)) {
679                object.remove("additionalProperties");
680            }
681            for child in object.values_mut() {
682                allow_additive_properties(child);
683            }
684        }
685        serde_json::Value::Array(items) => {
686            for item in items {
687                allow_additive_properties(item);
688            }
689        }
690        _ => {}
691    }
692}
693
694impl ProfileReport {
695    /// Serialize the canonical persisted document with deterministic object-key
696    /// order. Array order (columns, patterns, recovery events) is preserved.
697    pub fn to_json(&self) -> serde_json::Result<String> {
698        let mut document = serde_json::to_value(self)?;
699        canonicalize_key_order(&mut document);
700        serde_json::to_string_pretty(&document)
701    }
702
703    /// Serialize Python's historical summary projection. It deliberately omits
704    /// some runtime metadata; use [`Self::to_json`] for lossless persistence.
705    #[doc(hidden)]
706    pub fn summary_json(&self) -> serde_json::Result<String> {
707        let summary = PythonProfileReportDocument::from_report(self)?;
708        serde_json::to_string(&summary)
709    }
710
711    /// Create a new ProfileReport with auto-generated id and timestamp
712    pub fn new(
713        data_source: DataSource,
714        column_profiles: Vec<ColumnProfile>,
715        execution: ExecutionMetadata,
716        quality: Option<QualityAssessment>,
717    ) -> Self {
718        Self {
719            schema_version: REPORT_SCHEMA_VERSION,
720            id: uuid::Uuid::new_v4().to_string(),
721            timestamp: chrono::Utc::now().to_rfc3339(),
722            data_source,
723            column_profiles,
724            execution,
725            quality_status: match quality {
726                Some(_) => QualityAnalysisStatus::Computed,
727                // A caller constructing a report directly did not compute
728                // quality; `ReportAssembler` overrides this with the reason it
729                // actually observed.
730                None => QualityAnalysisStatus::NotRequested,
731            },
732            quality,
733            semantic_hint_bindings: Vec::new(),
734            metric_semantics: Some(MetricSemantics::current()),
735        }
736    }
737
738    /// Record what happened to the quality computation.
739    ///
740    /// Deliberately not public. `quality_status` and `quality` are two halves
741    /// of one fact, and [`check_quality_pairing`](Self::check_quality_pairing)
742    /// refuses to decode a document that pairs them wrongly — so a public
743    /// setter would let a caller build a report that serializes and then
744    /// cannot be read back by this same crate. Every path assigns the status
745    /// through [`ReportAssembler`](crate::ReportAssembler), which derives it
746    /// from the computation that produced the assessment.
747    pub(crate) fn with_quality_status(mut self, status: QualityAnalysisStatus) -> Self {
748        self.quality_status = status;
749        self
750    }
751
752    /// Attach per-column semantic-hint binding evidence.
753    pub fn with_semantic_hint_bindings(mut self, bindings: Vec<SemanticHintBinding>) -> Self {
754        self.semantic_hint_bindings = bindings;
755        self
756    }
757
758    /// Override the auto-generated ID (useful for deterministic caching/testing)
759    pub fn with_id(mut self, id: impl Into<String>) -> Self {
760        self.id = id.into();
761        self
762    }
763
764    /// Override the auto-generated timestamp
765    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
766        self.timestamp = timestamp.into();
767        self
768    }
769
770    /// Calculate the overall quality score (weighted across the assessed
771    /// dimensions). Returns `None` if quality metrics were not computed, or
772    /// if no dimension had anything to assess (e.g. an empty dataset) —
773    /// absence of evidence is neither a perfect score nor a zero.
774    pub fn quality_score(&self) -> Option<f64> {
775        self.quality.as_ref().and_then(|q| q.score())
776    }
777
778    /// Get the data source identifier (for backwards compatibility)
779    pub fn source_identifier(&self) -> String {
780        self.data_source.identifier()
781    }
782
783    /// Reject a document whose `quality_status` contradicts its `quality`.
784    ///
785    /// `Computed` and an assessment imply each other: no writer can produce one
786    /// without the other, because [`ReportAssembler`](crate::ReportAssembler)
787    /// derives the status from the computation that produced the assessment. A
788    /// document that pairs them any other way is internally inconsistent, and
789    /// loading it would hand a consumer a report that states a verdict its own
790    /// contents contradict — a gate could act on an assessment the report says
791    /// failed. Failing the decode is the answer the rest of this crate gives to
792    /// a malformed document, rather than repairing it into something plausible.
793    fn check_quality_pairing(self) -> Result<Self, String> {
794        let assessed = self.quality.is_some();
795        let claims_computed = matches!(self.quality_status, QualityAnalysisStatus::Computed);
796        if claims_computed == assessed {
797            return Ok(self);
798        }
799        Err(if assessed {
800            format!(
801                "report carries a quality assessment but reports quality_status \
802                 {:?}; only `computed` may accompany an assessment",
803                self.quality_status
804            )
805        } else {
806            "report reports quality_status `computed` but carries no quality assessment".to_string()
807        })
808    }
809}
810
811/// Mirror of [`ProfileReport`] carrying the field-level deserialization
812/// rules (legacy aliases, quality compat). Kept private: the public entry
813/// point is the manual [`serde::Deserialize`] impl below, which gates on
814/// `schema_version` before any of these fields decode.
815#[derive(serde::Deserialize)]
816struct ProfileReportFields {
817    #[serde(default)]
818    schema_version: u32,
819    id: String,
820    timestamp: String,
821    data_source: DataSource,
822    column_profiles: Vec<ColumnProfile>,
823    #[serde(alias = "scan_info")]
824    execution: ExecutionMetadata,
825    #[serde(
826        alias = "data_quality_metrics",
827        default,
828        deserialize_with = "deserialize_quality_compat"
829    )]
830    quality: Option<QualityAssessment>,
831    #[serde(default)]
832    quality_status: Option<QualityAnalysisStatus>,
833    #[serde(default)]
834    semantic_hint_bindings: Vec<SemanticHintBinding>,
835    #[serde(default)]
836    metric_semantics: Option<MetricSemantics>,
837}
838
839impl From<ProfileReportFields> for ProfileReport {
840    fn from(fields: ProfileReportFields) -> Self {
841        Self {
842            schema_version: fields.schema_version,
843            id: fields.id,
844            timestamp: fields.timestamp,
845            data_source: fields.data_source,
846            column_profiles: fields.column_profiles,
847            execution: fields.execution,
848            // A document written before the field: a stored assessment proves
849            // the computation ran, and nothing else about it is knowable.
850            quality_status: fields.quality_status.unwrap_or({
851                if fields.quality.is_some() {
852                    QualityAnalysisStatus::Computed
853                } else {
854                    QualityAnalysisStatus::Unrecorded
855                }
856            }),
857            quality: fields.quality,
858            semantic_hint_bindings: fields.semantic_hint_bindings,
859            metric_semantics: fields.metric_semantics,
860        }
861    }
862}
863
864/// Manual deserialization so the `schema_version` gate runs before any other
865/// field decodes. A derived deserializer visits fields in document order, so
866/// an unsupported future document that also changed structure could fail with
867/// a confusing structural error instead of the actionable version error; by
868/// buffering the document first, the version check always wins.
869impl<'de> serde::Deserialize<'de> for ProfileReport {
870    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
871    where
872        D: serde::Deserializer<'de>,
873    {
874        use serde::de::Error;
875
876        let value = serde_json::Value::deserialize(deserializer)?;
877        match value.get("schema_version") {
878            // Absent field: legacy pre-0.10 document, defaults to version 0.
879            None => {}
880            Some(serde_json::Value::Number(n)) if n.as_u64().is_some() => {
881                let version = n.as_u64().unwrap_or_default();
882                if version > u64::from(REPORT_SCHEMA_VERSION) {
883                    return Err(D::Error::custom(format!(
884                        "report schema version {version} is newer than the latest supported \
885                         version {REPORT_SCHEMA_VERSION}; upgrade dataprof to read this report"
886                    )));
887                }
888            }
889            // An explicit null or non-integer is malformed, not legacy.
890            Some(other) => {
891                return Err(D::Error::custom(format!(
892                    "report schema_version must be a non-negative integer, got {other}"
893                )));
894            }
895        }
896        // `quality_status` deserializes through `Option`, which cannot tell an
897        // absent field from an explicit null. Only the absent one is legacy;
898        // a null is a malformed current document, and the fallback would
899        // silently repair it into a plausible reason it never recorded.
900        if value.get("quality_status") == Some(&serde_json::Value::Null) {
901            return Err(D::Error::custom(
902                "report quality_status must be an object; an explicit null is malformed, \
903                 not a document written before the field existed",
904            ));
905        }
906        // Same rule: absent is "not recorded", which reads back as unknown;
907        // a null would be a current writer's malformed output.
908        if value.get("metric_semantics") == Some(&serde_json::Value::Null) {
909            return Err(D::Error::custom(
910                "report metric_semantics must be an object; an explicit null is malformed,                  not a document written before the field existed",
911            ));
912        }
913        let report = ProfileReportFields::deserialize(value)
914            .map(ProfileReport::from)
915            .map_err(D::Error::custom)?;
916        report.check_quality_pairing().map_err(D::Error::custom)
917    }
918}
919
920/// Custom deserializer that handles both legacy `DataQualityMetrics` (flat)
921/// and new `QualityAssessment` (wrapped with confidence) JSON formats.
922fn deserialize_quality_compat<'de, D>(
923    deserializer: D,
924) -> Result<Option<QualityAssessment>, D::Error>
925where
926    D: serde::Deserializer<'de>,
927{
928    use serde::Deserialize;
929
930    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
931    match value {
932        None => Ok(None),
933        Some(v) => {
934            if v.get("metrics").is_some() && v.get("confidence").is_some() {
935                let assessment: QualityAssessment =
936                    serde_json::from_value(v).map_err(serde::de::Error::custom)?;
937                Ok(Some(assessment))
938            } else {
939                let metrics: QualityMetrics =
940                    serde_json::from_value(v).map_err(serde::de::Error::custom)?;
941                // A flat legacy quality object says nothing about how its
942                // numbers were obtained. Recording `Exact` here would invent
943                // that, and a gate asked about the whole source would then
944                // rest a verdict on numbers that may have come from a sample.
945                Ok(Some(QualityAssessment::new(
946                    metrics,
947                    MetricConfidence::Unrecorded,
948                )))
949            }
950        }
951    }
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957    use dataprof_core::FileFormat;
958    use dataprof_metrics::MetricConfidence;
959    use serde_json::json;
960
961    fn report_without_quality() -> ProfileReport {
962        ProfileReport::new(
963            DataSource::File {
964                path: "test.csv".to_string(),
965                format: FileFormat::Csv,
966                size_bytes: 1024,
967                modified_at: None,
968                parquet_metadata: None,
969            },
970            vec![],
971            ExecutionMetadata::new(100, 5, 50),
972            None,
973        )
974    }
975
976    /// The `state` strings are the wire vocabulary: consumers branch on them,
977    /// the Python binding repeats them by hand, and the committed schema
978    /// enumerates them. Pin them here so a variant rename has to be deliberate.
979    #[test]
980    fn every_status_has_a_stable_wire_name() {
981        let cases = [
982            (QualityAnalysisStatus::Computed, "computed"),
983            (QualityAnalysisStatus::NotRequested, "not_requested"),
984            (QualityAnalysisStatus::NoData, "no_data"),
985            (
986                QualityAnalysisStatus::WithheldByProjection,
987                "withheld_by_projection",
988            ),
989            (
990                QualityAnalysisStatus::Failed {
991                    error: "boom".to_string(),
992                },
993                "failed",
994            ),
995            (QualityAnalysisStatus::Unrecorded, "unrecorded"),
996        ];
997
998        for (status, name) in cases {
999            let value = serde_json::to_value(&status).unwrap();
1000            assert_eq!(value.get("state"), Some(&json!(name)), "{status:?}");
1001            let restored: QualityAnalysisStatus = serde_json::from_value(value).unwrap();
1002            assert_eq!(restored, status);
1003        }
1004    }
1005
1006    /// A document may not claim one thing in `quality_status` and another in
1007    /// `quality`. Loading one would let a consumer act on an assessment the
1008    /// report says never completed.
1009    #[test]
1010    fn contradictory_status_and_assessment_fail_to_decode() {
1011        let with_assessment =
1012            report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1013                error: "boom".to_string(),
1014            });
1015        let mut document = serde_json::to_value(&with_assessment).unwrap();
1016        document.as_object_mut().unwrap().insert(
1017            "quality".to_string(),
1018            serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1019        );
1020        let error = serde_json::from_value::<ProfileReport>(document)
1021            .expect_err("an assessment under a `failed` status is not a readable report");
1022        assert!(
1023            error.to_string().contains("only `computed`"),
1024            "unhelpful error: {error}"
1025        );
1026
1027        let mut document = serde_json::to_value(
1028            report_without_quality().with_quality_status(QualityAnalysisStatus::Computed),
1029        )
1030        .unwrap();
1031        document.as_object_mut().unwrap().remove("quality");
1032        let error = serde_json::from_value::<ProfileReport>(document)
1033            .expect_err("`computed` without an assessment is not a readable report");
1034        assert!(
1035            error.to_string().contains("carries no quality assessment"),
1036            "unhelpful error: {error}"
1037        );
1038    }
1039
1040    /// An absent field is a document written before it existed. An explicit
1041    /// null is a malformed current one, and the legacy fallback would repair it
1042    /// into a reason the writer never recorded.
1043    #[test]
1044    fn an_explicit_null_status_is_malformed_not_legacy() {
1045        let mut document = serde_json::to_value(report_without_quality()).unwrap();
1046        document
1047            .as_object_mut()
1048            .unwrap()
1049            .insert("quality_status".to_string(), serde_json::Value::Null);
1050
1051        let error = serde_json::from_value::<ProfileReport>(document)
1052            .expect_err("an explicit null is not a readable status");
1053        assert!(
1054            error.to_string().contains("explicit null is malformed"),
1055            "unhelpful error: {error}"
1056        );
1057    }
1058
1059    fn summary_value(report: &ProfileReport) -> serde_json::Value {
1060        serde_json::from_str(&report.summary_json().unwrap()).unwrap()
1061    }
1062
1063    /// A report built by this crate records how it measured, in both dialects.
1064    /// A document written before the field reads back as unknown, and saving
1065    /// it again must not stamp it with the reader's definitions.
1066    #[test]
1067    fn metric_semantics_are_recorded_and_absence_survives_a_roundtrip() {
1068        let report = report_without_quality();
1069        assert_eq!(report.metric_semantics, Some(MetricSemantics::current()));
1070        let mut document = serde_json::to_value(&report).unwrap();
1071        let recorded = json!({"text_length_unit": "unicode_scalar"});
1072        assert_eq!(document["metric_semantics"], recorded);
1073        assert_eq!(summary_value(&report)["metric_semantics"], recorded);
1074
1075        document.as_object_mut().unwrap().remove("metric_semantics");
1076        let legacy: ProfileReport = serde_json::from_value(document).unwrap();
1077        assert_eq!(legacy.metric_semantics, None);
1078        assert!(
1079            serde_json::to_value(&legacy)
1080                .unwrap()
1081                .get("metric_semantics")
1082                .is_none()
1083        );
1084        assert!(summary_value(&legacy).get("metric_semantics").is_none());
1085    }
1086
1087    #[test]
1088    fn malformed_metric_semantics_fail_to_decode() {
1089        for value in [
1090            serde_json::Value::Null,
1091            json!("unicode_scalar"),
1092            json!({"text_length_unit": "utf8_byte"}),
1093            json!({"text_length_unit": null}),
1094        ] {
1095            let mut document = serde_json::to_value(report_without_quality()).unwrap();
1096            document["metric_semantics"] = value.clone();
1097            assert!(
1098                serde_json::from_value::<ProfileReport>(document).is_err(),
1099                "{value} decoded"
1100            );
1101        }
1102    }
1103
1104    /// Later releases add definitions; an older reader keeps the ones it knows.
1105    #[test]
1106    fn unknown_metric_definitions_are_ignored() {
1107        let mut document = serde_json::to_value(report_without_quality()).unwrap();
1108        document["metric_semantics"]["grapheme_policy"] = json!("extended");
1109        let restored: ProfileReport = serde_json::from_value(document).unwrap();
1110        assert_eq!(restored.metric_semantics, Some(MetricSemantics::current()));
1111    }
1112
1113    #[test]
1114    fn quality_status_survives_a_json_roundtrip() {
1115        let report = report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1116            error: "Metrics calculation failed: no data columns found".to_string(),
1117        });
1118
1119        let json = serde_json::to_string(&report).unwrap();
1120        let restored: ProfileReport = serde_json::from_str(&json).unwrap();
1121
1122        assert_eq!(restored.quality_status, report.quality_status);
1123        assert!(restored.quality.is_none());
1124    }
1125
1126    /// The serialized document is the contract, so the two states have to be
1127    /// distinguishable there and not only in the Rust value.
1128    #[test]
1129    fn serialized_failure_and_skip_differ() {
1130        let failed = serde_json::to_value(report_without_quality().with_quality_status(
1131            QualityAnalysisStatus::Failed {
1132                error: "boom".to_string(),
1133            },
1134        ))
1135        .unwrap();
1136        let skipped = serde_json::to_value(
1137            report_without_quality().with_quality_status(QualityAnalysisStatus::NotRequested),
1138        )
1139        .unwrap();
1140
1141        assert_eq!(
1142            failed.get("quality_status"),
1143            Some(&json!({"state": "failed", "error": "boom"}))
1144        );
1145        assert_eq!(
1146            skipped.get("quality_status"),
1147            Some(&json!({"state": "not_requested"}))
1148        );
1149        assert!(failed.get("quality").is_none());
1150        assert!(skipped.get("quality").is_none());
1151    }
1152
1153    /// A document written before the field existed: an assessment proves the
1154    /// computation ran, and its absence proves nothing.
1155    #[test]
1156    fn documents_without_the_field_read_back_honestly() {
1157        let mut document = serde_json::to_value(report_without_quality()).unwrap();
1158        document.as_object_mut().unwrap().remove("quality_status");
1159
1160        let restored: ProfileReport = serde_json::from_value(document.clone()).unwrap();
1161        assert_eq!(restored.quality_status, QualityAnalysisStatus::Unrecorded);
1162
1163        document.as_object_mut().unwrap().insert(
1164            "quality".to_string(),
1165            serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1166        );
1167        let restored: ProfileReport = serde_json::from_value(document).unwrap();
1168        assert_eq!(restored.quality_status, QualityAnalysisStatus::Computed);
1169    }
1170
1171    #[test]
1172    fn test_profile_report_json_roundtrip() {
1173        let report = ProfileReport::new(
1174            DataSource::File {
1175                path: "test.csv".to_string(),
1176                format: FileFormat::Csv,
1177                size_bytes: 1024,
1178                modified_at: None,
1179                parquet_metadata: None,
1180            },
1181            vec![],
1182            ExecutionMetadata::new(100, 5, 50),
1183            Some(QualityAssessment::exact(QualityMetrics::empty())),
1184        );
1185
1186        let json = serde_json::to_string(&report).unwrap();
1187        let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1188
1189        assert_eq!(deserialized.id, report.id);
1190        assert_eq!(deserialized.timestamp, report.timestamp);
1191        assert_eq!(deserialized.source_identifier(), "test.csv");
1192        assert_eq!(deserialized.execution.rows_processed, 100);
1193        assert!(deserialized.quality.is_some());
1194        assert_eq!(deserialized.schema_version, REPORT_SCHEMA_VERSION);
1195    }
1196
1197    /// The weights determine the aggregate score, so a summary that drops
1198    /// custom ones reloads with a score its weights no longer produce.
1199    #[test]
1200    fn summary_keeps_custom_score_weights_and_omits_defaults() {
1201        let summary_quality = |weights| {
1202            let mut metrics = QualityMetrics::empty();
1203            metrics.score_weights = weights;
1204            let report = ProfileReport::new(
1205                DataSource::File {
1206                    path: "test.csv".to_string(),
1207                    format: FileFormat::Csv,
1208                    size_bytes: 1024,
1209                    modified_at: None,
1210                    parquet_metadata: None,
1211                },
1212                vec![],
1213                ExecutionMetadata::new(100, 5, 50),
1214                Some(QualityAssessment::exact(metrics)),
1215            );
1216            let summary: serde_json::Value =
1217                serde_json::from_str(&report.summary_json().unwrap()).unwrap();
1218            summary["quality"].clone()
1219        };
1220
1221        let custom = QualityScoreWeights {
1222            completeness: 1.0,
1223            consistency: 0.0,
1224            uniqueness: 0.0,
1225            accuracy: 0.0,
1226            timeliness: 0.0,
1227            validity: 0.0,
1228            precision: 3.0,
1229        };
1230        assert_eq!(
1231            summary_quality(custom)["score_weights"],
1232            serde_json::to_value(custom).unwrap()
1233        );
1234        // Mirrors the canonical document, so default-weight summaries are unchanged.
1235        assert!(
1236            summary_quality(QualityScoreWeights::default())
1237                .get("score_weights")
1238                .is_none()
1239        );
1240    }
1241
1242    #[test]
1243    fn test_serialized_report_carries_schema_version() {
1244        let report = ProfileReport::new(
1245            DataSource::File {
1246                path: "test.csv".to_string(),
1247                format: FileFormat::Csv,
1248                size_bytes: 1024,
1249                modified_at: None,
1250                parquet_metadata: None,
1251            },
1252            vec![],
1253            ExecutionMetadata::new(100, 5, 50),
1254            None,
1255        );
1256
1257        let value = serde_json::to_value(&report).unwrap();
1258        assert_eq!(
1259            value.get("schema_version").and_then(|v| v.as_u64()),
1260            Some(u64::from(REPORT_SCHEMA_VERSION))
1261        );
1262    }
1263
1264    #[test]
1265    fn test_profile_report_without_quality() {
1266        let report = ProfileReport::new(
1267            DataSource::File {
1268                path: "test.csv".to_string(),
1269                format: FileFormat::Csv,
1270                size_bytes: 1024,
1271                modified_at: None,
1272                parquet_metadata: None,
1273            },
1274            vec![],
1275            ExecutionMetadata::new(100, 5, 50),
1276            None,
1277        );
1278
1279        let json = serde_json::to_string(&report).unwrap();
1280        let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1281
1282        assert!(deserialized.quality.is_none());
1283        assert_eq!(deserialized.execution.rows_processed, 100);
1284    }
1285
1286    #[test]
1287    fn test_profile_report_deserializes_legacy_quality_metrics() {
1288        let json = json!({
1289            "id": "legacy-report",
1290            "timestamp": "2026-05-22T10:00:00Z",
1291            "data_source": {
1292                "type": "file",
1293                "path": "test.csv",
1294                "format": "csv",
1295                "size_bytes": 42
1296            },
1297            "column_profiles": [],
1298            "scan_info": {
1299                "rows_processed": 10,
1300                "columns_detected": 2,
1301                "scan_time_ms": 5,
1302                "error_count": 0,
1303                "source_exhausted": true,
1304                "sampling_applied": false
1305            },
1306            "data_quality_metrics": {
1307                "completeness": {
1308                    "missing_values_ratio": 0.0,
1309                    "complete_records_ratio": 100.0,
1310                    "null_columns": []
1311                }
1312            }
1313        });
1314
1315        let report: ProfileReport = serde_json::from_value(json).unwrap();
1316
1317        assert_eq!(report.id, "legacy-report");
1318        // A document without a schema_version field is a legacy pre-0.10
1319        // report; it must load and be identifiable as such.
1320        assert_eq!(report.schema_version, 0);
1321        assert_eq!(report.execution.rows_processed, 10);
1322        // Legacy metrics predate the assessability denominators: the facts
1323        // stay readable, but no score is fabricated from them.
1324        assert!(report.quality_score().is_none());
1325        let quality = report
1326            .quality
1327            .expect("expected legacy quality to deserialize");
1328        // Nothing was assessable, so the assessment says so rather than
1329        // claiming exactness about a score it never produced (#571).
1330        assert!(matches!(quality.confidence, MetricConfidence::NotAssessed));
1331        let completeness = quality
1332            .metrics
1333            .completeness
1334            .as_ref()
1335            .expect("legacy completeness facts should deserialize");
1336        assert!((completeness.complete_records_ratio - 100.0).abs() < 0.01);
1337        assert!(quality.metrics.assessed_dimensions().is_empty());
1338    }
1339
1340    fn current_document() -> serde_json::Value {
1341        json!({
1342            "schema_version": REPORT_SCHEMA_VERSION,
1343            "id": "current-report",
1344            "timestamp": "2026-07-16T10:00:00Z",
1345            "data_source": {
1346                "type": "file",
1347                "path": "test.csv",
1348                "format": "csv",
1349                "size_bytes": 42
1350            },
1351            "column_profiles": [],
1352            "execution": {
1353                "rows_processed": 10,
1354                "columns_detected": 2,
1355                "scan_time_ms": 5,
1356                "error_count": 0,
1357                "source_exhausted": true,
1358                "sampling_applied": false
1359            }
1360        })
1361    }
1362
1363    #[test]
1364    fn test_additive_fields_from_newer_writer_are_ignored() {
1365        // A newer dataprof may add fields within the same schema version;
1366        // a compatible reader must not break on them.
1367        let mut json = current_document();
1368        json["a_future_additive_field"] = json!({"anything": true});
1369        json["column_profiles"] = json!([]);
1370
1371        let report: ProfileReport = serde_json::from_value(json).unwrap();
1372        assert_eq!(report.schema_version, REPORT_SCHEMA_VERSION);
1373        assert_eq!(report.id, "current-report");
1374    }
1375
1376    #[test]
1377    fn test_unsupported_future_schema_version_fails_explicitly() {
1378        let mut json = current_document();
1379        json["schema_version"] = json!(REPORT_SCHEMA_VERSION + 1);
1380
1381        let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1382        let msg = err.to_string();
1383        assert!(
1384            msg.contains("schema version") && msg.contains("upgrade dataprof"),
1385            "expected an actionable schema-version error, got: {msg}"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_version_error_wins_over_structural_errors() {
1391        // A future document that also broke structure, with schema_version
1392        // appearing after the broken field, must still fail with the version
1393        // error — not a confusing type error from the field encountered first.
1394        let json_text = format!(
1395            r#"{{"column_profiles": "not-an-array", "schema_version": {}}}"#,
1396            REPORT_SCHEMA_VERSION + 1
1397        );
1398        let err = serde_json::from_str::<ProfileReport>(&json_text).unwrap_err();
1399        let msg = err.to_string();
1400        assert!(
1401            msg.contains("upgrade dataprof"),
1402            "expected the schema-version error to win, got: {msg}"
1403        );
1404    }
1405
1406    #[test]
1407    fn test_null_schema_version_is_malformed_not_legacy() {
1408        let mut json = current_document();
1409        json["schema_version"] = json!(null);
1410
1411        let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1412        let msg = err.to_string();
1413        assert!(
1414            msg.contains("schema_version must be a non-negative integer"),
1415            "expected explicit rejection of null schema_version, got: {msg}"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_non_integer_schema_version_is_rejected() {
1421        for bad in [json!("1"), json!(1.5), json!(-1), json!(true)] {
1422            let mut json = current_document();
1423            json["schema_version"] = bad.clone();
1424            let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1425            assert!(
1426                err.to_string().contains("non-negative integer"),
1427                "expected rejection of {bad}, got: {err}"
1428            );
1429        }
1430    }
1431}