Introduction to generaterpm

Overview

generaterpm builds Raven’s Progressive Matrices (RPM) style test items as SVG. Everything is built from two kinds of ingredient:

Two functions build on top of the set operations to generate cell content for you:

Function What it produces
compute_item() Every standard-operation result for one pair of cells (a “what would each rule produce here” fan-out)
compute_operation_sequence() A chain of terms, each derived from the two terms before it (optionally reseeded with fresh random material every few steps)

Either one’s output can be handed to fill_rectangle_nn(), which lays cells out into a single combined SVG - a grid of stimulus cells with one cell hidden behind a ?, plus an optional shuffled row of answer choices.


Setup: primitives

Primitives are resolved by name at runtime (eval(parse(text = name))), so every primitive that appears in a cell vector must exist as a variable in the calling environment. This vignette uses a small grid of coloured dots, split into an internal subset (l1) and an external subset (l2) - the same convention used throughout the package’s own examples:

c01<-'<circle cx="30"  cy="60"  r="5" fill="green"/>'
c02<-'<circle cx="60"  cy="60"  r="5" fill="red"/>'
c03<-'<circle cx="90"  cy="60"  r="5" fill="red"/>'
c04<-'<circle cx="30"  cy="30"  r="5" fill="green"/>'
c05<-'<circle cx="60"  cy="30"  r="5" fill="green"/>'
c06<-'<circle cx="90"  cy="30"  r="5" fill="green"/>'
c07<-'<circle cx="30"  cy="90"  r="5" fill="green"/>'
c08<-'<circle cx="60"  cy="90"  r="5" fill="red"/>'
c09<-'<circle cx="90"  cy="90"  r="5" fill="red"/>'
c10<-'<circle cx="120" cy="30"  r="5" fill="green"/>'
c11<-'<circle cx="120" cy="60"  r="5" fill="green"/>'
c12<-'<circle cx="120" cy="90"  r="5" fill="green"/>'
c13<-'<circle cx="120" cy="120" r="5" fill="green"/>'
c14<-'<circle cx="30"  cy="120" r="5" fill="green"/>'
c15<-'<circle cx="60"  cy="120" r="5" fill="green"/>'
c16<-'<circle cx="90"  cy="120" r="5" fill="green"/>'

shape_vector <- paste0("c", sprintf("%02d", 1:16))
l1 <- c("c02", "c03", "c08", "c09")          # "internal" subset
l2 <- shape_vector[!shape_vector %in% l1]    # "external" subset - everything else

A cell is just a character vector naming which primitives are active in it, e.g. c("c01", "c02", "c09").


Set operations

The five set operations all work directly on vectors of primitive names:

p1_ex <- c("c01", "c02", "c04")
p2_ex <- c("c02", "c03", "c04", "c05")

operation_and(p1_ex, p2_ex)       # names in both
## [1] "c02" "c04"
operation_or(p1_ex, p2_ex)        # names in either
## [1] "c01" "c02" "c04" "c03" "c05"
operation_xor(p1_ex, p2_ex)       # names in exactly one
## [1] "c01" "c03" "c05"
operation_inhibit_a(p1_ex, p2_ex) # names in p2_ex but not p1_ex
## [1] "c03" "c05"
operation_inhibit_b(p1_ex, p2_ex) # names in p1_ex but not p2_ex
## [1] "c01"

compute_item(): every rule, for one pair

compute_item() takes two cells and computes what every standard operation would produce, plus every custom l1/l2-split combination named in a global combination_operations matrix (which must exist before you call it):

combination_operations <- matrix(c("and", "or"), ncol = 2, byrow = TRUE)

p1 <- c("c01", "c02", "c03", "c04")
p2 <- c("c03", "c04", "c05")

triplet <- compute_item(p1, p2, l1 = l1, l2 = l2)
names(triplet)
##  [1] "p1"                        "p2"                       
##  [3] "and"                       "or"                       
##  [5] "xor"                       "inhibit_a"                
##  [7] "inhibit_b"                 "l1_and_l2_and"            
##  [9] "l1_or_l2_and"              "l1_xor_l2_and"            
## [11] "l1_inhibit_a_l2_and"       "l1_inhibit_b_l2_and"      
## [13] "l1_and_l2_or"              "l1_or_l2_or"              
## [15] "l1_xor_l2_or"              "l1_inhibit_a_l2_or"       
## [17] "l1_inhibit_b_l2_or"        "l1_and_l2_xor"            
## [19] "l1_or_l2_xor"              "l1_xor_l2_xor"            
## [21] "l1_inhibit_a_l2_xor"       "l1_inhibit_b_l2_xor"      
## [23] "l1_and_l2_inhibit_a"       "l1_or_l2_inhibit_a"       
## [25] "l1_xor_l2_inhibit_a"       "l1_inhibit_a_l2_inhibit_a"
## [27] "l1_inhibit_b_l2_inhibit_a" "l1_and_l2_inhibit_b"      
## [29] "l1_or_l2_inhibit_b"        "l1_xor_l2_inhibit_b"      
## [31] "l1_inhibit_a_l2_inhibit_b" "l1_inhibit_b_l2_inhibit_b"

