From af592d1e63ebc0269ade27c62e8989921b55eadf Mon Sep 17 00:00:00 2001 From: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:46:47 +0800 Subject: [PATCH] feat: publish CleanReport JSON Schema --- docs/audit-trail.md | 49 +++++ mkdocs.yml | 1 + pyproject.toml | 1 + src/freshdata/report.py | 14 ++ .../schemas/clean_report.schema.json | 178 ++++++++++++++++++ tests/test_report.py | 78 ++++++++ 6 files changed, 321 insertions(+) create mode 100644 docs/audit-trail.md create mode 100644 src/freshdata/schemas/clean_report.schema.json diff --git a/docs/audit-trail.md b/docs/audit-trail.md new file mode 100644 index 0000000..edabb67 --- /dev/null +++ b/docs/audit-trail.md @@ -0,0 +1,49 @@ +--- +title: Audit-trail JSON and schema +description: >- + Export, persist, and validate FreshData CleanReport audit payloads with the + JSON Schema shipped in every installation. +keywords: freshdata audit trail, CleanReport JSON Schema, data cleaning audit log +--- + +# Audit-trail JSON and schema + +Every cleaning run can return a [`CleanReport`](api-reference.md) containing the +ordered actions, affected counts, rationale, risk, confidence, warnings, and +execution metadata. The report exposes the same stable payload as a dictionary +or JSON text: + +```python +import freshdata as fd + +cleaned, report = fd.clean(df, return_report=True) + +payload = report.to_dict() +report.write_json("audit/run-2026-08-21.json", indent=2) +``` + +## Validate an exported report + +FreshData ships a Draft 2020-12 JSON Schema inside the package. Load a fresh +copy with `CleanReport.to_json_schema()` and pass it to any compatible +validator: + +```python +from jsonschema import validate + +schema = fd.CleanReport.to_json_schema() +validate(instance=payload, schema=schema) +``` + +`jsonschema` is a development or application dependency, not a FreshData core +dependency. Install it separately when validation is part of your pipeline: + +```bash +pip install jsonschema +``` + +The schema requires the core report summary and action fields, constrains known +enums such as action risk and status, and documents optional backend, streaming, +domain, provenance, contract, and profile-replay sections. The helper only reads +the bundled schema, so it works offline and does not add validation overhead to +normal cleaning runs. diff --git a/mkdocs.yml b/mkdocs.yml index 6d4fe78..c52d85a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,7 @@ nav: - Quickstart: quickstart.md - Guide: - Cleaning engine: cleaning-engine.md + - Audit-trail JSON and schema: audit-trail.md - Validation (suites & contracts): validation.md - Interactive output: interactive.md - Peel output system: peel.md diff --git a/pyproject.toml b/pyproject.toml index 1fa733d..111db79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,6 +227,7 @@ packages = ["src/freshdata"] # (*.onnx) are intentionally never shipped in the wheel. artifacts = [ "src/freshdata/integrations/dbt/macros/*.sql", + "src/freshdata/schemas/*.json", "src/freshdata/semantic/data/*.json", ] diff --git a/src/freshdata/report.py b/src/freshdata/report.py index 381b08c..eef79aa 100644 --- a/src/freshdata/report.py +++ b/src/freshdata/report.py @@ -13,6 +13,7 @@ import json from collections.abc import Iterator from dataclasses import dataclass, field +from importlib import resources from pathlib import Path from typing import Any @@ -392,6 +393,19 @@ def to_json(self, **kwargs: Any) -> str: kwargs.setdefault("default", str) return json.dumps(self.to_dict(), **kwargs) + @classmethod + def to_json_schema(cls) -> dict[str, Any]: + """Return the published JSON Schema for :meth:`to_dict` payloads. + + The schema is shipped with the package, so loading it never requires + network access. A fresh dictionary is returned on every call and may + be passed directly to validators such as :mod:`jsonschema`. + """ + schema = resources.files("freshdata").joinpath("schemas").joinpath( + "clean_report.schema.json" + ) + return json.loads(schema.read_text(encoding="utf-8")) + def write_json(self, path: str | Path, **kwargs: Any) -> None: """Write the report's JSON audit payload to *path* as UTF-8 text.""" Path(path).write_text(self.to_json(**kwargs) + "\n", encoding="utf-8") diff --git a/src/freshdata/schemas/clean_report.schema.json b/src/freshdata/schemas/clean_report.schema.json new file mode 100644 index 0000000..41c1500 --- /dev/null +++ b/src/freshdata/schemas/clean_report.schema.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/FreshCode-Org/freshdata/main/src/freshdata/schemas/clean_report.schema.json", + "title": "FreshData CleanReport", + "description": "The stable audit payload returned by CleanReport.to_dict() and serialized by CleanReport.to_json().", + "type": "object", + "required": [ + "rows_before", + "rows_after", + "cols_before", + "cols_after", + "memory_before", + "memory_after", + "duration_seconds", + "missing_before", + "missing_after", + "duplicates_removed", + "outliers_handled", + "columns_dropped", + "columns_imputed", + "columns_preserved", + "warnings", + "recommendations", + "actions" + ], + "properties": { + "rows_before": { "$ref": "#/$defs/nonNegativeInteger" }, + "rows_after": { "$ref": "#/$defs/nonNegativeInteger" }, + "cols_before": { "$ref": "#/$defs/nonNegativeInteger" }, + "cols_after": { "$ref": "#/$defs/nonNegativeInteger" }, + "memory_before": { "$ref": "#/$defs/nonNegativeInteger" }, + "memory_after": { "$ref": "#/$defs/nonNegativeInteger" }, + "duration_seconds": { "type": "number", "minimum": 0 }, + "missing_before": { "$ref": "#/$defs/nonNegativeInteger" }, + "missing_after": { "$ref": "#/$defs/nonNegativeInteger" }, + "duplicates_removed": { "$ref": "#/$defs/nonNegativeInteger" }, + "outliers_handled": { "$ref": "#/$defs/nonNegativeInteger" }, + "columns_dropped": { "$ref": "#/$defs/stringArray" }, + "columns_imputed": { "$ref": "#/$defs/stringArray" }, + "columns_preserved": { "$ref": "#/$defs/stringArray" }, + "warnings": { "$ref": "#/$defs/stringArray" }, + "recommendations": { "$ref": "#/$defs/stringArray" }, + "actions": { + "type": "array", + "items": { "$ref": "#/$defs/action" } + }, + "coerced_cells": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/scalar" } + } + }, + "domain": { "type": "string" }, + "domain_trust_score": { + "type": ["number", "null"], + "minimum": 0, + "maximum": 1 + }, + "domain_findings": { + "type": "array", + "items": { "type": "object" } + }, + "domain_repairs": { + "type": "array", + "items": { "type": "object" } + }, + "streaming": { "type": "object" }, + "backend": { "type": "string" }, + "requested_backend": { "type": "string" }, + "peak_memory": { "$ref": "#/$defs/nonNegativeInteger" }, + "rows_materialized": { "$ref": "#/$defs/nonNegativeInteger" }, + "materialized": { "const": false }, + "fallback_events": { + "type": "array", + "items": { "$ref": "#/$defs/fallbackEvent" } + }, + "backend_differences": { + "type": "array", + "items": { "$ref": "#/$defs/backendDifference" } + }, + "stage_timings": { + "type": "array", + "items": { "$ref": "#/$defs/stageTiming" } + }, + "source_provenance": { "type": "object" }, + "contract_violations": { "type": "object" }, + "decisions_hash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "profile_replay": { "type": "object" } + }, + "additionalProperties": false, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "stringArray": { + "type": "array", + "items": { "type": "string" } + }, + "scalar": { + "type": ["string", "number", "boolean", "null"] + }, + "action": { + "type": "object", + "required": [ + "step", + "column", + "description", + "count", + "rationale", + "risk", + "confidence", + "model_id", + "status", + "reversible", + "memory_influenced", + "human_review" + ], + "properties": { + "step": { "type": "string" }, + "column": { "type": ["string", "null"] }, + "description": { "type": "string" }, + "count": { "$ref": "#/$defs/nonNegativeInteger" }, + "rationale": { "type": "string" }, + "risk": { "enum": ["low", "medium", "high"] }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "model_id": { "type": "string" }, + "status": { + "enum": ["automatic", "suggested", "skipped", "approved"] + }, + "reversible": { "type": ["boolean", "null"] }, + "memory_influenced": { "type": "boolean" }, + "human_review": { "type": "boolean" }, + "metadata": { "type": "object" } + }, + "additionalProperties": false + }, + "fallbackEvent": { + "type": "object", + "required": ["backend", "fallback_step", "fallback_reason"], + "properties": { + "backend": { "type": "string" }, + "fallback_step": { "type": "string" }, + "fallback_reason": { "type": "string" } + }, + "additionalProperties": false + }, + "backendDifference": { + "type": "object", + "required": ["backend", "step", "column", "detail"], + "properties": { + "backend": { "type": "string" }, + "step": { "type": "string" }, + "column": { "type": ["string", "null"] }, + "detail": { "type": "string" } + }, + "additionalProperties": false + }, + "stageTiming": { + "type": "object", + "required": ["backend", "stage", "seconds"], + "properties": { + "backend": { "type": "string" }, + "stage": { "type": "string" }, + "seconds": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + } + } +} diff --git a/tests/test_report.py b/tests/test_report.py index 798c9ab..6143368 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -3,6 +3,7 @@ from pathlib import Path import pandas as pd +from jsonschema import Draft202012Validator, validate import freshdata as fd @@ -71,6 +72,83 @@ def test_json_exports_serialize_nested_audit_values(messy, tmp_path: Path): assert json.loads(path.read_text(encoding="utf-8")) == payload +def test_json_schema_validates_representative_report(): + report = fd.CleanReport( + actions=[ + fd.Action( + step="missing", + column="age", + description="Filled one missing value", + count=1, + rationale="Median is robust to the observed skew", + risk="medium", + confidence=0.9, + status="approved", + reversible=True, + memory_influenced=True, + human_review=True, + metadata={"fill_value": 42.0}, + ) + ], + rows_before=4, + rows_after=4, + cols_before=2, + cols_after=2, + memory_before=256, + memory_after=256, + duration_seconds=0.02, + missing_before=1, + missing_after=0, + columns_imputed=["age"], + coerced_cells={"age": {"row-1": "unknown"}}, + domain="customers", + domain_trust_score=0.98, + domain_findings=[{"status": "passed"}], + domain_repairs=[{"status": "applied"}], + streaming={"batch_id": "batch-7"}, + backend="pandas", + requested_backend="auto", + peak_memory=1024, + rows_materialized=4, + materialized=False, + fallback_events=[ + { + "backend": "polars", + "fallback_step": "missing", + "fallback_reason": "unsupported dtype", + } + ], + backend_differences=[ + { + "backend": "polars", + "step": "outliers", + "column": "age", + "detail": "nearest-rank quantile", + } + ], + stage_timings=[{"backend": "pandas", "stage": "missing", "seconds": 0.01}], + source_provenance={"age": {"source_file": "customers.csv"}}, + contract_violations={"passed": True}, + decisions_hash="a" * 64, + profile_replay={"profile_id": "customers-v1"}, + ) + schema = report.to_json_schema() + + Draft202012Validator.check_schema(schema) + validate(instance=report.to_dict(), schema=schema) + validate(instance=json.loads(report.to_json()), schema=schema) + + +def test_json_schema_rejects_invalid_action_risk(): + payload = fd.CleanReport( + actions=[fd.Action(step="missing", column="age", description="Filled", risk="urgent")] + ).to_dict() + + validator = Draft202012Validator(fd.CleanReport.to_json_schema()) + + assert "urgent" in str(next(validator.iter_errors(payload))) + + def test_to_frame(messy): _, report = fd.clean(messy, return_report=True) frame = report.to_frame()