generaterpm builds Raven’s Progressive Matrices
(RPM) style test items as SVG. Everything is built from two
kinds of ingredient:
<circle>), referred
to everywhere else by name (a character string).operation_and(),
operation_or(), operation_xor(),
operation_inhibit_a(), operation_inhibit_b())
- these combine two cells’ primitive names into a new cell, which is how
every derived cell in a matrix is produced.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.
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 elseA cell is just a character vector naming which primitives are active
in it, e.g. c("c01", "c02", "c09").
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"
## [1] "c01" "c02" "c04" "c03" "c05"
## [1] "c01" "c03" "c05"
## [1] "c03" "c05"
## [1] "c01"
compute_item(): every rule, for one paircompute_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.
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
## [1] 1420
## [1] "/tmp/RtmpIyQhYg/intro_basic.svg"
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"
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 ownBoth 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:
## [1] "<circle cx=\"30\" cy=\"60\" r=\"5\" fill=\"green\"/>\n<circle cx=\"60\" cy=\"60\" r=\"5\" fill=\"red\"/>"
## [1] ""
return_shape(): one standalone shapeFor 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)">
##
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:
compute_operation_sequence().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
op1/op2Repeated "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:
## [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.
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
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
## [1] 740
## [1] "/tmp/RtmpIyQhYg/sequence_item_correct_B.svg"
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.
op1/op2 deliberatelyop1/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.
| 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 |