From 7b7809cc8ecdacf10cad5f3321ad90cf05b6a405 Mon Sep 17 00:00:00 2001 From: tmusser Date: Fri, 3 Jul 2026 18:03:35 -0400 Subject: [PATCH 1/2] feat: add distribution chart intents Add first-class histogram, boxplot, and violin support with distribution-specific audit rules, examples, docs, and updated Vega-Lite outputs. --- .gitignore | 1 + README.md | 19 + ROADMAP.md | 6 + artifacts/HANDOFF.md | 60 ++- artifacts/SPEC.md | 6 + artifacts/VERIFY.md | 39 ++ docs/AUDIT_RULES.md | 5 + examples/distribution_charts.py | 102 ++++++ examples/output/boxplot_chart.vl.json | 169 +++++++++ examples/output/compare_claim.vl.json | 6 +- examples/output/corrected_chart.vl.json | 6 +- examples/output/histogram_chart.vl.json | 177 +++++++++ examples/output/rank_claim.vl.json | 6 +- examples/output/trend_claim.vl.json | 12 +- examples/output/violin_chart.vl.json | 193 ++++++++++ src/chart_contract/audit.py | 465 ++++++++++++++++++++++-- src/chart_contract/chart.py | 101 ++++- src/chart_contract/renderers/altair.py | 104 +++++- tests/test_audit_spec.py | 37 +- tests/test_chart_intents.py | 4 + tests/test_distribution_intents.py | 182 ++++++++++ 21 files changed, 1621 insertions(+), 79 deletions(-) create mode 100644 examples/distribution_charts.py create mode 100644 examples/output/boxplot_chart.vl.json create mode 100644 examples/output/histogram_chart.vl.json create mode 100644 examples/output/violin_chart.vl.json create mode 100644 tests/test_distribution_intents.py diff --git a/.gitignore b/.gitignore index 6d071ca..269fddb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ .pytest_cache/ *.egg-info/ future.md +instructions.txt diff --git a/README.md b/README.md index 786a8c0..c08726d 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,9 @@ Supported Python API front-door intents: - `Chart.trend()` - `Chart.rank()` - `Chart.compare()` +- `Chart.histogram()` +- `Chart.boxplot()` +- `Chart.violin()` - experimental `audit_spec()` ## Scope and Non-Goals @@ -205,6 +208,22 @@ Supported Python API front-door intents: The audit layer uses Tufte-inspired visual integrity checks. It does not claim to be Tufte-compliant or Tufte-certified. +## Distribution Charts + +Use distribution intents when the claim is about spread, shape, outliers, or typical values rather than a trend or rank. + +- Histograms are good for one metric's distribution. +- Boxplots are good for robust summaries and outliers. +- Violin plots are good for shape, but they need more observations to stay readable. + +Run `python examples/distribution_charts.py` to write: + +- `examples/output/histogram_chart.vl.json` +- `examples/output/boxplot_chart.vl.json` +- `examples/output/violin_chart.vl.json` + +These charts stay inside the same claim-first audit flow and are not a full visualization library. + ## Companion Artifact This repo was built using `ai-engineering-skills` and is intended as the analytical-integrity proof artifact companion to `context-to-action-skills`. diff --git a/ROADMAP.md b/ROADMAP.md index 0d9f599..b1d55cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,6 +29,12 @@ Explicit non-goals: - Do not add auto-correction. - Do not build a dashboard or chart generator. +## v0.3 distribution preview + +- Chart intents: `Chart.histogram()`, `Chart.boxplot()`, `Chart.violin()` +- Distribution audit rules: numeric metric checks, sample-size thresholds, grouped category thresholds, histogram bins, violin density warnings +- Example artifact: `examples/distribution_charts.py` + ## Later - More visual intents only after the audit contract is strong diff --git a/artifacts/HANDOFF.md b/artifacts/HANDOFF.md index 4ecf853..f54c6c6 100644 --- a/artifacts/HANDOFF.md +++ b/artifacts/HANDOFF.md @@ -2,50 +2,44 @@ RESUME PACKET -- Goal: keep `chart-contract` stable at v0.1 while building out the v0.2 agent gate in small, auditable slices. -- Workflow State: the CLI docs now mention `--fail-on READY|REVIEW|BLOCK` in addition to `--warnings-as-errors`, and the wording stays careful about `READY` as an explicit threshold. -- Branch: `main` -- Next task: commit the docs cleanup and push it if the branch looks good. -- Verification: `./.venv/bin/python -m pytest`, `./.venv/bin/chart-contract --version`, and `git diff --check` -- Read first: `README.md`, `docs/AGENT_INTEGRATION.md`, `artifacts/VERIFY.md` +- Goal: add first-class distribution intents without breaking the existing trend/rank/compare contract or CLI behavior. +- Workflow State: `Chart.histogram()`, `Chart.boxplot()`, `Chart.violin()`, distribution renderers, distribution audit rules, tests, docs, and `examples/distribution_charts.py` are all in place. +- Branch: `codex/distribution-chart-intents` +- Next task: review the diff, then stage/commit or keep iterating if a follow-up is needed. +- Verification: `./.venv/bin/python -m pytest -q`, `./.venv/bin/python examples/distribution_charts.py`, `./.venv/bin/python examples/bad_to_good_chart.py`, `./.venv/bin/chart-contract --help`, and `git diff --check` +- Read first: `README.md`, `ROADMAP.md`, `docs/AUDIT_RULES.md`, `artifacts/VERIFY.md` ## Current Repo State -- The v0.1 scope in `artifacts/SPEC.md` remains unchanged. -- The v0.2.0 roadmap is now explicit and commit-shaped. -- The package version is bumped to 0.2.0 and the changelog calls out the shipped gate surface. -- Report serialization is hardened for CLI use, and the CLI now loads specs/data from disk, emits multiple report formats, and writes file outputs when requested. -- The v0.2 trap fixtures are file-based and runnable from the CLI, with separate spec, data, and claim files for easy inspection. -- The README now advertises the CLI audit gate, and CI runs a trap smoke check in addition to pytest. -- The trend-spec audit now matches the chart-level trend rule for simple line specs, so the single-point trap is BLOCK again. -- The CI workflow now explicitly checks that BLOCK exits 1 and that `--warnings-as-errors` turns REVIEW into a nonzero exit. -- `report.verdict` is now spelled out in the README as the authoritative gate field, and `report.passed` is documented as a no-FAIL signal. -- The `--fail-on` flag is now briefly documented so users do not have to discover it only through `--help`. +- The v0.1 scope in `artifacts/SPEC.md` remains intact, and the new distribution intents live in a separate v0.3 preview section. +- The v0.2.0 agent gate is still intact: CLI loading, report serialization, trap fixtures, and CI smoke checks remain in place. +- Distribution intents now exist as `Chart.histogram()`, `Chart.boxplot()`, and `Chart.violin()` with matching Altair renderers. +- `audit_chart()` and `audit_spec()` now enforce distribution-specific checks for numeric metrics, sample sizes, grouped categories, histogram bins, and violin density warnings. +- `examples/distribution_charts.py` writes histogram, boxplot, and violin Vega-Lite specs into `examples/output/`. +- README, `docs/AUDIT_RULES.md`, `ROADMAP.md`, and `artifacts/SPEC.md` now describe the distribution preview and keep the claim-first framing explicit. +- The repo still has tracked example-output drift whenever the example scripts are run, so those artifacts need review before any commit. ## Working Commands - `git diff --check` -- `./.venv/bin/python -m pytest tests/test_cli.py` -- `./.venv/bin/python -m pytest` +- `./.venv/bin/python -m pytest tests/test_distribution_intents.py -q` +- `./.venv/bin/python -m pytest tests/test_audit_spec.py -q` +- `./.venv/bin/python -m pytest tests/test_chart_intents.py -q` +- `./.venv/bin/python -m pytest -q` +- `./.venv/bin/python examples/distribution_charts.py` +- `./.venv/bin/python examples/bad_to_good_chart.py` +- `./.venv/bin/chart-contract --help` - `sed -n '1,260p' ROADMAP.md` -- `sed -n '1,220p' docs/AGENT_INTEGRATION.md` +- `sed -n '1,220p' docs/AUDIT_RULES.md` ## Important Decisions -- Keep v0.2.0 concrete enough that each slice can become a commit. -- Preserve the v0.1 and later-roadmap context while avoiding v0.3 drift. -- Do not add ChartContract, auto-correction, or extra chart intents in this slice. -- Use `schema_version: "0.2"` in serialization output as the CLI-facing contract anchor. -- Use verdict names for `--fail-on` so the future exit-code mapping stays aligned with `AuditReport.verdict`. -- Keep stdout text as the default surface unless `--out` switches it to a one-line verdict summary. -- Keep trap fixtures tiny and synthetic so they stay inspectable and easy to copy-paste. -- Keep the CI smoke check on a REVIEW fixture so it stays a passing gate while still exercising the CLI front door. -- Keep the release notes aligned with the actual CLI behavior so `0.2.0` stays a truthful cut. -- Mirror chart-level trend completeness checks in spec-audit code whenever line specs are treated as trend-like. -- When adding CI smoke checks, assert the exit code explicitly instead of relying on output alone. -- Keep docs wording version-neutral when the release version has already advanced. -- Keep public CLI flags like `--fail-on` documented in the agent guidance when they are exposed in `--help`. +- Keep the distribution slice claim-first and deterministic; avoid turning the package into a full visualization library. +- Keep v0.1 scope in `artifacts/SPEC.md` stable while using a separate preview section for new intents. +- Keep the existing trend/rank/compare API and CLI behavior unchanged. +- Keep the distribution audits focused on numeric metrics, sample size, grouping, bins, and violin density warnings. +- Keep example outputs synthetic and inspectable so they stay easy to review. ## Next Recommended Task -Use the roadmap and agent-integration docs as the source of truth for the next implementation slice. +Use the roadmap, SPEC, and audit-rules docs as the source of truth for any follow-up slice. diff --git a/artifacts/SPEC.md b/artifacts/SPEC.md index 8423b9a..e9c4873 100644 --- a/artifacts/SPEC.md +++ b/artifacts/SPEC.md @@ -20,6 +20,12 @@ Build `chart-contract`, a lightweight Python harness for claim-first, audited an - deterministic PASS/WARN/FAIL findings - docs, tests, examples, and build-proof artifacts +## v0.3 Preview + +- `Chart.histogram()`, `Chart.boxplot()`, `Chart.violin()` +- distribution-specific audit rules for numeric value fields, sample size, grouped categories, histogram bins, and violin density warnings +- `examples/distribution_charts.py` + ## Non-Goals - UI, dashboards, or Streamlit diff --git a/artifacts/VERIFY.md b/artifacts/VERIFY.md index 5daaeef..4e13222 100644 --- a/artifacts/VERIFY.md +++ b/artifacts/VERIFY.md @@ -1,5 +1,44 @@ # VERIFY +2026-07-03 - Add distribution chart intents + +Environment: +- Working directory: repo root + +Commands: +- `./.venv/bin/python -m pytest tests/test_distribution_intents.py -q` -> PASSED (`11 passed`) +- `./.venv/bin/python -m pytest tests/test_audit_spec.py -q` -> PASSED (`5 passed`) +- `./.venv/bin/python -m pytest tests/test_chart_intents.py -q` -> PASSED (`4 passed`) +- `./.venv/bin/python -m pytest -q` -> PASSED (`52 passed`) +- `./.venv/bin/python examples/distribution_charts.py` -> PASSED +- `./.venv/bin/python examples/bad_to_good_chart.py` -> PASSED +- `./.venv/bin/chart-contract --help` -> PASSED +- `git diff --check` -> PASSED + +Changed files: +- `src/chart_contract/chart.py` +- `src/chart_contract/renderers/altair.py` +- `src/chart_contract/audit.py` +- `tests/test_distribution_intents.py` +- `tests/test_audit_spec.py` +- `tests/test_chart_intents.py` +- `examples/distribution_charts.py` +- `examples/output/histogram_chart.vl.json` +- `examples/output/boxplot_chart.vl.json` +- `examples/output/violin_chart.vl.json` +- `README.md` +- `docs/AUDIT_RULES.md` +- `ROADMAP.md` +- `artifacts/SPEC.md` +- `artifacts/HANDOFF.md` + +Remaining risks: +- The example scripts rewrite tracked output artifacts, so the working tree will still show those diffs until they are reviewed or intentionally committed. +- The distribution preview is intentionally separate from the v0.1 scope, so any follow-up work should keep that boundary explicit. + +Next safest task: +- Review the generated output artifacts and decide whether to commit or keep iterating on the distribution preview. + 2026-06-22 - Document `--fail-on` in CLI guidance Environment: diff --git a/docs/AUDIT_RULES.md b/docs/AUDIT_RULES.md index f540b1f..f639fb3 100644 --- a/docs/AUDIT_RULES.md +++ b/docs/AUDIT_RULES.md @@ -21,6 +21,11 @@ Audits catch common analytical and visual-integrity failure modes. They do not p | `data.not_empty` | Chart audits | `PASS` when data has rows; `WARN` when the dataset is empty. | Checks that the chart has any observations to render. | Provide data with at least one row. | | `data.trend.min_points` | Trend chart audits | `PASS` when the trend has at least two rows; `FAIL` when it has fewer than two. | Ensures a directional trend has more than one observation. | Add historical data covering at least two time periods. | | `data.trend.x.ordered` | Trend chart audits | `PASS` when x is ordered or datetime-like; `WARN` otherwise. | Checks that the trend axis can be read as a progression. | Use a time field, numeric sequence, or ordered categorical series. | +| `data.distribution.value.numeric` | Distribution chart audits | `PASS` when the distribution metric exists and is numeric; `FAIL` when it is missing or non-numeric. | Ensures histogram, boxplot, and violin claims use a numeric measure. | Add or convert the metric column to numeric. | +| `data.distribution.sample_size` | Distribution chart audits | `PASS` when the chart has at least 20 rows; `WARN` for 5-19; `FAIL` below 5. | Checks whether the sample is large enough to summarize shape or spread. | Collect more observations before summarizing the distribution. | +| `data.distribution.group_sample_size` | Distribution chart audits with categories | `PASS` when each non-null group has at least 10 rows; `WARN` when any group is smaller. | Checks whether grouped distributions have enough rows per category. | Aggregate small groups or collect more data for each group. | +| `readability.histogram.bins` | Histogram audits | `PASS` when bins are default/custom-controlled or an integer between 5 and 50; `WARN` when an integer falls outside that range. | Checks whether the histogram bin count stays readable. | Use a bin count between 5 and 50 unless the claim needs a custom setting. | +| `visual.violin.sample_size` | Violin audits | `PASS` when the chart has at least 30 rows; `WARN` below 30. | Checks whether a violin plot has enough rows to justify the density view. | Use a boxplot or strip/point summary when the sample is small. | | `readability.rank.category_count` | Rank chart audits | `PASS` when categories are at or below the limit; `WARN` when there are too many. | Checks whether a rank chart stays readable. | Reduce categories or aggregate the long tail. | | `readability.color.category_count` | Chart audits with grouped color encodings | `PASS` when group count is at or below the limit; `WARN` when there are too many. | Checks whether color encoding remains readable. | Reduce groups or facet the comparison. | | `claim.causal_support` | Chart audits and spec audits | `PASS` when the claim is non-causal or justified; `WARN` when causal language lacks caveat/evidence. | Checks whether the claim overreaches causally. | Add a caveat or causal evidence metadata when justified. | diff --git a/examples/distribution_charts.py b/examples/distribution_charts.py new file mode 100644 index 0000000..b964aa3 --- /dev/null +++ b/examples/distribution_charts.py @@ -0,0 +1,102 @@ +"""Distribution chart demo: histogram, boxplot, and violin.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +from chart_contract import Chart + +OUTPUT_DIR = Path(__file__).resolve().parent / "output" + + +def _write_json(path: Path, payload: dict) -> None: + path.write_text(json.dumps(payload, indent=2) + "\n") + + +def _audit_and_write(label: str, chart: Chart, output_path: Path) -> None: + report = chart.audit() + print(f"{label}: {report.verdict_summary()}") + _write_json(output_path, chart.to_vega_lite()) + print(f"Wrote {output_path}") + + +def main() -> None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + df = pd.DataFrame( + { + "segment": ["SMB"] * 15 + ["Enterprise"] * 15, + "amount": [ + 4, + 5, + 6, + 7, + 5, + 4, + 6, + 7, + 8, + 6, + 5, + 7, + 6, + 4, + 5, + 12, + 13, + 15, + 14, + 13, + 12, + 16, + 14, + 15, + 13, + 12, + 14, + 15, + 13, + 12, + ], + } + ) + + histogram = Chart.histogram( + data=df, + value="amount", + claim="The amount values are spread across the observed range.", + source="synthetic.amounts", + unit="count", + title="Amount distribution", + bins=12, + ) + _audit_and_write("Histogram", histogram, OUTPUT_DIR / "histogram_chart.vl.json") + + boxplot = Chart.boxplot( + data=df, + x="segment", + y="amount", + claim="SMB and Enterprise have different amount summaries.", + source="synthetic.amounts", + unit="count", + title="Amount by segment", + ) + _audit_and_write("Boxplot", boxplot, OUTPUT_DIR / "boxplot_chart.vl.json") + + violin = Chart.violin( + data=df, + x="segment", + y="amount", + claim="SMB and Enterprise have different amount shapes.", + source="synthetic.amounts", + unit="count", + title="Amount density by segment", + ) + _audit_and_write("Violin", violin, OUTPUT_DIR / "violin_chart.vl.json") + + +if __name__ == "__main__": + main() diff --git a/examples/output/boxplot_chart.vl.json b/examples/output/boxplot_chart.vl.json new file mode 100644 index 0000000..d394d15 --- /dev/null +++ b/examples/output/boxplot_chart.vl.json @@ -0,0 +1,169 @@ +{ + "config": { + "view": { + "continuousWidth": 300, + "continuousHeight": 300 + } + }, + "data": { + "name": "data-3a1cad260c715f65215a6ae77c2d0135" + }, + "mark": { + "type": "boxplot" + }, + "encoding": { + "tooltip": [ + { + "field": "segment", + "type": "nominal" + }, + { + "field": "amount", + "type": "quantitative" + } + ], + "x": { + "field": "segment", + "title": "Segment", + "type": "nominal" + }, + "y": { + "field": "amount", + "title": "Amount (count)", + "type": "quantitative" + } + }, + "height": 360, + "title": { + "text": "Amount by segment", + "subtitle": [ + "Source: synthetic.amounts" + ] + }, + "width": 640, + "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", + "datasets": { + "data-3a1cad260c715f65215a6ae77c2d0135": [ + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 8 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 16 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + } + ] + } +} diff --git a/examples/output/compare_claim.vl.json b/examples/output/compare_claim.vl.json index 7d64226..4722751 100644 --- a/examples/output/compare_claim.vl.json +++ b/examples/output/compare_claim.vl.json @@ -6,7 +6,7 @@ } }, "data": { - "name": "data-eb06ab1a7eacc5baf1a04c35baca6b50" + "name": "data-afe65d1ce3efc3888320aa3197894a6c" }, "mark": { "type": "bar" @@ -57,9 +57,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", + "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", "datasets": { - "data-eb06ab1a7eacc5baf1a04c35baca6b50": [ + "data-afe65d1ce3efc3888320aa3197894a6c": [ { "segment": "SMB", "region": "East", diff --git a/examples/output/corrected_chart.vl.json b/examples/output/corrected_chart.vl.json index ed5ed11..d823a89 100644 --- a/examples/output/corrected_chart.vl.json +++ b/examples/output/corrected_chart.vl.json @@ -6,7 +6,7 @@ } }, "data": { - "name": "data-b111a15c207abcdf7297dcd503c0405a" + "name": "data-78ac950c7395f02241a48f28d1f28140" }, "mark": { "type": "bar" @@ -46,9 +46,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", + "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", "datasets": { - "data-b111a15c207abcdf7297dcd503c0405a": [ + "data-78ac950c7395f02241a48f28d1f28140": [ { "segment": "Enterprise", "conversion_rate": 0.18 diff --git a/examples/output/histogram_chart.vl.json b/examples/output/histogram_chart.vl.json new file mode 100644 index 0000000..a0bf694 --- /dev/null +++ b/examples/output/histogram_chart.vl.json @@ -0,0 +1,177 @@ +{ + "config": { + "view": { + "continuousWidth": 300, + "continuousHeight": 300 + } + }, + "data": { + "name": "data-3a1cad260c715f65215a6ae77c2d0135" + }, + "mark": { + "type": "bar" + }, + "encoding": { + "tooltip": [ + { + "bin": { + "maxbins": 12 + }, + "field": "amount", + "title": "Amount (count)", + "type": "quantitative" + }, + { + "aggregate": "count", + "title": "Count", + "type": "quantitative" + } + ], + "x": { + "bin": { + "maxbins": 12 + }, + "field": "amount", + "title": "Amount (count)", + "type": "quantitative" + }, + "y": { + "aggregate": "count", + "title": "Count", + "type": "quantitative" + } + }, + "height": 360, + "title": { + "text": "Amount distribution", + "subtitle": [ + "Source: synthetic.amounts" + ] + }, + "width": 640, + "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", + "datasets": { + "data-3a1cad260c715f65215a6ae77c2d0135": [ + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 8 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 16 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + } + ] + } +} diff --git a/examples/output/rank_claim.vl.json b/examples/output/rank_claim.vl.json index 44eab27..3ed0bd5 100644 --- a/examples/output/rank_claim.vl.json +++ b/examples/output/rank_claim.vl.json @@ -6,7 +6,7 @@ } }, "data": { - "name": "data-8c4e9aa994fcc77756e77dbfefc5a8a9" + "name": "data-2dbdc06c1874e60e0fa223517c655205" }, "mark": { "type": "bar" @@ -45,9 +45,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", + "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", "datasets": { - "data-8c4e9aa994fcc77756e77dbfefc5a8a9": [ + "data-2dbdc06c1874e60e0fa223517c655205": [ { "segment": "Free", "adoption": 120 diff --git a/examples/output/trend_claim.vl.json b/examples/output/trend_claim.vl.json index 117e561..b192c1d 100644 --- a/examples/output/trend_claim.vl.json +++ b/examples/output/trend_claim.vl.json @@ -8,7 +8,7 @@ "layer": [ { "data": { - "name": "data-89a6f5a0d51458e583a40d63981be75e" + "name": "data-f7ae7c7116813c8ce173f7a8e96e6a11" }, "mark": { "type": "line", @@ -39,7 +39,7 @@ }, { "data": { - "name": "data-cb09c0238c286cd711cb319ffbf98496" + "name": "data-30c96721cf7dfaa86848adb2762b6597" }, "mark": { "type": "rule", @@ -58,7 +58,7 @@ }, { "data": { - "name": "data-cb09c0238c286cd711cb319ffbf98496" + "name": "data-30c96721cf7dfaa86848adb2762b6597" }, "mark": { "type": "text", @@ -91,9 +91,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", + "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", "datasets": { - "data-89a6f5a0d51458e583a40d63981be75e": [ + "data-f7ae7c7116813c8ce173f7a8e96e6a11": [ { "week": "2026-05-01", "conversion_rate": 0.12 @@ -111,7 +111,7 @@ "conversion_rate": 0.16 } ], - "data-cb09c0238c286cd711cb319ffbf98496": [ + "data-30c96721cf7dfaa86848adb2762b6597": [ { "week": "2026-05-08", "label": "Onboarding launch" diff --git a/examples/output/violin_chart.vl.json b/examples/output/violin_chart.vl.json new file mode 100644 index 0000000..2c04971 --- /dev/null +++ b/examples/output/violin_chart.vl.json @@ -0,0 +1,193 @@ +{ + "config": { + "view": { + "continuousWidth": 300, + "continuousHeight": 300 + } + }, + "data": { + "name": "data-3a1cad260c715f65215a6ae77c2d0135" + }, + "mark": { + "type": "area", + "opacity": 0.6, + "orient": "horizontal" + }, + "encoding": { + "color": { + "field": "segment", + "title": "Segment", + "type": "nominal" + }, + "tooltip": [ + { + "field": "segment", + "type": "nominal" + }, + { + "field": "amount", + "type": "quantitative" + }, + { + "field": "density", + "title": "Density", + "type": "quantitative" + } + ], + "x": { + "field": "density", + "title": "Density", + "type": "quantitative" + }, + "y": { + "field": "value", + "title": "Amount (count)", + "type": "quantitative" + } + }, + "height": 360, + "title": { + "text": "Amount density by segment", + "subtitle": [ + "Source: synthetic.amounts" + ] + }, + "transform": [ + { + "density": "amount", + "groupby": [ + "segment" + ], + "as": [ + "value", + "density" + ] + } + ], + "width": 640, + "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", + "datasets": { + "data-3a1cad260c715f65215a6ae77c2d0135": [ + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 8 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "SMB", + "amount": 7 + }, + { + "segment": "SMB", + "amount": 6 + }, + { + "segment": "SMB", + "amount": 4 + }, + { + "segment": "SMB", + "amount": 5 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 16 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + }, + { + "segment": "Enterprise", + "amount": 14 + }, + { + "segment": "Enterprise", + "amount": 15 + }, + { + "segment": "Enterprise", + "amount": 13 + }, + { + "segment": "Enterprise", + "amount": 12 + } + ] + } +} diff --git a/src/chart_contract/audit.py b/src/chart_contract/audit.py index 686a41d..9e9e0a0 100644 --- a/src/chart_contract/audit.py +++ b/src/chart_contract/audit.py @@ -32,6 +32,7 @@ REVIEW = "REVIEW" BLOCK = "BLOCK" REPORT_SCHEMA_VERSION = "0.2" +DISTRIBUTION_INTENTS = {"histogram", "boxplot", "violin"} @dataclass(slots=True) @@ -148,6 +149,9 @@ def audit_chart(chart: Any) -> AuditReport: title = (chart.title or claim).strip() source = (chart.source or "").strip() caveat = (chart.caveat or "").strip() + distribution_intent = chart.intent in DISTRIBUTION_INTENTS + metric_field = _chart_metric_field(chart) + category_field = _distribution_category_field(chart) if claim: report.add("contract.claim.present", PASS, "Claim is declared.") @@ -171,36 +175,37 @@ def audit_chart(chart: Any) -> AuditReport: field="source", ) - if chart.y not in chart.data.columns: - report.add( - "data.y.column", - FAIL, - f"Required y column '{chart.y}' is missing from the data.", - field=chart.y, - ) - elif is_numeric_series(chart.data[chart.y]): - report.add("data.y.numeric", PASS, "Y field is quantitative.") - if chart.unit: - report.add("labels.unit.present", PASS, "Unit is declared for the quantitative metric.") + if distribution_intent: + _audit_distribution_value(report, chart, metric_field) + else: + if chart.y not in chart.data.columns: + report.add( + "data.y.column", + FAIL, + f"Required y column '{chart.y}' is missing from the data.", + field=chart.y, + ) + elif is_numeric_series(chart.data[chart.y]): + report.add("data.y.numeric", PASS, "Y field is quantitative.") + if chart.unit: + report.add("labels.unit.present", PASS, "Unit is declared for the quantitative metric.") + else: + report.add( + "labels.unit.present", + WARN, + "Unit is missing for a quantitative metric.", + suggestion="Add a unit such as percent, count, dollars, or rate.", + field="unit", + ) else: report.add( - "labels.unit.present", - WARN, - "Unit is missing for a quantitative metric.", - suggestion="Add a unit such as percent, count, dollars, or rate.", - field="unit", + "data.y.numeric", + FAIL, + f"Y field '{chart.y}' must be numeric for {chart.intent} charts.", + field=chart.y, ) - else: - report.add( - "data.y.numeric", - FAIL, - f"Y field '{chart.y}' must be numeric for {chart.intent} charts.", - field=chart.y, - ) - required_columns = [chart.x, chart.y] - if chart.group: - required_columns.append(chart.group) + required_columns = _required_fields_for_chart(chart) for column in required_columns: if column not in chart.data.columns: report.add( @@ -219,6 +224,13 @@ def audit_chart(chart: Any) -> AuditReport: else: report.add("data.not_empty", PASS, "Chart data is not empty.") + if distribution_intent: + _audit_distribution_sample_size(report, chart, category_field) + if chart.intent == "histogram": + _audit_histogram_bins(report, chart) + if chart.intent == "violin": + _audit_violin_sample_size(report, chart) + if chart.intent == "trend": row_count = len(chart.data) if row_count < 2: @@ -361,6 +373,7 @@ def audit_spec( evidence_flag = declared_evidence_from_spec(spec) resolved_claim = (claim or "").strip() resolved_data = _coerce_records(spec, data) + resolved_frame = pd.DataFrame(resolved_data) if resolved_data is not None else None if resolved_claim: report.add("contract.claim.present", PASS, "Claim is declared for the spec audit.") @@ -467,6 +480,17 @@ def audit_spec( elif category_count is not None: report.add("visual.arc.category_count", PASS, "Pie/arc category count is within the v0.1 limit.") + distribution_kind = _distribution_spec_kind(mark, spec, x_encoding, y_encoding, color_encoding) + if distribution_kind and resolved_frame is not None: + _audit_distribution_spec( + report, + distribution_kind, + resolved_frame, + x_encoding, + y_encoding, + color_encoding, + ) + color_count = _category_count(resolved_data, color_encoding) if color_count is not None and color_count > 8: report.add( @@ -542,3 +566,392 @@ def _category_count(records: list[dict[str, Any]] | None, encoding: Any) -> int return None values = {record.get(field) for record in records if field in record} return len(values) + + +def _distribution_spec_kind( + mark: str, + spec: Mapping[str, Any], + x_encoding: Any, + y_encoding: Any, + color_encoding: Any, +) -> str | None: + if mark == "bar" and _encoding_bin_config(x_encoding) is not None and _encoding_aggregate(y_encoding) == "count": + return "histogram" + if mark == "boxplot": + return "boxplot" + if mark == "area" and _contains_density_transform(spec): + return "violin" + return None + + +def _audit_distribution_spec( + report: AuditReport, + kind: str, + resolved_frame: pd.DataFrame, + x_encoding: Any, + y_encoding: Any, + color_encoding: Any, +) -> None: + metric_field = _distribution_spec_metric_field(kind, x_encoding, y_encoding) + if not metric_field: + report.add( + "data.distribution.value.numeric", + FAIL, + f"{kind.title()} specs require a metric field.", + suggestion="Add a numeric metric field to the distribution spec.", + ) + elif metric_field not in resolved_frame.columns: + report.add( + "data.distribution.value.numeric", + FAIL, + f"Distribution value field '{metric_field}' is missing from the data.", + suggestion="Add the metric column or change the chart to use an existing field.", + field=metric_field, + ) + elif is_numeric_series(resolved_frame[metric_field]): + report.add( + "data.distribution.value.numeric", + PASS, + "Distribution value field is numeric.", + ) + else: + report.add( + "data.distribution.value.numeric", + FAIL, + f"Distribution value field '{metric_field}' must be numeric for {kind} specs.", + field=metric_field, + ) + + row_count = len(resolved_frame) + if row_count < 5: + report.add( + "data.distribution.sample_size", + FAIL, + f"{kind.title()} spec has {row_count} observation(s); distribution shape and summary claims need enough observations.", + suggestion="Collect more observations before summarizing the distribution.", + ) + elif row_count < 20: + report.add( + "data.distribution.sample_size", + WARN, + f"{kind.title()} spec has {row_count} observation(s); distribution shape and summary claims need more data.", + suggestion="Use more observations before making shape or summary claims.", + ) + else: + report.add( + "data.distribution.sample_size", + PASS, + f"{kind.title()} spec has {row_count} observations.", + ) + + category_field = _distribution_spec_category_field(kind, x_encoding, color_encoding) + if category_field and category_field in resolved_frame.columns: + group_counts = resolved_frame[category_field].dropna().value_counts() + if not group_counts.empty: + if (group_counts < 10).any(): + smallest = int(group_counts.min()) + report.add( + "data.distribution.group_sample_size", + WARN, + f"Distribution groups in '{category_field}' include categories with fewer than 10 rows; the smallest group has {smallest}.", + suggestion="Aggregate small groups or collect more data for each group.", + field=category_field, + ) + else: + report.add( + "data.distribution.group_sample_size", + PASS, + f"All non-null groups in '{category_field}' have at least 10 rows.", + ) + + if kind == "histogram": + _audit_histogram_spec_bins(report, x_encoding) + if kind == "violin" and row_count < 30: + report.add( + "visual.violin.sample_size", + WARN, + f"Violin spec has {row_count} observation(s); boxplot or point summary is safer for small samples.", + suggestion="Use a boxplot or strip/point summary when the sample is small.", + ) + elif kind == "violin": + report.add( + "visual.violin.sample_size", + PASS, + f"Violin spec has {row_count} observations.", + ) + + +def _distribution_spec_metric_field(kind: str, x_encoding: Any, y_encoding: Any) -> str | None: + if kind == "histogram": + return _encoding_field(x_encoding) + if kind in {"boxplot", "violin"}: + return _encoding_field(y_encoding) + return None + + +def _distribution_spec_category_field(kind: str, x_encoding: Any, color_encoding: Any) -> str | None: + if kind == "histogram": + return _encoding_field(color_encoding) + if kind == "boxplot": + return _encoding_field(x_encoding) + if kind == "violin": + return _encoding_field(color_encoding) + return None + + +def _audit_histogram_spec_bins(report: AuditReport, x_encoding: Any) -> None: + bin_config = _encoding_bin_config(x_encoding) + if bin_config is None or bin_config is True: + report.add( + "readability.histogram.bins", + PASS, + "Histogram uses default binning.", + ) + return + if isinstance(bin_config, Mapping): + maxbins = bin_config.get("maxbins") + if isinstance(maxbins, int) and 5 <= maxbins <= 50: + report.add( + "readability.histogram.bins", + PASS, + f"Histogram uses {maxbins} bins, which is a readable range.", + ) + elif isinstance(maxbins, int): + report.add( + "readability.histogram.bins", + WARN, + f"Histogram uses {maxbins} bins, which may be too coarse or too fine for quick reading.", + suggestion="Use a bin count between 5 and 50 unless the claim needs a custom setting.", + field="encoding.x.bin.maxbins", + ) + else: + report.add( + "readability.histogram.bins", + PASS, + "Histogram binning is user-controlled.", + ) + return + report.add( + "readability.histogram.bins", + PASS, + "Histogram binning is user-controlled.", + ) + + +def _encoding_field(encoding: Any) -> str | None: + if isinstance(encoding, Mapping): + field = encoding.get("field") + if isinstance(field, str) and field: + return field + return None + + +def _encoding_bin_config(encoding: Any) -> Any: + if isinstance(encoding, Mapping): + return encoding.get("bin") + return None + + +def _encoding_aggregate(encoding: Any) -> str | None: + if isinstance(encoding, Mapping): + aggregate = encoding.get("aggregate") + if isinstance(aggregate, str) and aggregate: + return aggregate + return None + + +def _contains_density_transform(payload: Any) -> bool: + if isinstance(payload, Mapping): + if "density" in payload: + return True + return any(_contains_density_transform(value) for value in payload.values()) + if isinstance(payload, list): + return any(_contains_density_transform(item) for item in payload) + return False + + +def _chart_metric_field(chart: Any) -> str | None: + if chart.intent == "histogram": + return chart.value + return chart.y + + +def _distribution_category_field(chart: Any) -> str | None: + for field in (getattr(chart, "category", None), getattr(chart, "x", None), getattr(chart, "group", None)): + if isinstance(field, str) and field: + return field + return None + + +def _required_fields_for_chart(chart: Any) -> list[str]: + if chart.intent in DISTRIBUTION_INTENTS: + fields: list[str] = [] + metric_field = _chart_metric_field(chart) + if metric_field: + fields.append(metric_field) + category_field = _distribution_category_field(chart) + if category_field and category_field not in fields: + fields.append(category_field) + group_field = getattr(chart, "group", None) + if isinstance(group_field, str) and group_field and group_field not in fields and group_field != category_field: + fields.append(group_field) + return fields + + fields = [] + if getattr(chart, "x", None): + fields.append(chart.x) + if getattr(chart, "y", None): + fields.append(chart.y) + if getattr(chart, "group", None): + fields.append(chart.group) + return fields + + +def _audit_distribution_value(report: AuditReport, chart: Any, metric_field: str | None) -> None: + if not metric_field: + report.add( + "data.distribution.value.numeric", + FAIL, + f"{chart.intent.title()} charts require a value field.", + suggestion="Add a numeric metric column for the distribution view.", + ) + return + + if metric_field not in chart.data.columns: + report.add( + "data.distribution.value.numeric", + FAIL, + f"Distribution value field '{metric_field}' is missing from the data.", + suggestion="Add the metric column or change the chart to use an existing field.", + field=metric_field, + ) + return + + if is_numeric_series(chart.data[metric_field]): + report.add( + "data.distribution.value.numeric", + PASS, + "Distribution value field is numeric.", + ) + if chart.unit: + report.add("labels.unit.present", PASS, "Unit is declared for the distribution metric.") + else: + report.add( + "labels.unit.present", + WARN, + "Unit is missing for a distribution metric.", + suggestion="Add a unit such as percent, count, dollars, or rate.", + field="unit", + ) + return + + report.add( + "data.distribution.value.numeric", + FAIL, + f"Distribution value field '{metric_field}' must be numeric for {chart.intent} charts.", + field=metric_field, + ) + + +def _audit_distribution_sample_size(report: AuditReport, chart: Any, category_field: str | None) -> None: + row_count = len(chart.data) + if row_count < 5: + report.add( + "data.distribution.sample_size", + FAIL, + f"{chart.intent.title()} chart has {row_count} observation(s); distribution shape and summary claims need enough observations.", + suggestion="Collect more observations before summarizing the distribution.", + ) + elif row_count < 20: + report.add( + "data.distribution.sample_size", + WARN, + f"{chart.intent.title()} chart has {row_count} observation(s); distribution shape and summary claims need more data.", + suggestion="Use more observations before making shape or summary claims.", + ) + else: + report.add( + "data.distribution.sample_size", + PASS, + f"{chart.intent.title()} chart has {row_count} observations.", + ) + + if not category_field or category_field not in chart.data.columns: + return + + group_counts = chart.data[category_field].dropna().value_counts() + if group_counts.empty: + return + + if (group_counts < 10).any(): + smallest = int(group_counts.min()) + report.add( + "data.distribution.group_sample_size", + WARN, + f"Distribution groups in '{category_field}' include categories with fewer than 10 rows; the smallest group has {smallest}.", + suggestion="Aggregate small groups or collect more data for each group.", + field=category_field, + ) + else: + report.add( + "data.distribution.group_sample_size", + PASS, + f"All non-null groups in '{category_field}' have at least 10 rows.", + ) + + +def _audit_histogram_bins(report: AuditReport, chart: Any) -> None: + bins = chart.bins + if bins is None: + report.add( + "readability.histogram.bins", + PASS, + "Histogram uses default binning.", + ) + return + if isinstance(bins, str): + report.add( + "readability.histogram.bins", + PASS, + "Histogram binning is user-controlled.", + ) + return + if isinstance(bins, int) and 5 <= bins <= 50: + report.add( + "readability.histogram.bins", + PASS, + f"Histogram uses {bins} bins, which is a readable range.", + ) + return + if isinstance(bins, int): + report.add( + "readability.histogram.bins", + WARN, + f"Histogram uses {bins} bins, which may be too coarse or too fine for quick reading.", + suggestion="Use a bin count between 5 and 50 unless the claim needs a custom setting.", + field="bins", + ) + return + + report.add( + "readability.histogram.bins", + PASS, + "Histogram binning is user-controlled.", + ) + + +def _audit_violin_sample_size(report: AuditReport, chart: Any) -> None: + row_count = len(chart.data) + if row_count < 30: + report.add( + "visual.violin.sample_size", + WARN, + f"Violin chart has {row_count} observation(s); boxplot or point summary is safer for small samples.", + suggestion="Use a boxplot or strip/point summary when the sample is small.", + ) + else: + report.add( + "visual.violin.sample_size", + PASS, + f"Violin chart has {row_count} observations.", + ) diff --git a/src/chart_contract/chart.py b/src/chart_contract/chart.py index 40d840c..2956b72 100644 --- a/src/chart_contract/chart.py +++ b/src/chart_contract/chart.py @@ -15,8 +15,10 @@ class Chart: intent: str data: pd.DataFrame - x: str - y: str + x: str | None = None + y: str | None = None + value: str | None = None + bins: int | str | None = None category: str | None = None group: str | None = None claim: str = "" @@ -125,6 +127,101 @@ def compare( metadata=metadata, ) + @classmethod + def histogram( + cls, + *, + data: pd.DataFrame, + value: str, + claim: str, + source: str | None = None, + unit: str | None = None, + title: str | None = None, + bins: int | str | None = None, + group: str | None = None, + caveat: str | None = None, + filters: Mapping[str, Any] | str | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> "Chart": + return cls( + intent="histogram", + data=data, + value=value, + bins=bins, + group=group, + claim=claim, + source=source, + unit=unit, + title=title, + caveat=caveat, + filters=filters, + metadata=metadata, + ) + + @classmethod + def boxplot( + cls, + *, + data: pd.DataFrame, + x: str | None = None, + y: str, + claim: str, + source: str | None = None, + unit: str | None = None, + title: str | None = None, + group: str | None = None, + caveat: str | None = None, + filters: Mapping[str, Any] | str | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> "Chart": + return cls( + intent="boxplot", + data=data, + x=x, + y=y, + category=x or group, + group=group, + claim=claim, + source=source, + unit=unit, + title=title, + caveat=caveat, + filters=filters, + metadata=metadata, + ) + + @classmethod + def violin( + cls, + *, + data: pd.DataFrame, + x: str | None = None, + y: str, + claim: str, + source: str | None = None, + unit: str | None = None, + title: str | None = None, + group: str | None = None, + caveat: str | None = None, + filters: Mapping[str, Any] | str | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> "Chart": + return cls( + intent="violin", + data=data, + x=x, + y=y, + category=x or group, + group=group, + claim=claim, + source=source, + unit=unit, + title=title, + caveat=caveat, + filters=filters, + metadata=metadata, + ) + def audit(self) -> AuditReport: return audit_chart(self) diff --git a/src/chart_contract/renderers/altair.py b/src/chart_contract/renderers/altair.py index a71d159..6175121 100644 --- a/src/chart_contract/renderers/altair.py +++ b/src/chart_contract/renderers/altair.py @@ -35,6 +35,12 @@ def render_chart(chart: Any) -> alt.Chart: rendered = _render_rank(chart, records) elif chart.intent == "compare": rendered = _render_compare(chart, records) + elif chart.intent == "histogram": + rendered = _render_histogram(chart, records) + elif chart.intent == "boxplot": + rendered = _render_boxplot(chart, records) + elif chart.intent == "violin": + rendered = _render_violin(chart, records) else: raise ValueError(f"Unsupported chart intent: {chart.intent}") @@ -107,9 +113,103 @@ def _render_compare(chart: Any, records: list[dict[str, Any]]) -> alt.Chart: return alt.Chart(alt.InlineData(values=records)).mark_bar().encode(**encoding) +def _render_histogram(chart: Any, records: list[dict[str, Any]]) -> alt.Chart: + value_field = chart.value or chart.x or chart.y + if not value_field: + raise ValueError("Histogram charts require a value field.") + + bin_config: Any = True + if isinstance(chart.bins, int): + bin_config = alt.Bin(maxbins=chart.bins) + + encoding: dict[str, Any] = { + "x": alt.X(f"{value_field}:Q", bin=bin_config, title=_metric_title(value_field, chart.unit)), + "y": alt.Y("count():Q", title="Count"), + "tooltip": [ + alt.Tooltip(f"{value_field}:Q", bin=bin_config, title=_metric_title(value_field, chart.unit)), + alt.Tooltip("count():Q", title="Count"), + ], + } + if chart.group: + encoding["color"] = alt.Color(f"{chart.group}:N", title=chart.group.replace("_", " ").title()) + encoding["tooltip"] = [ + alt.Tooltip(f"{value_field}:Q", bin=bin_config, title=_metric_title(value_field, chart.unit)), + alt.Tooltip(field=chart.group, type="nominal"), + alt.Tooltip("count():Q", title="Count"), + ] + return alt.Chart(alt.InlineData(values=records)).mark_bar().encode(**encoding) + + +def _render_boxplot(chart: Any, records: list[dict[str, Any]]) -> alt.Chart: + if not chart.y: + raise ValueError("Boxplot charts require a y field.") + + category_field = chart.category or chart.x or chart.group + group_field = chart.group if chart.group and chart.group != category_field else None + working_records = records + if category_field is None: + category_field = "_distribution" + working_records = _add_constant_field(working_records, category_field, "All observations") + + encoding: dict[str, Any] = { + "x": alt.X(f"{category_field}:N", title=category_field.replace("_", " ").title()), + "y": alt.Y(f"{chart.y}:Q", title=_metric_title(chart.y, chart.unit)), + "tooltip": [ + alt.Tooltip(field=category_field, type="nominal"), + alt.Tooltip(field=chart.y, type="quantitative"), + ], + } + if group_field: + encoding["color"] = alt.Color(f"{group_field}:N", title=group_field.replace("_", " ").title()) + encoding["tooltip"] = [ + alt.Tooltip(field=category_field, type="nominal"), + alt.Tooltip(field=group_field, type="nominal"), + alt.Tooltip(field=chart.y, type="quantitative"), + ] + return alt.Chart(alt.InlineData(values=working_records)).mark_boxplot().encode(**encoding) + + +def _render_violin(chart: Any, records: list[dict[str, Any]]) -> alt.Chart: + if not chart.y: + raise ValueError("Violin charts require a y field.") + + category_field = chart.category or chart.x or chart.group + working_records = records + if category_field is None: + category_field = "_distribution" + working_records = _add_constant_field(working_records, category_field, "All observations") + + density_groupby = [category_field] + + base = alt.Chart(alt.InlineData(values=working_records)).transform_density( + chart.y, + as_=["value", "density"], + groupby=density_groupby, + ) + violin = base.mark_area(orient="horizontal", opacity=0.6).encode( + x=alt.X("density:Q", title="Density"), + y=alt.Y("value:Q", title=_metric_title(chart.y, chart.unit)), + color=alt.Color(f"{category_field}:N", title=category_field.replace("_", " ").title()), + tooltip=[ + alt.Tooltip(field=category_field, type="nominal"), + alt.Tooltip(field=chart.y, type="quantitative"), + alt.Tooltip("density:Q", title="Density"), + ], + ) + return violin + + +def _metric_title(field_name: str | None, unit: str | None) -> str: + base = (field_name or "").replace("_", " ").title() + return f"{base} ({unit})" if unit else base + + def _y_title(chart: Any) -> str: - base = chart.y.replace("_", " ").title() - return f"{base} ({chart.unit})" if chart.unit else base + return _metric_title(chart.y, chart.unit) + + +def _add_constant_field(records: list[dict[str, Any]], field: str, value: Any) -> list[dict[str, Any]]: + return [{**record, field: value} for record in records] def _prepare_records(data: pd.DataFrame) -> list[dict[str, Any]]: diff --git a/tests/test_audit_spec.py b/tests/test_audit_spec.py index 5fd113b..23fa9a3 100644 --- a/tests/test_audit_spec.py +++ b/tests/test_audit_spec.py @@ -3,7 +3,7 @@ import pandas as pd -from chart_contract import audit_spec +from chart_contract import Chart, audit_spec TRAPS = Path(__file__).resolve().parent.parent / "examples" / "traps" @@ -78,3 +78,38 @@ def test_two_point_line_trend_passes_min_points() -> None: severities = {finding.rule_id: finding.severity for finding in report.findings} assert severities["data.trend.min_points"] == "PASS" + + +def test_histogram_spec_audit_detects_distribution_rules() -> None: + df = pd.DataFrame( + { + "amount": list(range(20)), + "segment": ["A"] * 10 + ["B"] * 10, + } + ) + + spec = Chart.histogram( + data=df, + value="amount", + claim="The amount distribution is spread across the observed range.", + source="synthetic.amounts", + unit="count", + title="Amount distribution by segment", + bins=12, + group="segment", + ).to_vega_lite() + + report = audit_spec( + spec=spec, + data=df, + claim="The amount distribution is spread across the observed range.", + ) + + severities = {finding.rule_id: finding.severity for finding in report.findings} + assert report.verdict == "REVIEW" + assert severities["contract.source.present"] == "WARN" + assert severities["labels.unit.present"] == "WARN" + assert severities["data.distribution.value.numeric"] == "PASS" + assert severities["data.distribution.sample_size"] == "PASS" + assert severities["data.distribution.group_sample_size"] == "PASS" + assert severities["readability.histogram.bins"] == "PASS" diff --git a/tests/test_chart_intents.py b/tests/test_chart_intents.py index a6d3649..606c839 100644 --- a/tests/test_chart_intents.py +++ b/tests/test_chart_intents.py @@ -78,6 +78,7 @@ def test_examples_execute_and_write_specs() -> None: repo_root = Path(__file__).resolve().parents[1] scripts = [ "bad_to_good_chart.py", + "distribution_charts.py", "trend_claim.py", "rank_claim.py", "compare_claim.py", @@ -88,6 +89,9 @@ def test_examples_execute_and_write_specs() -> None: output_dir = repo_root / "examples" / "output" assert (output_dir / "corrected_chart.vl.json").exists() + assert (output_dir / "histogram_chart.vl.json").exists() + assert (output_dir / "boxplot_chart.vl.json").exists() + assert (output_dir / "violin_chart.vl.json").exists() assert (output_dir / "trend_claim.vl.json").exists() assert (output_dir / "rank_claim.vl.json").exists() assert (output_dir / "compare_claim.vl.json").exists() diff --git a/tests/test_distribution_intents.py b/tests/test_distribution_intents.py new file mode 100644 index 0000000..58f2862 --- /dev/null +++ b/tests/test_distribution_intents.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from chart_contract import Chart + + +def test_histogram_chart_preserves_distribution_fields() -> None: + df = pd.DataFrame({"amount": [1, 2, 2, 3, 4, 5, 5, 5], "segment": ["A", "A", "B", "B", "B", "C", "C", "C"]}) + + chart = Chart.histogram( + data=df, + value="amount", + claim="The amounts are spread across the observed range.", + source="synthetic.amounts", + unit="count", + bins=12, + group="segment", + ) + spec = chart.to_vega_lite() + + assert chart.intent == "histogram" + assert chart.value == "amount" + assert chart.bins == 12 + assert spec["mark"]["type"] == "bar" + assert spec["encoding"]["x"]["field"] == "amount" + assert spec["encoding"]["x"]["bin"]["maxbins"] == 12 + assert spec["encoding"]["y"]["aggregate"] == "count" + + +def test_boxplot_chart_renders_boxplot_mark() -> None: + df = pd.DataFrame( + { + "segment": ["SMB", "SMB", "Enterprise", "Enterprise"], + "amount": [5, 7, 12, 14], + } + ) + + spec = Chart.boxplot( + data=df, + x="segment", + y="amount", + claim="Enterprise spends more than SMB.", + source="synthetic.amounts", + unit="count", + ).to_vega_lite() + + assert spec["mark"]["type"] == "boxplot" + assert spec["encoding"]["x"]["field"] == "segment" + assert spec["encoding"]["y"]["field"] == "amount" + + +def test_violin_chart_renders_density_area() -> None: + df = pd.DataFrame( + { + "segment": ["SMB", "SMB", "Enterprise", "Enterprise", "Enterprise", "SMB"], + "amount": [5, 7, 12, 14, 15, 6], + } + ) + + spec = Chart.violin( + data=df, + x="segment", + y="amount", + claim="The two segments have different shapes.", + source="synthetic.amounts", + unit="count", + ).to_vega_lite() + + assert spec["mark"]["type"] == "area" + assert spec["transform"][0]["density"] == "amount" + assert spec["encoding"]["color"]["field"] == "segment" + + +def test_histogram_audit_fails_when_value_column_is_missing() -> None: + df = pd.DataFrame({"segment": ["A", "B", "C"]}) + + report = Chart.histogram( + data=df, + value="amount", + claim="The amounts are spread across the observed range.", + source="synthetic.amounts", + unit="count", + ).audit() + + severities = {finding.rule_id: finding.severity for finding in report.findings} + assert severities["data.distribution.value.numeric"] == "FAIL" + assert report.verdict == "BLOCK" + + +def test_distribution_audit_fails_when_value_column_is_not_numeric() -> None: + df = pd.DataFrame({"segment": ["A", "B", "C"], "amount": ["low", "mid", "high"]}) + + report = Chart.boxplot( + data=df, + x="segment", + y="amount", + claim="The segments differ in amount.", + source="synthetic.amounts", + unit="count", + ).audit() + + severities = {finding.rule_id: finding.severity for finding in report.findings} + assert severities["data.distribution.value.numeric"] == "FAIL" + assert report.verdict == "BLOCK" + + +@pytest.mark.parametrize( + ("row_count", "expected_severity"), + [ + (4, "FAIL"), + (10, "WARN"), + (20, "PASS"), + ], +) +def test_distribution_sample_size_thresholds(row_count: int, expected_severity: str) -> None: + df = pd.DataFrame({"amount": list(range(row_count))}) + + report = Chart.histogram( + data=df, + value="amount", + claim="The amounts are spread across the observed range.", + source="synthetic.amounts", + unit="count", + ).audit() + + severities = {finding.rule_id: finding.severity for finding in report.findings} + assert severities["data.distribution.sample_size"] == expected_severity + + +def test_histogram_bins_outside_readable_range_warn() -> None: + df = pd.DataFrame({"amount": list(range(50))}) + + report = Chart.histogram( + data=df, + value="amount", + claim="The amounts are spread across the observed range.", + source="synthetic.amounts", + unit="count", + bins=3, + ).audit() + + severities = {finding.rule_id: finding.severity for finding in report.findings} + assert severities["readability.histogram.bins"] == "WARN" + + +def test_violin_low_sample_size_warns() -> None: + df = pd.DataFrame({"segment": ["A"] * 12, "amount": list(range(12))}) + + report = Chart.violin( + data=df, + x="segment", + y="amount", + claim="The segment distribution is shaped this way.", + source="synthetic.amounts", + unit="count", + ).audit() + + severities = {finding.rule_id: finding.severity for finding in report.findings} + assert severities["visual.violin.sample_size"] == "WARN" + + +def test_distribution_group_sample_size_warns_for_small_groups() -> None: + df = pd.DataFrame( + { + "segment": ["A"] * 4 + ["B"] * 12, + "amount": list(range(16)), + } + ) + + report = Chart.boxplot( + data=df, + x="segment", + y="amount", + claim="The segments differ in amount.", + source="synthetic.amounts", + unit="count", + ).audit() + + severities = {finding.rule_id: finding.severity for finding in report.findings} + assert severities["data.distribution.group_sample_size"] == "WARN" From cc13289fba4a1750d7a45cc14fb9304994e7efb4 Mon Sep 17 00:00:00 2001 From: tmusser Date: Fri, 3 Jul 2026 18:10:48 -0400 Subject: [PATCH 2/2] fix: polish violin tooltip and example outputs Reference the generated value field in the violin tooltip and revert unrelated example-output churn back to the repo baseline. --- examples/output/compare_claim.vl.json | 6 +++--- examples/output/corrected_chart.vl.json | 6 +++--- examples/output/rank_claim.vl.json | 6 +++--- examples/output/trend_claim.vl.json | 12 ++++++------ examples/output/violin_chart.vl.json | 3 ++- src/chart_contract/renderers/altair.py | 2 +- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/examples/output/compare_claim.vl.json b/examples/output/compare_claim.vl.json index 4722751..7d64226 100644 --- a/examples/output/compare_claim.vl.json +++ b/examples/output/compare_claim.vl.json @@ -6,7 +6,7 @@ } }, "data": { - "name": "data-afe65d1ce3efc3888320aa3197894a6c" + "name": "data-eb06ab1a7eacc5baf1a04c35baca6b50" }, "mark": { "type": "bar" @@ -57,9 +57,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", + "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", "datasets": { - "data-afe65d1ce3efc3888320aa3197894a6c": [ + "data-eb06ab1a7eacc5baf1a04c35baca6b50": [ { "segment": "SMB", "region": "East", diff --git a/examples/output/corrected_chart.vl.json b/examples/output/corrected_chart.vl.json index d823a89..ed5ed11 100644 --- a/examples/output/corrected_chart.vl.json +++ b/examples/output/corrected_chart.vl.json @@ -6,7 +6,7 @@ } }, "data": { - "name": "data-78ac950c7395f02241a48f28d1f28140" + "name": "data-b111a15c207abcdf7297dcd503c0405a" }, "mark": { "type": "bar" @@ -46,9 +46,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", + "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", "datasets": { - "data-78ac950c7395f02241a48f28d1f28140": [ + "data-b111a15c207abcdf7297dcd503c0405a": [ { "segment": "Enterprise", "conversion_rate": 0.18 diff --git a/examples/output/rank_claim.vl.json b/examples/output/rank_claim.vl.json index 3ed0bd5..44eab27 100644 --- a/examples/output/rank_claim.vl.json +++ b/examples/output/rank_claim.vl.json @@ -6,7 +6,7 @@ } }, "data": { - "name": "data-2dbdc06c1874e60e0fa223517c655205" + "name": "data-8c4e9aa994fcc77756e77dbfefc5a8a9" }, "mark": { "type": "bar" @@ -45,9 +45,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", + "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", "datasets": { - "data-2dbdc06c1874e60e0fa223517c655205": [ + "data-8c4e9aa994fcc77756e77dbfefc5a8a9": [ { "segment": "Free", "adoption": 120 diff --git a/examples/output/trend_claim.vl.json b/examples/output/trend_claim.vl.json index b192c1d..117e561 100644 --- a/examples/output/trend_claim.vl.json +++ b/examples/output/trend_claim.vl.json @@ -8,7 +8,7 @@ "layer": [ { "data": { - "name": "data-f7ae7c7116813c8ce173f7a8e96e6a11" + "name": "data-89a6f5a0d51458e583a40d63981be75e" }, "mark": { "type": "line", @@ -39,7 +39,7 @@ }, { "data": { - "name": "data-30c96721cf7dfaa86848adb2762b6597" + "name": "data-cb09c0238c286cd711cb319ffbf98496" }, "mark": { "type": "rule", @@ -58,7 +58,7 @@ }, { "data": { - "name": "data-30c96721cf7dfaa86848adb2762b6597" + "name": "data-cb09c0238c286cd711cb319ffbf98496" }, "mark": { "type": "text", @@ -91,9 +91,9 @@ ] }, "width": 640, - "$schema": "https://vega.github.io/schema/vega-lite/v6.4.1.json", + "$schema": "https://vega.github.io/schema/vega-lite/v5.8.0.json", "datasets": { - "data-f7ae7c7116813c8ce173f7a8e96e6a11": [ + "data-89a6f5a0d51458e583a40d63981be75e": [ { "week": "2026-05-01", "conversion_rate": 0.12 @@ -111,7 +111,7 @@ "conversion_rate": 0.16 } ], - "data-30c96721cf7dfaa86848adb2762b6597": [ + "data-cb09c0238c286cd711cb319ffbf98496": [ { "week": "2026-05-08", "label": "Onboarding launch" diff --git a/examples/output/violin_chart.vl.json b/examples/output/violin_chart.vl.json index 2c04971..77ee242 100644 --- a/examples/output/violin_chart.vl.json +++ b/examples/output/violin_chart.vl.json @@ -25,7 +25,8 @@ "type": "nominal" }, { - "field": "amount", + "field": "value", + "title": "Amount (count)", "type": "quantitative" }, { diff --git a/src/chart_contract/renderers/altair.py b/src/chart_contract/renderers/altair.py index 6175121..9cf43ee 100644 --- a/src/chart_contract/renderers/altair.py +++ b/src/chart_contract/renderers/altair.py @@ -192,7 +192,7 @@ def _render_violin(chart: Any, records: list[dict[str, Any]]) -> alt.Chart: color=alt.Color(f"{category_field}:N", title=category_field.replace("_", " ").title()), tooltip=[ alt.Tooltip(field=category_field, type="nominal"), - alt.Tooltip(field=chart.y, type="quantitative"), + alt.Tooltip(field="value", type="quantitative", title=_metric_title(chart.y, chart.unit)), alt.Tooltip("density:Q", title="Density"), ], )