Always pass l1/l2 explicitly. compute_item()’s defaults are the globals pi/pe - and pi shadows base R’s pi = 3.14159..., so if you forget to define your own and rely on the default, every %in% l1 comparison silently becomes all-FALSE instead of erroring.

You decide which of these named results is the “correct” rule for your item and which are plausible wrong answers - compute_item() only computes the candidates.


Laying a matrix out with fill_rectangle_nn()

fill_rectangle_nn() takes just items and a single index, correct. It always renders items[1:correct] as the main grid, with the panel at position correct replaced by a ? - and if there are any items after correct, every one of them automatically becomes a distractor: the answer row is items[(correct+1):length(items)] shuffled together with the true items[[correct]], auto-labelled "A", "B", "C", … There’s no separate labels/distractor/answer_labels argument to fill in - the position of correct within items is what controls all of it, so you arrange items accordingly: known cells first, the hideable correct answer at correct, candidate distractors after it (and anything you don’t want considered at all simply left out of items).

items <- unname(triplet[!names(triplet) %in% c("p1", "p2")])
length(items) # 6: and, or, xor, inhibit_a, inhibit_b, l1_and_l2_or
## [1] 30
grid <- fill_rectangle_nn(
  items,
  correct  = length(items),           # hide the last cell behind "?", no items after it
  ncol     = 3,
  filename = file.path(outdir, "intro_basic")
)
grid$width
## [1] 440
grid$height
## [1] 1420
grid$filename # no trailing items -> no answer row -> plain "<filename>.svg"
## [1] "/tmp/RtmpIyQhYg/intro_basic.svg"
?

Adding answer choices

Move correct earlier in items so some items are left over to serve as distractors - here the 4th term (inhibit_a) is the one to guess, with the two terms after it (inhibit_b, l1_and_l2_or) as its distractor pool:

grid2 <- fill_rectangle_nn(
  items,
  correct  = 4,
  ncol     = 3,
  filename = file.path(outdir, "intro_with_answers")
)
grid2$filename # e.g. ".../intro_with_answers_correct_B.svg" - the letter it shuffled to
## [1] "/tmp/RtmpIyQhYg/intro_with_answers_correct_H.svg"
? A B C D E F G H I J K L M N O P Q R S T U V W X Y Z NA

fill_rectangle_nn() rejects a duplicate distractor by resampling it from the whole primitive pool as a last resort - but it’s much better to never hand it a degenerate answer set in the first place. See the retry-loop pattern later in this vignette for how to guarantee a clean, duplicate-free answer set before rendering.


render_cell(): rendering one cell on its own

Both grid functions resolve a cell’s primitive names to markup via render_cell(), which is exported if you want to preview a single cell (e.g. inside your own SVG wrapper) without building a whole grid:

render_cell(c("c01", "c02"))
## [1] "<circle cx=\"30\"  cy=\"60\"  r=\"5\" fill=\"green\"/>\n<circle cx=\"60\"  cy=\"60\"  r=\"5\" fill=\"red\"/>"
render_cell(character(0)) # an empty/blank cell renders as ""
## [1] ""

return_shape(): one standalone shape

For a single, standalone SVG document (not part of a grid), use return_shape():

svg_output <- return_shape(shape_vector, width = 130, height = 130,
                           transform_x = -10, transform_y = -10,
                           filename = file.path(outdir, "my_shape"))
cat(substr(svg_output, 1, 120))
## <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="130" height="130">
##   <g transform="translate(-10,-10)">
## 

Building a sequence with compute_operation_sequence()

compute_item() (above) computes every candidate rule result for one pair of cells. compute_operation_sequence() instead grows a whole chain of terms - useful when you want a longer run of cells to draw a puzzle’s known cells and its answer choices from, rather than hand-picking a single pair per item.

The end-to-end workflow is:

  1. Generate a term sequence with compute_operation_sequence().
  2. Pick which term is the “correct” answer (everything after it becomes the distractor pool - see above).
  3. Guarantee the answer set has no duplicates before rendering - this is the step that’s easy to skip and easy to get subtly wrong (see below).
  4. Lay it out with fill_rectangle_nn().

