MyNixOS website logo
Description

Extract and Compare 'ggplot2' Plot Specifications as Tidy Data Frames.

Inspects 'ggplot' objects by extracting their full declarative specification - layers, aesthetic mappings, scales, facets, coordinate systems, 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. Unlike 'ggcheck', which is designed exclusively for 'learnr'/'gradethis' pipelines and returns ad-hoc objects, 'ggspec' returns rectangular, pipeable output and does not require any grading framework as a dependency.

ggspec

Lifecycle:experimental R-CMD-check Codecov testcoverage CRANstatus

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:

# install.packages("remotes")
remotes::install_github("clement-lee/ggspec")

Usage

Extracting a spec

library(ggspec)
library(ggplot2)
#> 
#> Attaching package: 'ggplot2'
#> The following object is masked from 'package:ggspec':
#> 
#>     is_ggplot

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)
#> # A tibble: 3 × 8
#>   layer geom   stat     position mapping   params           inherit_aes data_id
#>   <int> <chr>  <chr>    <chr>    <list>    <list>           <lgl>         <int>
#> 1     0 <NA>   <NA>     <NA>     <chr [2]> <list [0]>       NA                1
#> 2     1 point  identity identity <chr [3]> <named list [2]> TRUE             NA
#> 3     2 smooth smooth   identity <chr [2]> <named list [7]> TRUE             NA
spec_aes(p)
#> # A tibble: 7 × 5
#>   layer geom   aesthetic variable source
#>   <int> <chr>  <chr>     <chr>    <chr> 
#> 1     0 <NA>   x         displ    global
#> 2     0 <NA>   y         hwy      global
#> 3     1 point  x         displ    global
#> 4     1 point  y         hwy      global
#> 5     1 point  colour    class    local 
#> 6     2 smooth x         displ    global
#> 7     2 smooth y         hwy      global

Comparing two plots

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)
#> [PASS mode=strict] 6/6 checks passed
#>   Detail:
#> # A tibble: 9 × 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 identi… identity <NA>      <NA>     <NA>   <NA>     
#> 3 layers obs        0 <NA>  <NA>    <NA>     <NA>      <NA>     <NA>   <NA>     
#> 4 layers obs        1 point identi… 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>     
#> # ℹ 2 more variables: label_obs <chr>, match <list>
equiv_plot(ref, obs_wrong)
#> [FAIL mode=strict] 3/6 checks passed: Missing geom(s): point.; Aesthetic mapping issue(s): colour->class (layer 1).; Facet mismatch: cols: 'drv' vs 'cyl'
#>   Detail:
#> # A tibble: 9 × 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 smooth smooth 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>     
#> # ℹ 2 more variables: label_obs <chr>, match <list>

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.

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.
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).
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:

ClaimWHEN
boxplot, violin, jitter all similar1 continuous + 1 discrete variable
density, histogram, freqpoly, dotplot all similar1 continuous variable
geom_count, geom_point(aes(size = n)) similar2 discrete variables, joint counts
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:

es <- enrich_spec(p)
#> `geom_smooth()` using formula = 'y ~ x'

# Non-aesthetic parameters with explicit flag
es$params_tbl[[1]]
#> # A tibble: 0 × 4
#> # ℹ 4 variables: param <chr>, value <list>, explicit <lgl>, source <chr>

# Aesthetics resolved by ggplot2, with explicit flag
es$built_aes[[1]]
#> # A tibble: 0 × 3
#> # ℹ 3 variables: aesthetic <chr>, value <list>, explicit <lgl>

Key functions

TierFunctionWhat it returns
Extractionspec_layers()One row per layer
Extractionspec_aes()One row per layer × aesthetic
Extractionspec_scales()One row per scale
Extractionspec_facets()Facet type and variables
Extractionspec_labels()One row per label
Extractionspec_coord()Coordinate system
Extractionenrich_spec()spec_layers + default/explicit flags
Comparisonequiv_plot()All checks in one call (strict)
Comparisonequiv_layers()Geom and stat per layer
Comparisonequiv_aes()Aesthetic mappings
Comparisoncompare_plots()Four-mode comparison entry point
Comparisoncompare_visual()Visual equivalence via ggplot_build()
Comparisoncompare_conceptual()Conceptual similarity detectors
Comparisonequiv_rendered()Rendered layer data comparison
Checkcheck_plot()Framework-agnostic assertion
Checkexpect_equiv_plot()testthat expectation

Related packages

  • ggcheck — designed for learnr/gradethis pipelines; returns ad-hoc objects. ggspec returns rectangular, pipeable tibbles and has no grading framework dependency.

License

MIT.

Metadata

Version

0.1.0

License

Unknown

Platforms (79)

    Darwin
    FreeBSD
    Genode
    GHCJS
    Linux
    MMIXware
    NetBSD
    none
    OpenBSD
    Redox
    Solaris
    uefi
    wasip1
    Windows
Show all
  • aarch64-darwin
  • aarch64-freebsd
  • aarch64-genode
  • aarch64-linux
  • aarch64-netbsd
  • aarch64-none
  • aarch64-uefi
  • aarch64-windows
  • aarch64_be-none
  • arc-linux
  • arm-none
  • armv5tel-linux
  • armv6l-linux
  • armv6l-netbsd
  • armv6l-none
  • armv7a-linux
  • armv7a-netbsd
  • armv7l-linux
  • armv7l-netbsd
  • avr-none
  • i686-cygwin
  • i686-freebsd
  • i686-genode
  • i686-linux
  • i686-netbsd
  • i686-none
  • i686-openbsd
  • i686-windows
  • javascript-ghcjs
  • loongarch64-linux
  • m68k-linux
  • m68k-netbsd
  • m68k-none
  • microblaze-linux
  • microblaze-none
  • microblazeel-linux
  • microblazeel-none
  • mips-linux
  • mips-none
  • mips64-linux
  • mips64-none
  • mips64el-linux
  • mipsel-linux
  • mipsel-netbsd
  • mmix-mmixware
  • msp430-none
  • or1k-none
  • powerpc-linux
  • powerpc-netbsd
  • powerpc-none
  • powerpc64-linux
  • powerpc64le-linux
  • powerpcle-none
  • riscv32-linux
  • riscv32-netbsd
  • riscv32-none
  • riscv64-linux
  • riscv64-netbsd
  • riscv64-none
  • rx-none
  • s390-linux
  • s390-none
  • s390x-linux
  • s390x-none
  • sh4-linux
  • vc4-none
  • wasm32-wasip1
  • wasm64-wasip1
  • x86_64-cygwin
  • x86_64-freebsd
  • x86_64-genode
  • x86_64-linux
  • x86_64-netbsd
  • x86_64-none
  • x86_64-openbsd
  • x86_64-redox
  • x86_64-solaris
  • x86_64-uefi
  • x86_64-windows