https://github.com/clement-lee/ggspec
Extract and Compare ggplot2 Plot Specifications as Tidy Data Frames
https://github.com/clement-lee/ggspec
Last synced: about 1 month ago
JSON representation
Extract and Compare ggplot2 Plot Specifications as Tidy Data Frames
- Host: GitHub
- URL: https://github.com/clement-lee/ggspec
- Owner: clement-lee
- License: other
- Created: 2026-04-14T15:41:57.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-05-21T10:14:30.000Z (about 2 months ago)
- Last Synced: 2026-05-28T19:39:32.258Z (about 1 month ago)
- Language: R
- Size: 239 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.Rmd
- License: LICENSE
Awesome Lists containing this project
- awesome-ggplot2 - ggspec
README
---
output: github_document
---
```{r setup, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>",
fig.path = "man/figures/README-",
out.width = "100%"
)
```
# ggspec
[](https://lifecycle.r-lib.org/articles/stages.html#experimental)
[](https://github.com/clement-lee/ggspec/actions/workflows/R-CMD-check.yaml)
[](https://app.codecov.io/gh/clement-lee/ggspec)
[](https://CRAN.R-project.org/package=ggspec)
`ggspec` extracts the full declarative specification of a `ggplot2` object —
layers, aesthetic mappings, scales, facets, coordinate system, and labels — as
tidy data frames. A second tier of functions enables structural comparison of
two ggplot objects, supporting automated plot testing, auditing, and
framework-agnostic grading workflows.
## Motivation
Different large-language models and AI coding assistants generate syntactically
different code for the same visualisation task. One AI might write
`geom_bar(aes(x = species))` on raw data; another might write
`count(species) |> ... geom_col(aes(x = species, y = n))`. Both produce the
same chart, but naive string or AST comparison would flag them as different.
`ggspec` provides a principled hierarchy of equivalence checks — from strict
spec equality through structural canonicalisation to rendered-output comparison
— so that equivalent plots are recognised as equivalent regardless of which
syntactic path an AI (or human student) took to produce them.
## Installation
Install the development version from GitHub:
```r
# install.packages("remotes")
remotes::install_github("clement-lee/ggspec")
```
## Usage
### Extracting a spec
```{r usage-spec}
library(ggspec)
library(ggplot2)
p <- ggplot(mpg, aes(displ, hwy)) +
geom_point(aes(colour = class)) +
geom_smooth(method = "lm", se = FALSE) +
facet_wrap(~drv) +
labs(title = "Engine displacement vs highway MPG")
spec_layers(p)
spec_aes(p)
```
### Comparing two plots
```{r usage-equiv}
ref <- ggplot(mpg, aes(displ, hwy)) +
geom_point(aes(colour = class)) +
facet_wrap(~drv)
obs_correct <- ggplot(mpg, aes(displ, hwy)) +
geom_point(aes(colour = class)) +
facet_wrap(~drv)
obs_wrong <- ggplot(mpg, aes(displ, hwy)) +
geom_smooth() +
facet_wrap(~cyl)
equiv_plot(ref, obs_correct)
equiv_plot(ref, obs_wrong)
```
## Comparison modes
`ggspec` recognises four levels of plot equivalence or similarity, ordered from
most to least restrictive.
### Strict equivalence
Two plots are **strictly equivalent** when their specifications are identical
with no canonicalisation applied: same layer order, same data/mapping
placement, same geom names, same random seed for stochastic elements.
```{r mode-strict, eval=FALSE}
compare_plots(p1, p2, mode = "strict")
```
### Structural equivalence
Two plots are **structurally equivalent** when their specifications are
identical after canonicalisation via `canon()`. The canonical form is
computed by a term rewriting system (TRS) that applies a fixed set of
confluent rewrite rules:
- **fold\_global**: resolves the ambiguity of placing data/mapping at the
global level vs per-layer — both forms normalise to the same spec.
- **geom\_col\_to\_bar**: `geom_col()` is a shorthand for
`geom_bar(stat = "identity")`; the canonical form always uses the latter.
- **layer\_order**: non-spanning geoms are sorted alphabetically, so layer
order does not affect structural equivalence.
```{r mode-structural, eval=FALSE}
compare_plots(p1, p2, mode = "structural") # default
```
### Visual equivalence
Two plots are **visually equivalent** when they produce identical rendered
output. This pathway uses `ggplot_build()` to evaluate plots semantically
rather than comparing their specs, so it can detect equivalences that
structural comparison cannot:
- `geom_bar()` on raw data vs `geom_col()` on pre-counted data (same bars,
different specs).
- `coord_flip()` vs swapped aesthetics (same visual output, different
coordinate systems).
- `scale_fill_*(name = "v")` vs `labs(fill = "v")` (same legend label,
different spec location).
```{r mode-visual, eval=FALSE}
compare_plots(p1, p2, mode = "visual")
```
**Design constraint**: visual equivalence calls `ggplot_build()` on both plots.
This means (a) both plots must be buildable with their data accessible in the
session, (b) it is slower than structural comparison, and (c) it is
output-based — it does not verify that the plots were derived from the same
source data. Two plots backed by different datasets that happen to produce the
same rendered output will pass visual equivalence. Use structural mode when
data provenance must be verified.
### Conceptual similarity
Two plots are **conceptually similar** when they communicate the same
information using potentially different visual encodings. Unlike the
equivalence modes above, conceptual similarity is not a strict mathematical
equivalence relation; each claim is qualified by a WHEN condition:
| Claim | WHEN |
|---|---|
| boxplot, violin, jitter all similar| 1 continuous + 1 discrete variable |
| density, histogram, freqpoly, dotplot all similar | 1 continuous variable |
| `geom_count`, `geom_point(aes(size = n))` similar | 2 discrete variables, joint counts |
```{r mode-conceptual, eval=FALSE}
compare_plots(p1, p2, mode = "conceptual")
```
### Enriching a spec with build-derived defaults
`enrich_spec()` uses `ggplot_build()` to identify which parameters and
aesthetics were explicitly set by the user versus filled in by ggplot2:
```{r usage-enrich}
es <- enrich_spec(p)
# Non-aesthetic parameters with explicit flag
es$params_tbl[[1]]
# Aesthetics resolved by ggplot2, with explicit flag
es$built_aes[[1]]
```
## Key functions
| Tier | Function | What it returns |
|---|---|---|
| Extraction | `spec_layers()` | One row per layer |
| Extraction | `spec_aes()` | One row per layer × aesthetic |
| Extraction | `spec_scales()` | One row per scale |
| Extraction | `spec_facets()` | Facet type and variables |
| Extraction | `spec_labels()` | One row per label |
| Extraction | `spec_coord()` | Coordinate system |
| Extraction | `enrich_spec()` | spec_layers + default/explicit flags |
| Comparison | `equiv_plot()` | All checks in one call (strict) |
| Comparison | `equiv_layers()` | Geom and stat per layer |
| Comparison | `equiv_aes()` | Aesthetic mappings |
| Comparison | `compare_plots()` | Four-mode comparison entry point |
| Comparison | `compare_visual()` | Visual equivalence via `ggplot_build()` |
| Comparison | `compare_conceptual()` | Conceptual similarity detectors |
| Comparison | `equiv_rendered()` | Rendered layer data comparison |
| Check | `check_plot()` | Framework-agnostic assertion |
| Check | `expect_equiv_plot()` | testthat expectation |
## Related packages
- **[ggcheck](https://github.com/rstudio/ggcheck)** — designed for
`learnr`/`gradethis` pipelines; returns ad-hoc objects.
`ggspec` returns rectangular, pipeable tibbles and has no grading framework
dependency.
## License
MIT