ggspec provides a comparison tier (equiv_*()) and a check/assertion tier (check_plot(), expect_equiv_plot()) for comparing two ggplot objects. These are designed to be framework-agnostic: they work in plain R scripts, testthat test suites, and learnr/gradethis grading pipelines.
Checking visual equivalence is particularly important in the age of AI-assisted coding: different large-language models generate syntactically different code for the same visualisation task (geom_bar() on raw data vs geom_col() on pre-counted data; labs(x = ...) vs scale_x_continuous(name = ...)). ggspec provides a four-level hierarchy of equivalence checks so that functionally identical plots are recognised as equivalent regardless of how they were written.
library(ggspec)
library(ggplot2)equiv_plot()equiv_plot() is the high-level entry point. It accepts two ggplot objects and a character vector of check names to run. It returns a ggspec_result object that holds a pass/fail flag, a human-readable message, and a structured diff.
ref <- ggplot(mpg, aes(displ, hwy)) +
geom_point(aes(colour = class)) +
facet_wrap(~drv) +
labs(title = "Reference plot")
obs_correct <- ggplot(mpg, aes(displ, hwy)) +
geom_point(aes(colour = class)) +
facet_wrap(~drv) +
labs(title = "Reference plot")
obs_wrong <- ggplot(mpg, aes(displ, hwy)) +
geom_smooth() + # wrong geom
facet_wrap(~cyl) + # wrong facet variable
labs(title = "Student plot")# Passing case
result_ok <- equiv_plot(ref, obs_correct)
result_ok
#> [PASS mode=strict] 6/6 checks passed
#> Detail:
#> # A tibble: 10 × 12
#> check source layer geom stat position aesthetic variable status label_ref
#> <chr> <chr> <int> <chr> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 layers ref 0 <NA> <NA> <NA> <NA> <NA> <NA> <NA>
#> 2 layers ref 1 point ident… identity <NA> <NA> <NA> <NA>
#> 3 layers obs 0 <NA> <NA> <NA> <NA> <NA> <NA> <NA>
#> 4 layers obs 1 point ident… identity <NA> <NA> <NA> <NA>
#> 5 aes global 0 <NA> <NA> <NA> x displ match <NA>
#> 6 aes global 0 <NA> <NA> <NA> y hwy match <NA>
#> 7 aes global 1 point <NA> <NA> x displ match <NA>
#> 8 aes global 1 point <NA> <NA> y hwy match <NA>
#> 9 aes local 1 point <NA> <NA> colour class match <NA>
#> 10 labels <NA> NA <NA> <NA> <NA> title <NA> <NA> Referenc…
#> # ℹ 2 more variables: label_obs <chr>, match <lgl>
as.logical(result_ok)
#> [1] TRUE# Failing case
result_fail <- equiv_plot(ref, obs_wrong)
result_fail
#> [FAIL mode=strict] 2/6 checks passed: Missing geom(s): point.; Aesthetic mapping issue(s): colour->class (layer 1).; Facet mismatch: cols: 'drv' vs 'cyl'; wrong label(s): 'title' (expected 'Reference plot', got 'Student plot')
#> Detail:
#> # A tibble: 10 × 12
#> check source layer geom stat position aesthetic variable status label_ref
#> <chr> <chr> <int> <chr> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 layers ref 0 <NA> <NA> <NA> <NA> <NA> <NA> <NA>
#> 2 layers ref 1 point iden… identity <NA> <NA> <NA> <NA>
#> 3 layers obs 0 <NA> <NA> <NA> <NA> <NA> <NA> <NA>
#> 4 layers obs 1 smooth smoo… identity <NA> <NA> <NA> <NA>
#> 5 aes local 1 point <NA> <NA> colour class missi… <NA>
#> 6 aes global 0 <NA> <NA> <NA> x displ match <NA>
#> 7 aes global 0 <NA> <NA> <NA> y hwy match <NA>
#> 8 aes global 1 point <NA> <NA> x displ match <NA>
#> 9 aes global 1 point <NA> <NA> y hwy match <NA>
#> 10 labels <NA> NA <NA> <NA> <NA> title <NA> <NA> Referenc…
#> # ℹ 2 more variables: label_obs <chr>, match <lgl>Each equiv_*() function tests one dimension:
equiv_layers(ref, obs_wrong)
#> [FAIL] Missing geom(s): point.
#> Hint: Add + geom_point() to the observed plot.
#> Detail:
#> # A tibble: 4 × 5
#> source layer geom stat position
#> <chr> <int> <chr> <chr> <chr>
#> 1 ref 0 <NA> <NA> <NA>
#> 2 ref 1 point identity identity
#> 3 obs 0 <NA> <NA> <NA>
#> 4 obs 1 smooth smooth identity
equiv_facets(ref, obs_wrong)
#> [FAIL] Facet mismatch: cols: 'drv' vs 'cyl'
equiv_labels(ref, obs_wrong, aesthetics = "title")
#> [FAIL] wrong label(s): 'title' (expected 'Reference plot', got 'Student plot')
#> Hint: Add labs(title = 'Reference plot') to the observed plot.
#> Detail:
#> # A tibble: 1 × 4
#> aesthetic label_ref label_obs match
#> <chr> <chr> <chr> <lgl>
#> 1 title Reference plot Student plot FALSEexact argumentBy default, equiv_layers() and equiv_aes() use subset matching: the observed plot must contain at least the layers/mappings of the reference. Set exact = TRUE to require an exact match.
obs_extra <- ref + geom_smooth() # extra layer is fine by default
equiv_layers(ref, obs_extra)
#> [PASS] All expected geoms present.
#> Detail:
#> # A tibble: 5 × 5
#> source layer geom stat position
#> <chr> <int> <chr> <chr> <chr>
#> 1 ref 0 <NA> <NA> <NA>
#> 2 ref 1 point identity identity
#> 3 obs 0 <NA> <NA> <NA>
#> 4 obs 1 point identity identity
#> 5 obs 2 smooth smooth identity
equiv_layers(ref, obs_extra, exact = TRUE) # fails: extra layer
#> [FAIL] Expected 1 layer(s) [point]; got 2 [point, smooth].
#> Detail:
#> # A tibble: 5 × 5
#> source layer geom stat position
#> <chr> <int> <chr> <chr> <chr>
#> 1 ref 0 <NA> <NA> <NA>
#> 2 ref 1 point identity identity
#> 3 obs 0 <NA> <NA> <NA>
#> 4 obs 1 point identity identity
#> 5 obs 2 smooth smooth identitycheck_plot()check_plot() wraps equiv_plot() and calls a fail_fn if the check fails. The default fail_fn = stop makes it work anywhere.
# Passes silently
check_plot(obs_correct, ref, check = c("layers", "aes", "facets"))
# Fails with an informative error
check_plot(obs_wrong, ref, check = c("layers", "facets"))
#> Error in check_plot(obs_wrong, ref, check = c("layers", "facets")): 0/2 checks passed: Missing geom(s): point.; Facet mismatch: cols: 'drv' vs 'cyl'In a learnr tutorial, swap the fail_fn and pass_fn arguments to use the grading framework’s own signalling functions (e.g. gradethis::fail / gradethis::pass):
# Inside a learnr grade_this() block:
check_plot(
.result,
expected = ref,
check = c("layers", "aes", "facets"),
fail_fn = your_grading_framework_fail_fn,
pass_fn = your_grading_framework_pass_fn
)No hard dependency on any grading framework is required — fail_fn and pass_fn can be any functions with compatible signatures.
expect_equiv_plot() in testthattestthat::test_that("student plot has correct layers and facets", {
expect_equiv_plot(
obs_correct,
ref,
check = c("layers", "aes", "facets")
)
})Every equiv_*() result carries a $detail data frame for programmatic inspection:
result <- equiv_aes(ref, obs_wrong)
result$detail
#> # A tibble: 5 × 6
#> layer geom aesthetic variable source status
#> <int> <chr> <chr> <chr> <chr> <chr>
#> 1 1 point colour class local missing
#> 2 0 <NA> x displ global match
#> 3 0 <NA> y hwy global match
#> 4 1 point x displ global match
#> 5 1 point y hwy global matchequiv_params() checks whether a specific layer’s non-aesthetic parameters match, e.g. checking that a student used se = FALSE on geom_smooth().
p_ref <- ggplot(mpg, aes(displ, hwy)) + geom_smooth(method = "lm", se = FALSE)
p_wrong <- ggplot(mpg, aes(displ, hwy)) + geom_smooth(method = "lm", se = TRUE)
equiv_params(p_ref, p_wrong, layer = 1L, params = "se")
#> [FAIL] Layer 1 parameter mismatch: se.compare_plots()equiv_plot() performs direct structural comparison. When two plots are semantically equivalent but written differently — different geoms for the same stat, reversed aesthetic axes, scale names vs labs() — use compare_plots(), which normalises both plots before comparing.
# "structural" — normalises geom_col → geom_bar, sorts layer order
compare_plots(p_ref, p_col, mode = "structural", check = "layers")
# "visual" — additionally absorbs coord_flip() and scale name → labs()
compare_plots(p_ref, p_flip, mode = "visual", check = c("layers", "aes", "coord"))The result is a ggspec_compare object extending ggspec_result, with extra fields $canon_p1, $canon_p2 (the canonicalised specs) and $mode.
check_plot()Pass mode to check_plot() to apply canonicalisation in grading pipelines:
# Passes for a student who used geom_col() instead of geom_bar()
check_plot(student_plot, ref,
check = "layers",
mode = "structural")
# In learnr (swap fail_fn/pass_fn for your grading framework):
check_plot(.result, ref,
check = c("layers", "aes", "coord"),
mode = "visual",
fail_fn = your_grading_fail_fn,
pass_fn = your_grading_pass_fn)| Mode | Normalisation rules applied |
|---|---|
"strict" |
None beyond what spec_plot() already does |
"structural" |
geom_col -> geom_bar; layer order sorted |
"visual" |
Structural + coord_flip absorbed; scale name -> labs() |
"pedagogical" |
Visual + histogram bins/binwidth flagged; after_stat() logged |
The $changes tibble on a ggspec_canon object records every normalisation applied, making the comparison transparent:
c1 <- canon(p_flip, mode = "visual")
c1$changes # shows the coord_flip rule and its x/y swapFor a full catalogue of which equivalence patterns require which mode, see vignette("equivalence-patterns").
| Function | What it checks |
|---|---|
equiv_layers() |
Geom and stat per layer |
equiv_aes() |
Aesthetic-to-variable mappings |
equiv_scales() |
Explicitly added scales |
equiv_facets() |
Facet type and variables |
equiv_labels() |
Title, axis, and aesthetic labels |
equiv_coord() |
Coordinate system type |
equiv_params() |
Non-aesthetic layer parameters |
equiv_data() |
Data hash per layer |
equiv_plot() |
All of the above in one call (direct) |
compare_plots() |
Canonicalise then equiv_plot() |