Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/audit-trail.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
14 changes: 14 additions & 0 deletions src/freshdata/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
178 changes: 178 additions & 0 deletions src/freshdata/schemas/clean_report.schema.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
78 changes: 78 additions & 0 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

import pandas as pd
from jsonschema import Draft202012Validator, validate

import freshdata as fd

Expand Down Expand Up @@ -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()
Expand Down
Loading