Reports can be stored with a dataset, shared in an issue, passed to an agent, or compared across runs. This chapter covers JSON, Markdown, the context-budgeted digest, the Python dict, and the conditions under which serialization is deterministic. The field-level reference is JSON report reference.
The fixture contains a region-code mapping and a column equal to twice the quantity:
import json
import random
import polars as pl
import onceover
rng = random.Random(4)
n = 800
region_id = [rng.randrange(3) for _ in range(n)]
region_name = [["red", "green", "blue"][r] for r in region_id]
quantity = [rng.randrange(1, 11) for _ in range(n)]
double_quantity = [2 * q for q in quantity]
df = pl.DataFrame({
"order_id": list(range(n)),
"region_id": region_id,
"region_name": region_name,
"quantity": quantity,
"double_quantity": double_quantity,
})
report = onceover.profile(df)
print(report.overview["headline"])800 rows × 5 columns (standard budget); key candidate: order_id; 5 functional dependencies, 4 predictive dependencies, 6 conditional rules
JSON¶
to_json() returns the complete report — overview, columns, findings,
groups, selection summary, coverage — as one deterministic, schema-versioned
string. report.data parses it into a dict; save(path) writes it;
json.dumps(report.data, indent=2) formats it for reading.
schema_version (currently "0.2") identifies the report format. Changes
are usually additive, but the 0.1 → 0.2 revision changed the finding
taxonomy incompatibly. Readers should check the schema version and
tolerate unknown fields and categories.
Determinism¶
With the same package version, input, and settings, repeated runs produce identical JSON when no deadline is set. A deadline can change which passes finish. Coverage records those skips.
Two different tables can produce the same summary, so matching report JSON does not prove that the input data is identical.
first = onceover.profile(df)
second = onceover.profile(df, budget="standard")
print("byte-identical:", first.to_json() == second.to_json())
print("schema_version:", report.data["schema_version"])
print("selection:", report.data["selection"])byte-identical: True
schema_version: 0.2
selection: {'candidates': 42, 'verified_candidates': 32, 'distinct_statements': 27, 'selected': 27}
selection.candidates counts the detectors’ candidates before consolidation.
selection.distinct_statements counts the different claims after matching
candidates are combined; selection.selected counts the findings retained
under the selection limits. Compare these counts when a report contains
fewer findings than the detectors produced.
Markdown¶
to_markdown() renders the blocks as a compact document — headline, ranked
findings with their basis and form, the related-finding groups, a column
table, and the coverage table. It is the format meant for pasting into a
pull request or handing to a person. In a notebook, the report object
renders itself this way automatically:
print(report.to_markdown()[:800])# onceover report
800 rows × 5 columns (standard budget); key candidate: order_id; 5 functional dependencies, 4 predictive dependencies, 6 conditional rules
## Overview
- **rows × columns**: 800 × 5
- **dtypes**: 4×int64, 1×string
- **roles**: 4×categorical, 1×identifier
## Findings
- **[0.95·measured] uniqueness/uniqueness** — order_id uniquely identifies rows (primary key candidate)
- evidence: keys (uniqueness), direct_comparison, 800 rows evaluated, full data
- metrics: distinct=800, n=800, plausibility_score=1
- **[0.8695·measured] dependencies/functional (affine)** — double_quantity = 2.000000·quantity within tolerance on all 800 non-missing row pairs
- evidence: identities (ols_residual_scan), direct_comparison, 800 rows evaluated, full data
- metrics: a=2, absolute
Digest¶
report.to_digest(chars) renders a plain-text digest sized to a
reading budget — built for the case where the report’s JSON is larger
than the context it has to fit into. The digest has a header (shape,
schema version, headline, coverage exceptions, finding counts by
category), then one line per rollup group: findings that share
category, type, form, and target, carrying the group’s strongest
claim, strongest first, with a (xN) marker when a group rolls up N
findings. When the groups exceed the budget, the digest keeps as many
as fit and ends by stating how many were omitted; the result never
exceeds the budget. At very small budgets the header itself degrades
first — category counts, then the coverage line — before anything is
truncated.
A digest is an index into the report, not a summary of record. A rollup line names a group and its strongest claim; the group’s other members, and every finding’s evidence, remain in the JSON. Two findings that share a rollup key can differ in columns and conditions, so the marker is a prompt to look, not a claim that the members are interchangeable.
print(report.to_digest(500))profile digest: 800 rows x 5 columns, 27 findings, schema 0.2
headline: 800 rows × 5 columns (standard budget); key candidate: order_id; 5 functional dependencies, 4 predictive dependencies, 6 conditional rules
... [19 rolled-up finding groups omitted to fit 500 chars; 27 findings total]
Feeding an agent¶
The function below copies each finding’s claim verbatim and includes its
id and category. It summarizes the evidence record with the largest
rows_evaluated, giving its basis, validation method, and row count.
The complete evidence remains available in the report.
def brief(f):
primary = max(f["evidence"], key=lambda e: e["scope"]["rows_evaluated"] or 0)
return {
"id": f["id"],
"category": f["category"],
"type": f.get("type"),
"claim": f["claim"],
"basis": primary["basis"],
"validation": primary["validation"]["method"],
"rows_evaluated": primary["scope"]["rows_evaluated"],
"interpretation": f.get("interpretation"),
}
digest = {
"headline": report.overview["headline"],
"top_findings": [brief(f) for f in report.findings[:4]],
"coverage": report.coverage,
}
print(json.dumps(digest["top_findings"][0], indent=2)){
"id": "uniqueness-1ac6146d2c27c2e2",
"category": "uniqueness",
"type": "uniqueness",
"claim": "order_id uniquely identifies rows (primary key candidate)",
"basis": "measured",
"validation": "direct_comparison",
"rows_evaluated": 800,
"interpretation": "use as join key; exclude from numeric features"
}
Keep the basis, row count, and validation method with the claim when sharing this digest. The record with the most evaluated rows is not necessarily the best validated: inspect the full evidence before choosing a result for an automated action. When no finding is reported, check coverage to see whether the detector ran and what rows and candidates it examined.
The Rust API¶
The same profiler is a Rust crate over Polars 0.55, for pipelines that never leave Rust:
[dependencies]
onceover = "0.1"
polars = "0.55"use onceover::{profile, Budget, ProfileConfig};
use polars::prelude::*;
fn main() -> PolarsResult<()> {
let df = df!("id" => [1i64, 2, 3], "value" => [4.0, 5.0, 6.0])?;
let cfg = ProfileConfig {
budget: Budget::Deep,
detectors: None, // None: default detectors
selection: Default::default(),
deadline: None,
sample_rows: None,
max_predictors: None,
max_pairs: None,
};
let report = profile(&df, &cfg);
println!("{}", report.overview.headline);
println!("{}", report.to_json()); // same schema, same determinism
Ok(())
}ProfileConfig::default() uses the standard budget, default detectors,
the default selection policy, and no deadline; the selection field is the
same SelectionPolicy that caps findings and rules in Python via
max_findings/max_rules. The report schema is identical across
languages, so a Rust service and a Python notebook produce comparable
artifacts from the same table.