Behaviour changes
-
A decorated z-map on a device too small for it now says so, and can be made to fit.
plotZmap(decoration = TRUE)needs room for margins, labels and the colour scale — about
160px at the default text size on Linux and macOS, and about 200px on Windows, whose
graphics device is 96 ppi where theirs is 72 — and below that base R stopped withfigure margins too large, naming neitherrcicrnor a way forward. It now stops with a message giving the
size, the minimum, and the three ways out.plotZmap()gainspointsizeand
generateCI()gainszmappointsize: the decoration is measured in lines of text, so a
smaller size fits it onto a smaller image. This matters most throughgenerateCI(), which
sizes the z-map toimg_size, so a stimulus set below about 160px could not produce a
decorated z-map at all — a128pxset now can, withzmappointsize = 6.Both arguments default to the graphics device's own
12, so every existing call renders
exactly as before; a 512px decorated z-map is byte-identical. -
plotZmap()no longer depends onraster, and its...arguments now go to
graphics::image()instead of the raster package's plot method.colbehaves the same
way in both (and now actually works — see the bug fixes below); a call passing an argument
specific to that method will now be rejected as unused. Dropping the dependency also removes
terra,spandRcpp, and with them the GDAL/GEOS/PROJ system libraries that every
Linux CI job had to install before it could start.The z-map itself renders identically — the undecorated figures
generateCI()writes
are pixel-for-pixel the same as before, within colour quantisation, and a golden reference
rendered by the oldrastercode is committed as a test fixture to keep it that way. The
palettes are unchanged, including the quirk that a z-map drawn over a background image uses
the default palette rather than the viridis one. -
A
decoration = TRUEz-map is laid out slightly differently. The colour bar is now
drawn by hand rather than byraster, and the map is a few pixels wider at 512px. If you
regenerate a decorated z-map figure, it will not be pixel-identical to one saved with 1.2.3
— the same is already true of regenerating it on a different operating system, and for the
same reason: what a graphics device paints is not part of what this package computes. No
z-score, classification image, scaling result or informational value changes, and
generateCI()'s own z-map figures are undecorated and unaffected. See
?plotZmap, "Reproducibility across platforms". -
generateCI(mask = matrix(NA, 1, 1))now reports the malformed mask instead of silently
ignoring it. The internal test for "was a mask supplied?" asked only whether the argument
was a singleNA— which a one-cellNAmatrix, a one-elementarray(NA)and alist(NA)
all are. Such a mask was mistaken for theNAdefault, discarded without a word, and an
entirely unmasked classification image came back. A call that passes a mask and gets an
unmasked CI is the failure worth catching early; it now stops in the same mask validation
every other malformed mask reaches. The sentinel is now specifically an atomic scalar with no
dimensions, somask = NAstill means "no mask" — it is the default, and every unmasked call
relies on it — whilematrix(1, 1, 1), larger matrices, PNG paths andNULLare unchanged. -
generateStimuli2IFC()now checksbase_face_filesbefore it generates anything,
and names the entry it cannot use. Four inputs used to get past the old check and
fail from inside a parallel worker withattempt to select less than one element in get1index, which names neither the argument nor the file: a list with no names, a
list with some names missing, an empty list, and an element that is not a single file
name. They now stop immediately with a message saying which entry is wrong and why.One of them could previously appear to work. A
base_face_fileswith two entries
under the same name silently dropped all but the first — the loop looked each name
up by string, solist(face = 'a.png', face = 'b.png')produced one set of stimuli,
froma.png, and nothing at all fromb.png. That is now an error naming the
duplicated name. If you have a script that relies on it, the stimuli it produced were
never what the call asked for; give each base image its own name. -
The PNG-or-JPEG test now looks at the file extension rather than anywhere in the
path. It wasgrepl('png|PNG', filename)against the whole path, so a JPEG stored
under a directory calledpngwas handed topng::readPNG()and died withfile is not in PNG format, blaming the file for a choice the package had made. Files are now
recognised by a.png,.jpgor.jpegextension, case-insensitively. A base image
whose extension does not say what it is is rejected up front, by name, instead of
reaching a reader that cannot parse it. -
The
.Rdatavalidation errors now name the version of rcicr that wrote the file.
generateCI()andcomputeCumulativeCICorrelation()already said which field a file was
missing; they now also say where the file came from, which is what turns "this file has no
stimuli_params" into "it predates the version that added it — regenerate the stimuli, or
install that version". The version is read tolerantly, because the field cannot be taken at
face value:p$generator_versionis preferred over the top-levelgenerator_version, which
every release from 0.4.0 through 1.1.0 recorded as a hardcoded0.4.0, and a file that only
claims0.4.0is reported as unknown rather than as 0.4.0. A file with no version field at
all reports the absence and stops there — an absent field is equally a file older than 0.4.0,
a truncated one, or one rcicr never wrote.generateReferenceDistribution2IFC()'s two warnings about a missingnscalesor
noise_typewere reworded for the same reason. They said the file "was written by a version
of rcicr that did not save" the field, which an absence does not establish; they now report
what is missing, and keep the version as context rather than as a conclusion. The advice is
unchanged, and was always right either way: regenerate the stimulus set with this version. -
A stimulus file with gabor noise and no saved
sigmanow says so.
generateReferenceDistribution2IFC()assumes the historical default of 25 when a file
predates 1.1.0 and lacks the field, which it has always done silently — unlike the loud
warnings for a missingnscalesornoise_type. For gabor noise that silence hid the same
hazard those warnings exist for:sigmais what shapes the Gaussian mask, so guessing it
wrong rebuilds the null on a different noise basis than participants saw, and the resulting
InfoVal is wrong. On a 1.0.1 gabor stimulus set the reference norms move from
0.681/0.689/0.680 atsigma = 25to 0.615/0.620/0.626 atsigma = 10.Sinusoidal files are unaffected and stay silent, which is the point of doing this by
noise type rather than by field:sigmareaches the basis throughgenerateGabor()alone,
so for sinusoidal noise the norms are identical whatever it is, and a warning would be pure
noise on the far more common legacy file. Nothing warns that did not previously produce a
wrong answer, and no numeric output changes. -
plotZmap(mask = ...)now accepts a mask with an alpha channel. It previously required
every colour channel of a multi-channel PNG mask to match exactly, so a greyscale-plus-alpha
or RGBA mask whose alpha plane happened to differ from its colour planes was rejected — alpha
carries no colour information and is now always ignored, matchinggenerateCI(mask = ...).
A rectangularzmap/mask pair continues to work as before.plotZmap(mask = ...)also now
rejects a mask that is neither a string nor a matrix with a clear error, instead of failing
later insidepng::readPNG()with an unrelated message. -
plotZmap(mask = NA)now means "no mask", as it already does ingenerateCI(). The two
functions detected a supplied mask differently —plotZmap()asked only whether the argument
was non-NULL— so the same sentinel meant opposite things:generateCI()readNAas "no
mask" whileplotZmap()passed it on and stopped withThe mask argument is neither a string nor a matrix!.NaNbehaved the same way. Both now render an unmasked z-map. No call that
worked before changes: the inputs affected all raised that error. -
generateStimuli2IFC()no longer savestrialin the.Rdatafile. It was the loop
counter left over from stimulus generation — always equal ton_trials, which is already
saved. Nothing in the package or the documented contract reads it. Existing.Rdatafiles
that containtrialcontinue to work; the field is simply ignored on load.
Reproducibility impact
-
Individual-CI PNGs written by
generateCI(save_individual_cis = TRUE)carried the wrong
participant's name, and are now named correctly. The per-participant loop selects each
participant's trials by sorted order and took the output filename from order of
appearance. Where those two orders differ, every file in<targetpath>/individual_cis
was given another participant's ID. The pixels were always right; only the names were
wrong.Affected: a direct call to
generateCI(participants = ..., save_individual_cis = TRUE)where theparticipants
vector is not already in sorted order. Such a call produced correct images under incorrect
filenames, so a figure published as participantp2may be someone else's classification
image.Do not assume tidy data was safe — the common case is affected. Sorting is lexical,
so text labels like"p10"sort before"p2". A study whose participants appear in
collection orderp1, p2, ..., p10is therefore affected from the tenth participant
onward, even though nothing about that data is out of order in any ordinary sense:participants, in collection orderaffected "p1"…"p9"no "p1"…"p10"or beyondyes "p01"…"p12"(zero-padded)no "1"…"12"(character)yes 1…12(numeric)no Unpadded text IDs and ten or more participants is the ordinary shape of a reverse
correlation study, so if that describes yours and you used the call above, check rather
than assume.Not affected — and this is the documented route, so most analyses are in this group:
batchGenerateCI(),batchGenerateCI2IFC()andgenerateCI2IFC(). None of them
exposesparticipantsorsave_individual_cisat all; they split the data themselves
and name each image from the grouping value directly, never reaching the code that was
wrong. Per-participant classification images computed the way the package documentation
has recommended since the CRAN era —batchGenerateCI2IFC(), andbatchGenerateCI()in
the current vignette — were correctly labelled throughout.- Everything
generateCI()returns. The group classification image is a mean across
participants and so does not depend on their order; the per-participant stack, and every
z-map built from it, were internally consistent throughout. No returned value, and no
number in any.Rdatafile, ever changed. - Any call that left
save_individual_cisat its defaultFALSE. No individual-CI
file was written, so there was nothing to mislabel. - Participants already in sorted order, where the two orderings coincide.
- Every CRAN release. rcicr was on CRAN from July 2014 until it was archived in June
2021; the last release, 0.3.4.1 (July 2016), predates thesave_individual_cisoption
entirely.install.packages('rcicr')never produced a mislabelled file at any point.
If you have published from individual-CI images, check your analysis scripts. Two
steps, neither of which requires re-running anything.Step 1 — did you use the affected call at all? Look for an
individual_cis/directory
in your output. Nothing else in the package creates it, so if it is not there the affected
call never ran. If your per-participant images came frombatchGenerateCI()or
batchGenerateCI2IFC(), the same applies: those cannot reach the defect.Check the output rather than the script.
save_individual_cisis the sixth formal of
generateCI(), so a call can set it positionally —
generateCI(stim, resp, "face", rdata, pids, TRUE, ...)— and write those files without
the argument name appearing anywhere;do.call()hides it the same way. Searching your
scripts and finding nothing does not clear an analysis. Finding noindividual_cis/does.Step 2 — if the directory is there, was your
participantsvector in sorted order?
Note "sorted" means lexically sorted for text labels, per the table above — sorting your
data frame by a"p1"-style column does not make it safe once you reach ten participants.
Where you still have the vector, one expression settles it:identical(unique(participants), sort(unique(participants))) # TRUE = names were correct
If the output is long gone and only the script survives, search it for
individualrather
than the full argument name, and read by hand anygenerateCI()call passing six or more
arguments positionally.Recovering existing output is a rename, not a re-run. The mapping is exact: the file
namedunique(participants)[i]holds the classification image of
sort(unique(participants))[i].old <- unique(participants) # the names the files were given new <- sort(unique(participants)) # the participants they actually hold
Every release from 1.0.1 (January 2023) through 1.2.3 (August 2026) carries the
defect, as does every install from the development branch from 15 August 2017 onward —
the date it was introduced, in the commit that first made individual-CI saving work at
all. There were no releases between 2017 and 2023, so a copy obtained in those years came
from the branch and is affected too.Getting it from GitHub took a deliberate step, until December 2021. The default branch
wasmaster, which carried an older repository layout with noDESCRIPTIONat its root, so
install_github('rdotsch/rcicr')did not install anything at all — it failed. The affected
code was on thedevelopmentbranch, reached only by asking for it:
install_github('rdotsch/rcicr', ref = 'development'), which the README of the time
labelled "AT YOUR OWN RISK" and specifically advised against for analyses meant for
publication.developmentbecame the default branch on 2021-12-28, and from then a plain
install returns affected code.If you need to work out which version a stored analysis actually used, the version number
alone will not tell you:0.4.0sat on the development branch for five years, with the
defect entering partway through. A note listing every release, its date, whether it came
from CRAN or GitHub, and what "the latest version" gave you in each window is at
https://github.com/rdotsch/rcicr/blob/main/notes/individual-ci-mislabelling.md. -
computeCumulativeCICorrelation()with a maskedtargetcinow returns numeric
correlations where it previously returned all-NA. No existing analysis could have used
the old result — it carried no information — but code that checked forNAon the returned
curve will see a different answer. Unmasked targets are bit-identical.
Bug fixes
-
generateCI(save_individual_cis = TRUE)names each PNG for the participant it was
actually computed from. The loop selects participants by sorted level order and the
filename was taken from order of appearance, so the two disagreed for any data not already
sorted by participant. See "Reproducibility impact" above for how to tell whether files you
already have are affected, and how to correct them without recomputing anything. -
computeCumulativeCICorrelation()now returns real correlations whentargetciwas
generated with a mask.generateCI()storesNAin every pixel amaskexcludes,
and the correlation was taken over all pixels (use = "everything"), so a single masked
pixel made every point on the curveNAhowever strong the signal. Correlations are now
computed over the unmasked pixels only. A fully masked target (every pixelNA) still
returns an all-NAcurve — there are no complete pairs to correlate. -
plotZmap(col = ...)works. Supplying a palette is how?plotZmaphas always told
you to change the colours, and doing it stopped the call withformal argument "col" matched by multiple actual argumentsbefore anything was drawn — the function passed its
owncolalongside yours. This affected every released version; the argument is now taken
as an override, and is used for the colour bar as well as the map. -
A base image with no contrast no longer becomes an all-
NaNbase image. Under
maximize_baseimage_contrast = TRUE, the default,generateStimuli2IFC()rescales
with(img - min(img)) / (max(img) - min(img))— which is 0/0 when every pixel is the
same value. The resultingNaNbase face was written into the.Rdatawith no error
and no warning, every classification image computed from that stimulus set inherited
it, and the stimuli themselves came out uniformly black,png::writePNG()clamping
NaNto zero. It now stops with an error naming the file.A photograph is never uniform, so this bit synthetic and accidentally-blank base
images — but it failed silently, and the symptom appeared a long way from the cause.
The error fires only undermaximize_baseimage_contrast = TRUE: a flat base image is
perfectly usable with the rescale switched off, and the message says so. -
computeCumulativeCICorrelation()reads pre-0.3.0 stimulus files again. Files written
before rcicr 0.3.0 (January 2015) store 4096 contrast parameters per trial where only 4092
patches exist, andgenerateCI()has truncated the four unused columns for years.
computeCumulativeCICorrelation()did not, so on such a file the extra columns reached
generateNoiseImage()as a length mismatch and it aborted with "number of parameters
doesn't equal number of patches" — the cumulative-correlation curve could not be computed at
all. It now applies the same truncation. Files from 0.3.0 onward already have 4092 parameters
and are unaffected. The same fix keeps a single presented stimulus two-dimensional, so a
one-stimulus call — which aborted with "incorrect number of dimensions" on any file — now
returns its (single-point) curve. -
generateCI()withparticipantsworks again on more than one core. Supplying
participantswithouttargetpath— the normal thing to do when you are not saving
individual CIs — stopped withargument "targetpath" is missing, with no defaultas soon as
n_coreswas greater than 1, which is the default on any multi-core machine.foreach
inspects the loop body and fetches every variable it mentions, including one used only in
the branch that saves individual CIs, and that branch is precisely the one that cannot run
when you have not asked for those files. Single-core calls were unaffected, as was saving
individual CIs with atargetpath. -
The progress bar moves again during parallel runs.
generateStimuli2IFC()and
generateCI()build their progress bar in your session but were advancing it from inside
the parallel loop, where each worker holds a private copy — so under the default
(ncores/n_coresgreater than 1) the bar sat at 0% for the whole job and jumped to 100%
at the end. On a 770-trial 512px set that is a long time with nothing to watch. Affects
stimulus generation, per-participant CIs, andt.testz-maps. Serial runs were never
affected and are unchanged.This required swapping the
foreachbackend fromdoParalleltodoSNOW, which is the
one that can report task completions back to the parent session.doParallelis no longer
a dependency;doSNOWandsnowtake its place, for slightly less installed than it took.
Nothing about how the loops compute changes, and no numeric output changes. -
The
base_face_filestype check raises an error you can actually read. It wrote
its explanation tostderr()and then calledstop()with no arguments, so the
condition it raised carried an empty message:conditionMessage()returned"", and
anything wrapping the call — a Shiny app, a batch script, knitr — caught an error it
could not report. Undersink()orcapture.output(type = "message")the explanation
could be separated from the failure entirely. The text now travels with the condition. -
A base image that cannot be read is reported with the file it came from. The
reader's own complaint (file is not in PNG format, and the like) is kept and
prefixed with the entry's name and path, and a file that does not exist is now
reported as missing rather than as unopenable. -
Base images are validated before the noise basis is built and before the output
directory is created. A mistyped path used to cost the full noise-pattern
computation — appreciable at the default 512px — and left an empty stimulus directory
behind before reporting the problem.No numeric output changes. Every check here either replaces an error with a clearer
error, or rejects input that could not have produced correct stimuli; a call that
succeeds today produces exactly what it did before. -
A multi-channel PNG mask one pixel wide or tall is now rejected instead of silently
masking everything. Dropping the colour channel from such a mask also dropped its
singleton spatial dimension, leaving a plain vector whose absent dimensions made the
size check pass vacuously; the mask was then recycled by linear indexing. A 1-by-8 RGB
mask against an 8-by-8 stimulus set returned an entirelyNAclassification image with
no error or warning. It now fails the size check and says so. Masks of any other shape
are unaffected. -
generateCI(mask = ...)no longer crashes on a greyscale-plus-alpha PNG mask. The
internal mask importer hardcoded three colour channels, so any 2-channel PNG (the form
png::readPNG()produces for 8-bit greyscale-plus-alpha) failed withsubscript out of boundsrather than a clean error —plotZmap(mask = ...)'s separate, now-removed mask code
already accepted the same file. Mask import is now shared between the two functions (#185),
so both accept a 2-channel mask, and a 4-channel (RGBA) mask continues to work exactly as
before. The mask-size-mismatch message now names what the mask is being checked against
("stimuli" fromgenerateCI(), "z-map" fromplotZmap()) instead of always saying
"stimuli", and reports the mask's dimensions in the same row-by-column order as the
target's rather than transposed.
Documentation
-
?computeCumulativeCICorrelationexplains what its curve ending at 1 does and does not
mean. With notargetci, the final CI it compares against is built from the same
un-aggregated trials as the curve, so wherever the evaluated trials reach the last one the curve
ends at 1 — self-consistency, not convergence. That is every call at the defaultstep = 1,
though a largerstepcan stop short of the final trial and end below 1, and responses that
cancel exactly give a constant CI and an all-NAcurve. That final CI is
identical togenerateCI()'s where every stimulus was
presented the same number of times, and differs where repeat counts vary, because this
function weights each trial equally whilegenerateCI()weights each unique stimulus equally.
Measured on an 8-trial set with counts 4/2/1/1, the two correlate at 0.77. Pass
targetci = generateCI(...)when you want the curve to describe the CI you will report.
Nothing changed in what the function computes. -
?generateCIdocuments the weighting of repeated stimulus presentations. When
participantsisNA, repeated presentations are collapsed before building the CI: each
unique stimulus gets equal weight, regardless of how many times it was presented. With equal
repeat counts this changes nothing; with unequal counts it is a different estimand from
weighting each trial equally, and the difference can be substantial. Previously this was
described only as a performance optimisation in a code comment. -
There is now a documentation website: https://rdotsch.github.io/rcicr/. The
function reference, both vignettes and this changelog are readable without installing
the package first. It is generated from the same sources —README.mdis the home page,
so "How it works" and "Anatomy of the.Rdatafile" are not maintained twice — and
rebuilt by GitHub Actions on every push, so it cannot drift from the code. -
The repository now carries a
CITATION.cff, so GitHub offers a "Cite this repository"
button.citation("rcicr")is unchanged and still readsinst/CITATION, which remains the
citation of record; the new file is generated from it andDESCRIPTIONbycffr, and
carries it aspreferred-citation. The two cannot drift apart: CI regenerates the file,
compares every field, and validates the result against the CFF schema. -
The install instructions now install a release rather than the development version.
remotes::install_github('rdotsch/rcicr')takes the tip ofmain, which carries unreleased
changes; theREADME.mdand walkthrough-vignette instructions now lead with
@*release, and show@v1.2.3for installing one specific version — worth recording in an
analysis script, since a classification image is only reproducible against the version that
computed it. -
?generateGabornow documentsgenerateGabor(). Its example called
generateSinusoid(), so the help page and the reference site demonstrated a call that never
reached the function being documented;@returndescribed a sinusoid rather than a gabor
patch, and@param sigmawas an unfinished sentence that did not say what the value means.
sigmais the standard deviation of the Gaussian mask in pixels — measured, not
inferred: the mask falls toexp(-1/2)at 24.5 px from centre forsigma = 25. The
function itself is unchanged.
Internal
-
The pre-0.3.0 0-indexed noise path is now pinned against an independent oracle. Reading a
.Rdatafile whose patch indices start at 0 takes a backward-compatibility branch in
generateNoiseImage()that drops the 0-indexed cells and recycles a too-short weight vector —
which looks like it could misalign the whole image. It does not: the last patch layer is
all-zero, so the recycled values land only where the patch is zero and are multiplied away.
A test confirms the output equals the honest "one patch not shown" result exactly, so no
change to the indexing can silently reintroduce a misalignment. No behaviour changes. -
Stimulus sets generated by 1.0.1 and 1.1.0 are now covered by tests. The suite carries
.Rdatafiles those releases actually wrote — generated by installing each tag, not
reconstructed — and checks that the currentgenerateCI()still reads them and produces a
classification image. A 1.0.1 file predatesnscales, so
generateReferenceDistribution2IFC()has to assume it: the test asserts the null it builds is
bit-identical to one from a file that statesnscalesoutright, because a null built on the
wrong noise basis finishes just as happily and is wrong. (The parallelsigmafallback only
affects Gabor noise, which these sinusoidal fixtures do not exercise — tracked separately.)
Every other
fixture is built by the current generator, so nothing before this could catch a field this
version expects and an older one never saved. It runs on every platform with no network: the
fixtures are 205 KB (1.0.1) and 45 KB (1.1.0).