Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Reports as artifacts

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:

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.

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:

# 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.

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.

{
  "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.