compute_operation_sequence() starts from two random terms, then produces each new term either by combining the two immediately preceding terms (op1 on their l1-subsets, op2 on their l2-subsets), or - every reset steps - by drawing two brand-new random terms instead of chaining:

set.seed(1)
seq_items <- compute_operation_sequence(shape_vector, l1, l2,
                                        sequence = 14, op1 = "and", op2 = "and", reset = 3)
length(seq_items)
## [1] 14

A caveat worth knowing before you pick op1/op2

Repeated "and" (intersection) can only shrink a set, never grow it, so a run of several "and" steps in a row easily collapses to an empty term. That’s not a bug so much as a property of the rule you chose - but it does mean some terms in the sequence may come out empty:

vapply(seq_items, length, integer(1))
##  [1]  9  5  2  7  0  0  3  0  0 16  0  0  2  0

If you see several empty terms clustered together, consider a less collapse-prone combination (mixing in "or"/"xor", or resetting more often) rather than assuming something is broken. See ?compute_operation_sequence for the full explanation of how reset and the internal/external split interact.


Picking correct + distractors, safely

correct <- 9   # seq_items[10:14] (the rest of the sequence) become the distractor pool

Before rendering, confirm the answer pool has no duplicates. fill_rectangle_nn() has a last-resort fallback that resamples a duplicate distractor from the whole primitive pool, but that fallback can’t tell “correct answer” apart from “distractor” - if the true answer happens to collide with a distractor (quite possible after several "and" steps produce more than one empty term, as above), it’s the fallback, not you, that decides which copy survives. It’s much safer to just reject a bad draw up front:

max_tries <- 200L
found <- FALSE
for (attempt in seq_len(max_tries)) {
  seq_items <- compute_operation_sequence(shape_vector, l1, l2,
                                          sequence = 14, op1 = "and", op2 = "and", reset = 3)
  answer_pool <- seq_items[correct:length(seq_items)]   # the correct answer + every distractor
  if (!any(duplicated(answer_pool))) {
    found <- TRUE
    break
  }
}
found
## [1] TRUE

Rendering the sequence item

grid3 <- fill_rectangle_nn(
  seq_items,
  correct  = correct,
  ncol     = 3,
  cell_px  = 150L,
  gap_px   = 20L,
  filename = file.path(outdir, "sequence_item")
)
grid3$width
## [1] 1040
grid3$height
## [1] 740
grid3$filename # includes which letter the correct answer shuffled to, e.g. "..._correct_D.svg"
## [1] "/tmp/RtmpIyQhYg/sequence_item_correct_B.svg"
? A B C D E F

The main grid shows terms 1 through correct, with the correct-th panel replaced by a ?; the answer row below shuffles together seq_items[(correct+1):length(seq_items)] (the distractors) and the true seq_items[[correct]], auto-labelled "A", "B", "C", … - the letter the true answer lands on is reported back in grid3$filename and encoded in the saved file’s name.


Choosing op1/op2 deliberately

op1/op2 Effect across the sequence
"and" Shapes only ever drop out - trends toward empty over several steps
"or" Shapes only ever accumulate - trends toward the full pool
"xor" Shapes toggle in and out - stays lively, doesn’t trend to empty or full
"inhibit_a" / "inhibit_b" Asymmetric removal - order of the two input terms matters

Mixing op1 and op2 (e.g. "and" internally, "xor" externally) is a good way to keep one half of every cell simplifying while the other half keeps varying.


Quick reference

Function Purpose
operation_and/or/xor/inhibit_a/inhibit_b() Combine two cells’ primitive names
compute_item(p1, p2, l1, l2) Every standard-operation result for one pair
compute_operation_sequence(shape_vector, l1, l2, sequence, reset, op1, op2) A chained, periodically-reseeded sequence of terms
render_cell(vec) Resolve one cell’s primitive names to SVG markup
fill_rectangle_nn(items, correct, ncol, cell_px, gap_px, answer_gap_px, filename) Lay out items[1:correct], hiding position correct behind ?; anything after it in items auto-becomes the shuffled answer row
return_shape(shape_vector, width, height, transform_x, transform_y, filename) Render one standalone shape
compute_operation_sequence() parameter Meaning
shape_vector Pool to draw seed/reset terms from
l1 / l2 “Internal” / “external” primitive subsets (no default - always pass explicitly)
sequence Total terms produced, including the two seeds (must be >= 3)
reset How often fresh random terms replace the chained recurrence
op1 / op2 Operation applied to the l1- / l2-subsets at each step