lexsync (development version)
Design, schema and registry files are read as UTF-8 whatever the session’s character type.
yaml::read_yaml()converted each file to the session’s encoding, so under the C locale, as in a container or a batch job without a UTF-8 locale, a file stopped at its first accented letter:config/design_es_lexdec.yamlread as an empty design,list_corpora()andfetch_corpus()failed on the bundled registry’s citations, and the datasheet left out a cached corpus’s citation without a word. A file that is not UTF-8 is now refused, as the Python engine refuses it, where it used to be cut short with a warning, and a literal block scalar that ends a file keeps its final line break, as it does in Python.The hash keys behind the trial order, list balancing, jitter and the tie-break between candidates are UTF-8 in every locale. Under the C locale,
paste()turned a latin1-marked condition label into escape text andenc2utf8()did the same to an unmarked one, so such labels ranked by other digests than in the Python engine. The 0.1.0 entry below that says a latin1-marked label “can no longer rank by different digests” held in a UTF-8 session only. A run under the C locale now writes the stimuli, reports and experiments of the Spanish and Chinese demonstrations byte for byte.list_corpora()andfetch_corpus()refuse aregistry_paththat does not exist with lexsync’s own message, not the connection errorfile()gave. The Python twin read whichever registry its search found next.resample_stimuli()andvalidate_design()refuse aresample.n_setsthat is not a whole number of at least 1, the rulen_per_conditionfollows. A fractional count was truncated in silence, a logical or a quoted number was taken as a count, and zero gave an empty selection.describe_stimuli()refuses a dimension or a grouping column the stimuli do not carry, and so domatch_report()andmatch_report_continuous(), which are built on it. A misspelt dimension gave a table of missing values with annof 0, which looked complete. The pipeline reports only the columns it has, so no run is affected.The licence notice for the bundled CC BY-SA lexica is installed with the package, as
COPYRIGHTS. R does not installLICENSE.note, which theCopyrightfield named, so binary installations carried the lexica without it.LICENSE.notenow points toinst/COPYRIGHTS, and theCopyrightfield, the citation footer and the About guide name the installed file.The spelling test fails a check run with
NOT_CRANset, as indevtools::check()and on GitHub Actions, when the help pages, the vignettes, the README, this file or the package description use a word found neither in the British English dictionary nor ininst/WORDLIST. It used to list such words and carry on, so a misspelling left only a NOTE, which does not fail a run. CRAN and a plainR CMD checkstill skip the test. The documentation has no such word today, so the word list is unchanged.The datasheet records the materials source, the stimuli file and every artefact with forward slashes, as the Python engine does. A run given Windows paths recorded them with backslashes.
The apps’ download bundles reproduce their own record. The apps ran a temporary copy of the design with absolute paths and then bundled the displayed design, so the bundled datasheet’s design checksum matched no file in the bundle, and the datasheet and run logs exposed the user’s home and temporary directories. They now run the exported command in a folder laid out like the bundle, and the Download tab archives that folder, with the outputs under
output/. Rerunning the exported code in the unzipped folder rewrites them with the same bytes, run logs apart. The PsychoPy and OpenSesame downloads are zip archives holding the tables those experiments load, and each app removes a run’s folder once its results leave the screen.The Shiny app’s presets set the matched dimensions and the matching method, as the Streamlit app’s presets do. Its neighbourhood preset matched on N, the dimension it manipulates, and on OLD20, a close correlate of N, and it left length unmatched. Both apps now warn when ‘Match on’ names a dimension whose windows differ between conditions, and neither runs a factorial design with nothing to match on. Their command lines quote the design file’s name and run in
cmd.exeand Windows PowerShell, and the Streamlit app’s R line now passes theoutdirthatrun_pipeline()requires. The interval column is labelled by the configured alpha, and the sentence under the exported code says that the engines agree byte for byte only when the run’s datasheet says so. The Shiny method selector carries the Streamlit app’s note onmahalanobisandoptimal. Both apps open onen_v2, the English lexicon screened against SUBTLEX-US, and keep only its unmarked forms unless ‘Screened forms only’ is cleared, as the shipped English designs do. A session left at its defaults therefore selects different words from before. The Streamlit app no longer passesuse_container_width, an argument Streamlit is removing, and needs Streamlit 1.49 or later.counterbalance.max_runcaps runs of one condition, or of one value of any column named incounterbalance.run_on, in the trial order.counterbalance()repairs the seeded order deterministically, taking at each step the earliest trial in it that keeps the limit and leaves the rest of the list able to keep it, so it refuses only a limit no order can meet, with a message giving the counts. A missing value counts as a value of its own. With fillers, the limit applies to the order they are interleaved in, and the practice trials keep it on their own. The datasheet records the limit and the longest run realised in each presentation version ascounterbalancing$max_run, a field of datasheet format 1.2, and the Methods text states it, citing van Casteren and Davis (2006). The shipped designs hold runs of up to eleven trials of one condition, and the lexical decisions up to seven consecutive trials with the same correct key. Both new keys are off by default, so no shipped design changes.counterbalance.orders: mgives each list m trial orders.counterbalance()addsorder_1toorder_m, each trial’s position in each order, to the stimuli and the loop tables. Order 1 is the seeded order, and order j from 2 is ranked by the SHA-256 ofseed|order<j>|replicate|list|set|condition. Participant p runs version((p - 1) mod V) + 1in order(((p - 1) div V) mod m) + 1: the PsychoPy script and the jsPsych page sort the version’s trials by that order and record it in every data row, and OpenSesame gets one experiment per version and order,<base>_list<k>_order<j>.osexp, whose start-up check compares the order too. Participants on one list no longer all meet the same order. The datasheet recordscounterbalancing$orders(the number of orders and the rule), a field of datasheet format 1.2.Every generated PsychoPy script and jsPsych page carries
ORDERSand the code that picks an order, so their bytes change, and so do the checksums the datasheets record of them and of the schema, whose comments describe the new keys. No stimulus, loop table or OpenSesame experiment moves.The documentation said that practice trials are shuffled so that participants meet them in different orders. Every participant given a list meets them in one fixed order, as the design intends, and the text now says so.
counterbalance.scale: madbalances lists on each dimension’s deviations from its mean in units of its mean absolute deviation, so a dimension’s weight no longer depends on where its scale starts. The values are rounded to hundredths by the shared rounder before the integer mean and the unit are taken, so adding a constant that is a whole number of hundredths leaves the lists unchanged. The default,mean, divides by the dimension’s mean, which suits ratio scales only: Zipf frequency, whose scale starts near 3, carried the least weight of the four dimensions inen_balanced_lists, and re-expressing it as Zipf - 3 moved 22 of 40 sets to another list. The default is unchanged so running studies keep their lists. Undermad, thebalance_lists()report and the datasheet’scounterbalancing$optimiserecord givescale, a field of datasheet format 1.2,cost_unitnames the unit and the Methods paragraph names the scale.validate_design()refuses any other scale, and the key joins the design vocabulary in both engines, whose suites pin the new digest. The optimiser’s exact-integer guard now bounds the sum of the absolute set totals, since signed deviations can sum to near zero, and gives the same answer as before for values of one sign. The schema’s comments describe the option in all three copies. The edit changesconfig/schema.yaml, so every committed datasheet records a new schema digest and every run log a new datasheet checksum. The twin-edge casesscale_madandscale_unknownpin both engines to one outcome.normalise_lexicon()turns a delimited corpus file into a lexiconload_lexicon()reads. It applies a column map, reads tab-separated files, derives Zipf values from frequencies per million (Zipf = log10(per million) + 3) and writeswordandfreq_zipf, withfreq_permillionandposwhen the file has them. Words are lower-cased and put in NFC, and a form listed more than once keeps its first row or, under a registry entry’sduplicates: sum, the sum of its rows’ frequencies, as Lexique needs. A message counts the derived values that lay within 1e-9 of a rounding boundary, the one step at which the two engines’log10()could round a value apart, and a run over a lexicon carryingfreq_permillionrecords the count in its run log and asmaterials_source$zipf_derivation, a field of datasheet format 1.2.fetch_corpus("subtlex_us")downloads SUBTLEX-US from openlexicon, checks its SHA-256 and normalises it in either engine. No registered corpus could be fetched before. An entry may also name aderivedfile, a lexicon already normalised, which is fetched in its place. The registry header describes this route where it said that the column maps were not yet applied. Lexique 3.83 stays a listed entry: the manual confirms that it splits a homograph’s frequency between rows, so the entry recordsduplicates: sum, but the forms its film corpus never attests carry a frequency of 0, which has no Zipf value.load_lexicon()refuses Zipf values outside 0 to 9, which are frequencies per million in the wrong column. A log count such as SUBTLEX-US’sLg10WFlies inside that range and must still be converted.fetch_corpus()refuses a corpus name containing a path before it reads the registry, so an edited registry cannot make it write outside the cache. Its refusals name the registry they read and say when an entry registers no download address, and a wordfreq language code now gets an explanation that its lexicon is built by the Python package (lexsync fetch fr) and read here withload_lexicon(), where the refusal said the language was not in the registry.A
masked_primingparadigm presents a forward mask of hash marks for 500 ms, a lower-case prime for three frames and an upper-case target that stays until the response, the procedure of Forster and Davis (1984). Without a forward mask, a prime this brief is usually visible. The target collects its response from its onset and stays on screen for the whole 2,000 ms window. A text event’scase: upperorcase: loweris applied by the runners when they display it, withstr.upper()andstr.lower()in PsychoPy and OpenSesame andtoUpperCase()andtoLowerCase()in jsPsych, so the loop tables and the data files keep each item as it was written. lexsync does not change the case itself, becausetoupper()leaves the German sharp s unchanged where Python turns it into two letters. A mask’slength_ofnames the fields whose longest value sets its length. Its one character is repeated as many times as that value has characters, not bytes, counted over every trial when the experiments are written, so each target and each list shows the same mask.validate_design()refuses a case other than upper or lower and acaseon any event but a text event. It also refuseslength_ofon any event but a mask and on a mask whose content is not one character.export_experiments()refuses alength_offield the trials do not have. The two keys join the design vocabulary in both engines, whose suites pin the new digest, and the schema’s comments describe them. Theprimingparadigm is unchanged and is documented as backward-masked, as is its label in the demos index. The registry is pinned by a digest that both suites compute, so a paradigm changed in one engine only fails both. Every demonstration’s experiments, datasheets and run logs change, because the PsychoPy and jsPsych templates do. The twin-edge casemasked_priming_textpins both engines to one outcome.The
en_rastle_reproexample uses masked priming with nonword targets, anden_andrews_reprogains pseudowords, so both run as lexical-decision tasks.en_rastle_reprointerleaves twelve nonword targets, each with a word prime, as fillers (items/rastle_nonword_fillers_en.csv), anden_andrews_reproone pseudoword per selected word (items/andrews_pseudoword_fillers_en.csv). The nonwords were made once with the package’s letter-substitution generator and checked against wordfreq’s known English forms, and each design’s comment gives the command and lists what still differs from the original study. Each design puts its word conditions on the left arrow key, gives its fillers the right and shows its own instructions. Their stimuli files gain ablockcolumn and their trials are renumbered, since the fillers are interleaved with the words. The items, sets, lists and conditions are the same, and no descriptives or comparisons change.Generated experiments show task instructions before the first trial and wait for the space bar, so the experimenter can start the run when the participant is ready. A design sets the text with
instructions. It may hold line breaks and no other control character, and every target carries it as data. Without it, a lexical decision, a categorisation and a design with comprehension questions get English instructions that say what their keys mean. The run log notes a design in another language that gives no text of its own, and a design whose keys the instructions can only name, as in every factorial and priming demonstration. A lexical-decision design states its response mapping: word on the response event’s first key and pseudoword on its second, unless the design givesresponse_mapping, androtate_mapping_by_list: truerotates the keys across lists. A mapping that names a key the response event does not allow, or leaves a condition’s trials without an answer, is refused. The loop tables carry each trial’s correct key inanswer, so accuracy can be scored on every trial of a lexical decision, where it could be scored only on practice and filler trials. jsPsych records a key by the browser’s name for it, so the jsPsych page names each answer that way,arrowleftforleft, as it already named the keys, and in every target a correct response equals its trial’s answer.export_experiments()derives the answers as it assigns the trigger codes, so the stimuli file does not change. A practice or filler table keeps its own answers, and the run log notes any that the mapping contradicts. The datasheet records the mapping aspresentation$response_mapping, a field of datasheet format 1.2, and its Methods text says which key the participants pressed for which condition. The schema’s comments describe the three keys. Every demonstration’s experiments, datasheets and run logs change, and the loop tables of the three lexical decisions carry an answer on every trial. The twin-edge casesmapping_rotatedandinstructions_textpin both engines to one outcome. No design selects different items.The lexical-decision practice table no longer presents the English word ‘plank’ as a pseudoword. Its replacement, ‘plang’, came from the package’s own generator and is not among wordfreq’s English forms, and
design_en_lexdec_blocks.yamlrecords the command. The categorisation example adds 16 non-member foils as fillers (items/categorisation_foils_en.csv), nouns that do not belong to the category they appear under, so pressing the ‘yes’ key on every trial no longer scores perfectly. Each list now holds 16 ‘yes’ and 16 ‘no’ trials. The foils are interleaved with the members, so the example’s stimuli file gains ablockcolumn and its trials are renumbered, while the same items stay in the same lists and conditions.The PsychoPy and jsPsych experiments paused wherever the block changed. Fillers are interleaved with the main trials so that nothing sets them apart, yet a pause came before and after every filler, and each pause after one said that the practice had ended. Fillers now run with the main trials, and the one pause comes where the practice trials end, in
design_en_lexdec_blocks.yamland in the categorisation example, whose foils are fillers.CITATION.cffandcodemeta.json, which describe the repository and so both packages, give the Zenodo concept DOI, which archives every GitHub release of both, in place of the DOI that CRAN assigned to the R package. They list each package’s identifiers, marked with the package they name.citation("lexsync")is unchanged and gives the CRAN DOI.citation("lexsync")names its publisher in full, “Comprehensive R Archive Network (CRAN)”, where it said “CRAN”, and the About page’s reference and BibTeX entry and the R reference incodemeta.jsonnow name it too. Every reference across the package family and the author’s website now names CRAN in the same words.The CRAN DOI and the Zenodo DOI are shown as full links on the website and in the README. The README gains a badge for the DOI CRAN assigned, for GitHub and CRAN, which the home page leaves out of its sidebar, where the DOI is already given under Citation.
28 of the committed datasheets recorded the SHA-256 of a copy of
corpora/derived/en.csvores.csvwhose line endings one machine had converted to CRLF, so the digest could not be reproduced from a checkout and read as altered materials. Two of them also recorded such a copy ofconfig/design_en_priming_continuous.yaml. The datasheets are regenerated from the committed bytes, and a test in both engines recomputes every recorded digest, the design and schema digests included. No stimulus, report table or experiment changes. The run logs of the 14 designs concerned change with their datasheets, whose MD5 they record.Generated OpenSesame experiments no longer pass stimulus text through OpenSesame’s variable store, which evaluates it. In OpenSesame 4.x, a loop cell containing
{runs as a Python f-string, and in 3.3 a leading=or[=...]runs Python, so an item table shared with a laboratory could execute code on the machine that presents it. Ordinary words in the English lexicon, such as ‘infinity’ and ‘nan’, could stop an OpenSesame 4.x session, though no demonstration design selected one. The loop table now holds only numbers:lexsync_row, the trial’s position in loop order, withlexsync_trial,lexsync_list,lexsync_set, the trigger codes,critical_regionand any per-trial duration. The prefix stops the columns from shadowing Python’s built-inlistandsetin OpenSesame 4’s inline scripts. The inline scripts read the presented text,condition,blockandanswerfrom a companion file,<base>_opensesame_text.csv, to be copied with the experiment.export_opensesame()refuses a value in one of the numeric columns that is not a plain number, naming the column. A practice timeout is reported as ‘Too slow’. Both versions scored it as a wrong key, because the response came back as the string ‘None’. The 0.1.0 notes said stimulus text was always safe. That held for PsychoPy and jsPsych but not for OpenSesame. Every demonstration’s.osexpand OpenSesame loop table change, and each demonstration gains a text table. The datasheets and run logs change with them, since they record the experiment’s checksum. No stimulus moves.The Shiny app writes an edit to the conditions table into the cell that was edited. Every edit landed one column to the left, because
DT::editData()was called withoutrownames = FALSE, so a custom condition could not be entered. The bounds of the second factor, which the one-factor presets leave empty, are now numeric columns, so a bound typed there is kept as a number. They were logical columns, on which DT warned and stored the typed text.The R engine no longer reads a design key by partial matching. R’s
$accepts the start of a name when no name matches in full, so a key that began another key’s name stood in for that key when it was absent.font_size: 40set the font of all three generated experiments to ‘40’,language_tagserved aslanguage, and an annotation namedcontinuous_notesmade a factorial design fail. Design, schema and corpus-registry entries are now read by exact name, so a design carryingfont_sizeorcontinuous_notesruns as it does in Python, and one that giveslanguage_tagwithoutlanguagestops, as it does in Python. The Python engine was never affected.Numbers in the Markdown datasheet and the Methods paragraph are formatted by lexsync itself, not by each language’s printf, which rounds decimal ties differently: R on Windows printed 0.0825 as 0.082 where Python printed 0.083. A displayed value is rounded half away from zero from the value the JSON record stores, so a statistic stored as 0.745 now shows as 0.75 in both engines, where both printed 0.74. Some two-place cells in the committed datasheets of 10 designs change for this reason, and the Methods paragraph of
es_freqcontrastnow gives the interval as [-0.19, 0.34]. A cell in which the R datasheet ofen_andrews_reprodisagreed with Python’s (-0.46 against -0.47) now agrees. The TOST p column is printed to four places in both engines, and a p that rounds to zero is shown as < 0.0001. R wrote such a column as 9e-04 and 1, Python as 0.0009 and 1.0. No JSON record, stimulus, report table or experiment changes. The run logs of the 10 designs change with their datasheets, whose MD5 they record.Both engines read CSV input by one rule. Header names and fields are trimmed, and an empty field or
NAis missing. A column is numeric only when every value is a plain decimal number (no thousands separators), logical only when every value is TRUE or FALSE (in upper, lower or title case), and text otherwise. The Python engine did not trim, so a padded category or header made it refuse a design the R engine ran. The R engine guessed types from the first 1,000 rows and readTandFas logical,1,000as a number and ISO dates as dates, so a pass-through column could be writtenFby one engine andFALSEby the other. A header that names a column twice is now refused, an empty header cell is named by its position (...3), andload_lexicon()refuses a frequency column that is not numeric, naming the first value that is not a plain number. The R engine parses every plain decimal number to the nearest double, as Python’sfloat()does, because readr’s parser could miss it by one unit in the last place (50.768212762897100, for instance). A number of up to 15 significant digits, with a power of ten within 10^22, takes one exact operation, and any other is rounded in exact integer arithmetic, becauseas.numeric()misses as well, now and then on x86-64 and far more often on Apple Silicon, where R CMD check read0.12345678901234567one unit away. R wrote a negative zero as-0and Python as0, so both engines now read it as zero. Every demonstration’s stimuli and reports are unchanged.Changes list assignment: a paradigm’s Latin-square recipe now applies when a design lists its own
events. Anyevents:list madecounterbalance()deal the items factorially, so a priming, categorisation or self-paced reading design with per-trial timing showed every target in both conditions of each list (inen_priming_jitter, six targets per list, each twice), while the datasheet and the Methods text described a Latin square.counterbalance.recipe(factorialorlatin_square_target) overrides the paradigm’s recipe, andcounterbalance.optimiseis now refused for these designs, as for every Latin-square design. Of the shipped examples onlyen_priming_jittermoves: each of its two lists now shows all twelve targets once, and its stimuli, experiments and datasheets are regenerated.The datasheet, now format 1.2, records the counterbalancing that ran. It takes the recipe from the resolver that
counterbalance()dispatches on, and no longer infers it from the item source, which recorded a table-sourced lexical-decision design as a Latin square. It records the lists the counterbalancing realised, in place ofcounterbalance.listswith a default of 1, and gives per-list cell counts. An incomplete Latin-square rotation (a single list, or lists or item sets not a multiple of the number of conditions) is reported in the run log and the datasheet and described as incomplete in the Methods text, andcounterbalance.incomplete: error, in the design or the schema, refuses it. A factorial design asking for more lists than item sets is refused, because the surplus lists would be empty. Every committed datasheet and run log is regenerated, and no other stimulus moves.The
en_priming_jitterexample names its per-trial columnprime_ms. It was calledsoa_ms, but a 33 ms mask follows the prime, so the stimulus-onset asynchrony isprime_msplus 33 ms.Designs are checked before anything is selected or written, by the new
validate_design(), whichrun_pipeline()calls right after reading the design and the schema. A key one edit from a known key of up to five characters, or two edits from a longer one, is refused as a probable typo and named with the key it was probably meant to be (n_per_conditonsilently gave the default 20 items). Any other unknown key is reported in the run log and will be refused in a later release, so annotations belong indescriptionor a key beginningx_. A missing or non-textnameorlanguage, an unsafe name or font, a bad trigger address or response key, a non-text or repeated condition name and a missing or emptymatch_onare refused with one message in both engines. They used to fail at export, after four files had been written, or at different points in each engine, and withoutmatch_onthis engine paired the alphabetically first words.The R engine reads an unquoted
yornin a design as text, as the Python engine does. It read them as TRUE and FALSE, sokeys: [y, n]generated experiments that waited for keys named TRUE and FALSE, which no participant can press. A YAML boolean given as a response key (an unquotedyes,no,onoroff) is refused with a hint to quote it, andvalidate_design()refuses acounterbalance.optimisethat is nottrueorfalse, sinceoptimise: nor1balanced the lists in the Python engine only. The twin-edge casesyn_keys,yes_no_keys,optimise_nandlanguage_nopin both engines to these outcomes.A norms entry’s join key is written
join_on:. Both engines’ YAML readers took the documentedon:for the boolean true, so every join silently usedword. A bareon:is now read as the join key, with a note in the run log, and any other key in a norms entry is refused, so a misspeltjoin_onno longer falls back toword. An entry that is not a mapping, such as a bare file name, is refused as needing apath, where it used to stop the run with R’s own error. This engine also joined each entry of anorms:block written as a mapping of named entries, such asconc: {path: norms.csv}, which the Python engine refused. Both engines now refuse it and nameconcas an unknown key. The datasheet still records the key ason. The schema’s comment now showsjoin_on:. The 42 committed datasheets record the schema’s SHA-256 and the run logs record each datasheet’s MD5, so both are regenerated. No stimulus, report table or experiment changes. The twin-edge casenorms_on_barepins the join onlemma.n_per_conditionis resolved by one rule everywhere. A whole number written as4.0is accepted, and 0 is refused bymatch_stimuli()andselect_continuous_stimuli(), which failed here with an obscure error and returned their default size in Python. Anulltolerance is read asmodifyList()reads it in both engines, and a schema without amatching:block works in Python.A design whose name or language has non-ASCII characters writes its files under a stem with an eight-character checksum suffix, given by the new
design_stem(), so two designs with Chinese names in the same language no longer overwrite each other’s files and an all-Chinese name no longer gives files whose names begin with a dot or underscore.run_all()refuses two designs that would write the same files, such asen-lexdecanden_lexdec.The schema gains comments on the key check and on a
nulltolerance, so the 42 committed datasheets, which record its SHA-256, and the run logs, which record the datasheets’ MD5, are regenerated. No stimulus, report table or experiment changes.Both engines normalise text keys to Unicode NFC. Lexicon and pool words and norm-table and pair-member keys are normalised after the trimming and case-folding they already received. The item id, condition and presented fields of an item table, practice and filler tables included, and a
reference_wordslist passed torun_pipeline()are normalised as read. The two encodings of an accented letter, precomposed or with a combining accent, used to make two words of one. Both could then be selected, into opposite conditions of one matched set and with different lengths, and a norm or member join missed whichever spelling the lexicon did not use. De-duplication keeps the first row in file order. When normalisation rewrites or merges any form, the run log says how many, and the datasheet records the counts asmaterials_source$input_normalisation, a field of datasheet format 1.2. Every demonstration’s output is unchanged, and the twin-edge casenfd_poolpins the new outcome.Matching excludes candidates that lack a matched dimension, in every method and before anything is selected, and the run log and the datasheet record how many each condition lost (
selection.incomplete_dropped, a 1.2 field present only when a condition lost any). An anchor item missing a matched value used to be paired with the alphabetically first candidate in its window, or to re-pick a used candidate and stop the run with a false ‘overlapping conditions’ error.jointfilled a shortfall with incomplete pairs,optimalfailed with a solver error that differed between the engines, andselect_continuous_stimuli()selected items without the predictor (4 of 10 when 40% of the pool lacked it). The documentation already promised the exclusion. The shortfall policy applies to what remains, a condition left without a complete candidate is refused by name, and a word repeated within one condition, which used to be reported as overlapping conditions, is reported as an internal error. The datasheet’s verdict on whether the pairwise cap fired, and the Methods text’s count of eligible candidates, leave the excluded candidates out. For a design whose pool has such candidates the selection changes, a breaking change under the package’s versioning rule. No shipped design has one, so no stimulus, report table or experiment moves. The schema’s note on norm coverage, corrected in all three copies, changesconfig/schema.yaml, so every committed datasheet records a new schema digest and every run log a new datasheet checksum.resample_stimuli()records the candidates its first replicate excluded for a missing matched value, those of the whole pool, so the run log and the datasheet of a resampled design report them as well. Its"audit"attribute had been that of the first replicate in R, and in Python it was present only when all the replicates held the same one. A selection no longer passes on an audit its pool already carried. The Python engine did so whatever the method, and the R engine inselect_continuous_stimuli().mahalanobismatching estimates the correlations from the rows complete on the matched dimensions, and refuses a pool with fewer than k + 2 of them for k dimensions. A dimension with any missing value used to lose all its correlations and enter the distance as an independent term.Joint matching on a dimension with missing values over more than 1,200 candidates failed in R (‘missing value where TRUE/FALSE needed’) and ran in Python.
.exact_sum()now returnsNAfor such input, as the Python engine’s sum returns NaN. It also returns NaN where opposite infinities make the running sum NaN, a case in which it stopped in the same way.Generated experiments present one counterbalancing version to each participant. The PsychoPy, OpenSesame and jsPsych experiments used to present every list in one session, interleaved, so in a Latin-square design each target appeared in both conditions (in
en_priming, all 12 targets twice, three of them on consecutive trials), and in a resampled design every participant saw all the replicates. The 0.1.0 notes said that a categorisation participant never sees the same target twice. That held within a list only. A version is one (replicate, list) pair of the presented trials, numbered from 1 in that order, and participant p runs version ((p - 1) mod V) + 1 of V, the ruleparticipant_table()uses. The PsychoPy script reads--participantand--version, or--listwithout replicates, from its command line. Launched with neither, it asks for them in a dialogue, which a design with one version also shows and runs on blank answers. It stops on a version it does not have. The jsPsych page reads?participant=,?version=or?list=from its address, and a page with several versions opened without one says how to open it and runs nothing. Every page names its data file after the participant and the version, so a page with one version saves<title>_v1.csv. The OpenSesame target writes one experiment per version (<base>_list<k>.osexp, or<base>_rep<r>_list<k>.osexpwith replicates), each with its own tables, and each warns when the subject number belongs to another version. A design with one list and no replicates keeps its file names.export_experiments()lists every file it wrote asfiles, beside the three paths it returned before, sounlist()of its result now holds each script twice. The exporters refuse alistorreplicatevalue that is not a whole number, read by one rule in both engines, since the versions are numbered by those values. In a resampled design with practice or filler trials, each version has its own copy of them, and fillers no longer stop such a design with an error about a missing hash-key component. The loop tables carryreplicateand keep each version’s trials together. The datasheet recordscounterbalancing.versionsandcounterbalancing.assignment, and its Methods text says that each participant was presented one version. Its artefact list and the run log include the loop and text tables, which hold the trial order and the trigger codes. Every demonstration’s experiments, datasheets and run logs change, and the seven multi-list designs anden_resamplehave one OpenSesame experiment per version in place of<base>.osexp. No stimulus changes.A design can refer to a derived dimension (
n_density,old20,bigram_freq) in a condition’sdefine_by, inpool_filters, as a continuous predictor or control, or incounterbalance.balance_on, as well as inmatch_on. When the lexicon lacks the dimension,run_pipeline()computes it against the lexicon, after the filters the lexicon can answer and before those on the derived dimension. A high- against low-bigram contrast, a continuous design withbigram_freqas its predictor, or an N design on a lexicon built bylexsync fetchused to fail with ‘absent from the pool’. Two twin-edge cases,bigram_contrastandfetched_es_ndensity, pin the result in both engines.The
reference_wordsargument ofrun_pipeline()takes effect. For a corpus or pool design, the pipeline recomputes N and OLD20, and bigram frequency where the lexicon carries it or the design uses it, against the supplied list, overriding the lexicon’s columns, and logs the step. The datasheet’sselection$neighbourhood_referencegainsapplied_to, the columns recomputed, and is written only when the list was applied. Every bundled lexicon carries N and OLD20, so the argument was ignored while the datasheet recorded it as applied. A generate or table design does not use the argument, and its run log now says that it was ignored. A list that is empty or holds a missing value is refused, since it would set every N to 0 and every OLD20 to missing. A lexicon may mark the forms that make up the neighbourhood reference with areference_membercolumn of 0 and 1, whichload_pool()honours too.The schema documents
reference_memberand when the derived dimensions are computed, so the committed datasheets, which record its SHA-256, and the run logs, which record the datasheets’ MD5, are regenerated. No stimulus, report table or experiment changes.items.generation.excludelists further word forms that a generated pseudoword must not equal: files with one form per line, CSV files with awordcolumn, or corpora fetched into the cache withfetch_corpus(). The forms are normalised as a lexicon’s words are, and the datasheet records each list with its checksum asselection$generation_exclude, a field of datasheet format 1.2. The Methods paragraph names the lists. The generators checked only the lexicon, whose English and Spanish forms stop at Zipf 2.95 and 3.11, so real words such as crier and pall, and in Spanish renos and esteras, passed as pseudowords.generate_pseudowords()andbuild_lexdec_stimuli()take the list asexclude. The repository’scorpora/fetch_corpora.py --known-formswrites wordfreq’s fuller word lists for this purpose (289,023 English and 334,625 Spanish forms), and neither package ships them. No shipped design uses the key yet, so no stimulus moves. The schema’s comments describe it, so the 42 committed datasheets, which record the schema’s SHA-256, and their run logs are regenerated.A generated lexical-decision design reports the number of words the generator could use. It reported the band before the a-z filter, 8,305 words where 6,937 were eligible in a Spanish band of length 4-7 and Zipf 3.5-6. The English demonstrations’ counts do not change, since their bands hold only a-z words.
Lexical-decision generation works with accented letters. The generators’ alphabet is the set of letters the reference lexicon attests, in UTF-8 byte order. A Spanish design therefore keeps its accented words, 16.5% of a typical band (1,368 of the 8,305 words of length 4-7 and Zipf 3.5-6, among them 261 preterites in
-ó), and can generate accented pseudowords. The generators substituted only the letters a to z and left those words out. A word is now eligible when every character in it is a letter the reference attests, so a word with a hyphen, an apostrophe or a digit is still left out. The subsyllabic segmenter finds its nuclei among accented vowels too, andmake_pseudoword()gains analphabetargument. A word in a logographic script, such as Chinese, is refused with a message. Unicode counts its characters as letters, but a form made by swapping one for another is of unknown validity as a pseudoword. A generated design’s datasheet counts the eligible words, so the Spanish band above now counts all 8,305. A new example,es_lexdec, checks its pseudowords against wordfreq’s Spanish word list. English selections and every other committed output are unchanged. The twin-edge casegeneration_excludegains a hyphenated word, which the count leaves out, and its known-form list now names the forms its new selection would otherwise produce. The new caselexdec_logographicpins the refusal in both engines.matching.tie_breakchooses how exact ties between equally close candidates are broken.bytesbreaks them by spelling, as lexsync 0.1 did, and reproduces its selections.hashbreaks them by a keyed SHA-256 of each word (saltlexsync-tie-v1), which favours no part of the alphabet and does not depend on the seed. Ties decide most picks because lengths and neighbourhood counts are whole numbers and frequencies have two decimals. Inen_freqcontrast, 71 of the 80 anchored picks had another candidate at the chosen distance, and breaking them by spelling filled conditions with early-alphabet words. In its low-frequency condition, 61% of the words began with a or b, against 13% of the candidates, and inen_ndensity‘s dense condition and theen_andrews_reprolow-frequency cells every word did, an initial-letter confound the realised-control report cannot see. Underhash, the low-frequency share fell to 12.5% and the dense share to 17.5%, against 13% and 12% among the candidates, and Cohen’s d on every matched dimension of every shipped matched design was unchanged. The rule decides the anchored picks, the order of tied pairs injointandoptimalmatching and the rows tied at their candidate cap. The even spreads that pick the anchor’s items and a continuous design’s items keep the byte order. Under either rule,standardised_euclideanandjointselect identical items in the two engines. The datasheet records the rule asselection$tie_break, a 1.2 field, with the salt underhash. Its Markdown no longer says that selection is deterministic given the seed and free of item-selection bias. Selection does not depend on the seed, which fixes the trial order, and the Markdown and the Methods paragraph now say so. The Methods sentence that read ’deterministic and reproducible (seed 2026; lexsync 0.1.1)’ now reads ‘deterministic and reproducible (lexsync 0.1.1), and the seed (2026) fixes the trial order’. The schema gainstie_break, and the twin-edge casetie_hash_smallpins thehashrule in both engines.count_syllables()counts the vowels of Polish, Czech, Hungarian, Romanian, Turkish, Danish, Norwegian and Vietnamese (ae, oe, the dotless i and the vowels with an ogonek, caron, breve, macron, ring or double acute), and returnsNAfor a word in another script, where it returned 0. It now decomposes a word and sets its diacritics aside, so a vowel counts whatever its accent. Every Chinese, Russian or Greek word counted 0 syllables, so a design could match onn_syllablesand report perfect balance.run_pipeline()refuses a design that refers ton_syllableswhen a word without it passes the other filters the lexicon can answer. The documentation said the vowel class covered the Latin-1 vowels, but it lacked ae. Then_syllablescolumn ofzh_freqcontrast’s stimuli is now empty, and its datasheets and run logs change with it. No selection changes.Responses are collected from the onset of the stimulus they answer, and every target records them. The response window used to open only when the target had gone, 800 ms after its onset in lexical decision, and each target discarded a key pressed while the target was on screen: jsPsych showed the target with no keys allowed, PsychoPy’s
waitKeys()cleared earlier presses and OpenSesame’s keyboard item flushed them. jsPsych timed the reaction from the blank response screen, and the PsychoPy and OpenSesame targets recorded no response at all, so no accuracy, reaction time or reading time reached a data file. The 0.1.0 notes said that a categorisation design’s data were scoreable. That held for jsPsych only. A response event after a text event now opens its window at that stimulus’s onset, for the same total window, and a response ends the trial.from: offseton a response event keeps the delayed window, which is also the default after any other event, andfrom: onsetafter such an event is refused. A response collected from the onset runs where its stimulus runs, so a design that gives the two differentblocksis refused. The PsychoPy script writesdata/<base>_p<participant>_v<version>_<date>-<time>.csv, one row per trial, written as it goes, with the response, its reaction time from the stimulus’s onset, timeouts, accuracy when the item names its answer, self-paced reading times, the question’s response, and each event’s realised flips and onset. The OpenSesame experiment collects the response in the stimulus’s own script, clears the response variables as each trial starts and logs every trial with a logger item. The jsPsych page saves its CSV as before, and its target is now one trial whosertruns from the onset. The datasheet records each response window aspresentation.response_windows, says inanalysis.responsewhere a reaction time starts, and its Methods text says when responses were collected. The R engine writes a response key given alone as a list of one key, as the Python engine does. jsonlite wrote it as a bare string, and the PsychoPy runner split that into letters. Every demonstration’s experiments, datasheets and run logs change. No stimulus moves.OpenSesame response and question timeouts are written in whole milliseconds as declared. A timeout of 1,001 ms was written as 1,000, because the value went through seconds and back, and 95 of the whole-millisecond values from 1 to 10,000 came out a millisecond short. The demonstrations’ timeouts were not affected.
A screened English lexicon,
corpora/derived/en_v2.csv, holds the same 30,000 wordfreq forms asen.csvwith columns from SUBTLEX-US (Brysbaert & New, 2009):in_subtlex_us,lowercase_shareandnonstandard, which marks forms absent from SUBTLEX-US or beginning with a lower-case letter in under a tenth of their uses (names, acronyms and many internet forms, 6,565 in all). Its N and OLD20 are computed against the 23,435 unmarked forms only, so the marked forms no longer count as neighbours (cathas 21 whereen.csvgives 32), andn_density_rawandold20_rawkeep the old values. Filter withpool_filters: {nonstandard: [0, 0]}.en.csvis unchanged, so designs that must reproduce earlier selections keep working. The repository’scorpora/fetch_corpora.py --screen-enrebuilds the file byte for byte from a SUBTLEX-US copy whose checksum it verifies, andcorpora/ATTRIBUTION.mdandLICENSE-DATAcredit SUBTLEX-US as its direct source. This is repository data, and the package example slices follow later.A design can exclude words with
exclude_words, a list or a file with one word per line, applied before the pool filters and recorded in the datasheet with its checksum. The words are keyed as a lexicon’s are and leave the candidates of a corpus, pool or generated design after the norms join. The neighbourhood reference, and the words a generated pseudoword must not equal, keep them. The datasheet records the list asmaterials_source$exclusions(its path orinline, checksum, words listed and candidates removed), a field of datasheet format 1.2, the run log notes the removal, and the Methods paragraph reports it. A design with an item table refuses the key. The twin-edge caseexclude_words_inlinepins both engines to one outcome.The datasheet and the Methods text credit wordfreq only for the lexica lexsync ships. A user’s own lexicon was described as wordfreq data, so a study selecting from SUBTLEX would have cited the wrong corpus. It is now named by its file name and checksum, and a corpus fetched into the cache by the registry’s citation. A shipped lexicon is recognised by its checksum with CRLF line endings read as LF, and the credit for
en_v2.csvstates its screening rule. The shipped designs’ credits do not change, and the twin-edge caseuser_lexicon_provenancepins both engines. The schema’s comments describe the new key and columns, so the 42 committed datasheets, which record the schema’s SHA-256, and their run logs are regenerated. No stimulus, report table or experiment changes.The documentation now states what the lexica derived from
wordfreqcontain: proper names, acronyms and other non-standard forms among the most frequent forms, spread unevenly over frequency bands, with N and OLD20 computed over them. It says that a continuous design spreads items over the predictor’s ranks and not over its range, so the selection follows the pool’s distribution and the tolerance windows trim the controls’ tails without holding them constant, which the 0.1.0 entry forselect_continuous_stimuli()misstated. It also says that generated pseudowords are more bigram-typical than their base words, that theprimingparadigm is backward-masked, thaten_rastle_reproanden_andrews_reproreproduce item selection and not a lexical-decision task, and how each presentation target times its events.The corpus registry no longer maps
freq_zipfto the SUBTLEX-US and SUBTLEX-DE log-count columns or to a column that the Chinese SUBTLEX file does not contain. It says that its column maps are not yet applied and that no entry can yet be downloaded byfetch_corpus(), and DESCRIPTION names what the corpus access covers in place of calling it many-language.The documentation of
cohens_d_ci()andtost_equiv()cites Sassenhagen and Alday (2016) and Lakens (2017) for what they argue, and calls the 0.5 bound a convention. The quick start in the README and the Get started vignette uses 60 items per condition, as the Python twin’s does, in place of 15: at the default bound and alpha, no dimension that varies can pass the equivalence test with fewer than 23 items per condition. No stimulus, report table or experiment changes. Every JSON datasheet records the new schema digest, the five demonstrations whose design files were corrected record new design digests, and the Markdown datasheets of the four whose descriptions changed (en_andrews_repro,en_freqcontinuous,en_priming_continuousanden_rastle_repro) change with them, as do the run logs.optimalmatching minimises the summed distance of the n pairs it selects. It solved a complete assignment over both candidate pools and kept the n cheapest assigned pairs, which is not the optimum and was consistently worse than greedyjoint: 1.15 to 2.5 times the optimal summed distance in tests wherejointcame within 3.4%. It now solves the cardinality-constrained problem exactly with a padded assignment (Volgenant, 2004). In R a problem at the 1,200-candidate cap takes about 4 s, where the complete assignment took 34 s on the same machine. Both capped pools are put in tie-break order before the solve, by spelling underbytesas well as by key underhash, so the solver’s choice among equally cheap pairs no longer depends on the order in which the rows arrive. When the conditions overlap, a word cannot be paired with itself or used twice. A pair the solve loses that way is replaced by solving again over the unused words, and the selection is then no longer guaranteed to have the least summed distance. Designs usingoptimalselect different items, a breaking change under the package’s versioning rule. No shipped design uses it, so no stimulus moves. The schema’s description of the method, corrected in all three copies, changesconfig/schema.yaml, so every committed datasheet records a new schema digest and every run log a new datasheet checksum.run_pipeline()andrun_all()fall back to the schema bundled with the package when none is named and none is found beside the designs, as the Python command line already did, so the one-call pipeline works outside a clone. Both now default toschema_path = NULL. Before the bundled copy,run_pipeline()looks forconfig/schema.yamland then forschema.yamlbeside the design, andrun_all()looks inconfig_dirand then inconfig/. The first place each function looks is its old default, so a call that worked before reads the same file. A message names the schema chosen, and the run log (schema_path) and the datasheet (reproducibility$schema_path) record its path, so the choice is visible even when messages are suppressed. Every committed datasheet and run log gains that field. A named schema that does not exist is still an error, and the message gives the bundled copy’s location.The realised-control report covers every dimension lexsync knows for the items, not only the standard four and
match_on: syllables, bigram frequency, every joined norm column and everydefine_bydimension, each with a role (manipulated, controlled or uncontrolled), and it compares every pair of conditions, the anchor rows first. Uncontrolled differences used to go unreported while the Methods text called the control close: inen_ndensitythe dense words had fewer estimated syllables than the sparse ones (d = -0.83), and a norm used to define the conditions did not appear in the report at all. Each comparison also gives the raw mean difference in the dimension’s own unit (mean_diff). The comparisons gain rows, and two columns,mean_diffandrole, after the existing ones. A dimension is manipulated in a pair whosedefine_bywindows on it differ, so inen_andrews_reprofrequency is uncontrolled between the two high-frequency cells.match_report()takes the roles asroles, a function of the condition, reference and dimension thatrun_pipeline()derives from the design, and leaves out a dimension with no numeric value, such asn_syllablesfor Chinese. Bigram frequency is computed for the report on a copy of the stimuli, so no stimuli file or selection changes. The datasheet’srealised_controlentries gaincondition,referenceandmean_diff, fields of datasheet format 1.2, and their roles replace ‘manipulated/free’, the label every unmatched dimension was given. Both apps show each row’s role and raw difference and label each bar of the chart by dimension and pair. The comparisons, datasheets and run logs of the eleven demonstrations that match conditions change, and so do the descriptives of all of them buten_richdim, whose report had syllables and bigram frequency already. The twin-edge cases’ pinned reports are regenerated, and the new casefour_conditionspins the pairs and roles in both engines.A generated lexical-decision design reports bigram frequency, N and OLD20 for the words against the pseudowords, marked uncontrolled. Its stimuli give the base word of each pseudoword and the edit distance from it, as the
baseandedit_distancecolumnsbuild_lexdec_stimuli()now returns. Its pseudowords were more bigram-typical than their words (d about -1.5 inen_lexdec), which no report showed. The reports, datasheets and run logs of the three lexical-decision demonstrations change and their stimuli gain the two columns, while no pseudoword, trial order or experiment changes.A continuous design’s report gives the predictor’s SD against that of as many values spread evenly over its range, each control’s SD against the pool’s, and the control windows (
sd,sd_ratio,window_lowandwindow_high). An even spread has a predictor ratio of 1 whatever its size. Inen_freqcontinuous, the frequency SD is 0.60 of that of an even spread.select_continuous_stimuli()records the windows and the pool’s SDs as thecontrol_windowsentry of its"audit"attribute.match_report_continuous()reads them there, or takes them asaudit, and the datasheet’s continuous entries gain the four fields, in datasheet format 1.2. The comparisons, datasheets and run logs ofen_freqcontinuousanden_priming_continuouschange.cohens_d()andcohens_d_ci()return NA when a condition has fewer than two items, where they returned 0, a perfect balance. The Python pipeline no longer fails while formatting the missing interval. The twin-edge caseshortfall_onepins the result in both engines.The Methods text reports the realised control descriptively: the largest difference on a matched dimension, in its own unit and with its pair, d and interval, and then the equivalence tests as a secondary check that says when the number of items is too small for them ever to pass (fewer than 23 per condition at the default bound). It called the control close, or not confirmed, on the tests alone. It names, largest first, up to five comparisons of an uncontrolled dimension that differ by more than the bound, and a generated lexical decision says how its words and pseudowords differ in bigram frequency, N and OLD20. It states the number of items each condition holds, with the request beside it when a
shortfall: allowrun fell short, where it stated the request. It labels the interval by the configured alpha, where it always said 90%, and for a continuous design it reports the realised spreads, the control windows and any widened window in place of ‘near-constant’. The Markdown table of the realised control gains the pair and the raw difference of each row. The datasheet records the items each condition holds asitems$n_per_condition_realised, a field of datasheet format 1.2, anddesign$n_per_conditiongives the request the selector applied, its default when the design gives none, where it was null. The datasheets and run logs of every demonstration change, and no stimulus, report table or experiment does. The twin-edge caseshortfall_onepins the new fields.The suggested model uses the stimuli’s
conditioncolumn, where a lexical decision named alexicalitycolumn the stimuli do not have. It adds a matched design’s matched dimensions as centred covariates on the stimuli file’s own columns (Sassenhagen & Alday, 2016), leaving out one that does not vary among the items. It gives items a random slope for condition whenever the counterbalancing recipe rotates the conditions over the items by a Latin square (the categorisation design had none), crosses the condition with the predictor in a continuous pair design, with by-subject slopes for both and their interaction, and says how to recode a crossed design into its factors. Its note calls the random-effects structure maximal (Barr et al., 2013) only when it is, and describes the slopes of a design whose covariates or controls enter as fixed effects only.Exact ties between equally close candidates are broken by the keyed hash by default (
matching.tie_break: hash), andtie_break: bytesreproduces the selections of lexsync 0.1. Every design whose matched dimensions tie selects different items, a breaking change under the package’s versioning rule, and a design or schema that namesbytesselects what it did. The schema setstie_break: hashin all three copies. Of the shipped designs, the Spanish and Chinese ones (es_freqcontrast,es_ndensity,es_gender_reproandzh_freqcontrast) change their stimuli, reports, experiments and datasheets for this reason alone, and the English ones for the change below as well. Four twin-edge cases whose picks ties decided are pinned anew (four_conditions,joint_nan,nfd_poolanduser_lexicon_provenance),baseline_smallpins the hash rule in its datasheet, and the new casetie_bytes_smallholds both engines to the rule of lexsync 0.1.The English demonstrations select from the screened lexicon,
corpora/derived/en_v2.csv, withpool_filters: {nonstandard: [0, 0]}, leave out the words ofconfig/exclude_en_demo.txtthroughexclude_words, and check their generated pseudowords against wordfreq’s fuller English list,corpora/derived/en_known_forms.txt. Every English demonstration that draws its items from a lexicon or a supplied pool selects different items, a breaking change. The selections no longer hold a form the screen marks. Theen_andrews_reprolow-frequency cells held 24 such forms among their 36 items (aclu, adhd, bbq and bmx among them), where its high-frequency cells held none. The screen lets through taboo words and some forms that are not standard English words, such as abbreviations that subtitles write in lower case (mph, msg, yrs), and the exclusion list leaves them out. Its 53 forms were found by reading the demonstrations’ selections until a run selected none of them, and its header states the criteria. They include the taboo forms the 0.1 demonstrations selected: bitch and boobs inen_ndensity, fuck inen_freqcontrast, ass, damn and hell inen_andrews_repro, and asses and shit inen_resample. The maintainer signs off the list’s content. No generated pseudoword is a known English word now. The check leaves the generators fewer candidates, so more pseudowords are ill-formed at an edge or lack a vowel, as fdes, gngs and ntena do. The matching vignette counts 13 of the 140, against 5 when the same designs skip the check.en_supplied_poolanden_priming_continuousread their norms fromen_v2.csv, whose N and OLD20 count only the unmarked forms, soen_supplied_poolselects different words anden_priming_continuouskeeps its pairs while its stimuli gain the members’ screening columns. The documentation quotes the new figures. Inen_ndensity, the dense words have fewer estimated syllables than the sparse ones by d = -0.53, where the unscreened selection gave -0.83, and inen_lexdecthe pseudowords are more bigram-typical than their words by d = -0.97, where they were by -1.47.The English example lexicon,
inst/extdata/en_example.csv, holds 3,000 of the formsen_v2.csvkeeps, with its frequencies, N and OLD20, and the Python package bundles the same file. It was a slice ofen.csv, a quarter of it forms the screen marks, mostly names and acronyms. Examples and tests that use it give different numbers: in the quick start, frequency separates the conditions by d = 4.84, where it gave 5.92. The datasheet credits the slice as screened against SUBTLEX-US, andLICENSE.noteand the repository’sLICENSE-DATAcredit SUBTLEX-US as a direct source of it. The repository’scorpora/fetch_corpora.py --slice-enrebuilds it from the committeden_v2.csv, as--screen-ennow does too. The script writes the slices with LF line ends on every platform, and rebuildingen.csvno longer overwrites the English slice.Lexica that the Python package’s wordfreq connector builds for Hindi, Bengali, Persian, Ukrainian and Catalan now hold the words those languages spell with combining marks, a zero-width non-joiner, an apostrophe or a middle dot. The connector dropped them, so a Hindi lexicon of 10,000 forms came back with 3,207, most of them Latin-script tokens such as ‘and’. The R package reads the new lexica as before and, like the Python package, counts their length, N and OLD20 in code points.
Durations that
resolve_trial_timing()reads from an item column must be present, finite and not negative. Fractional milliseconds are rounded once and recorded. A blank or negative value used to produce experiments that stopped at the blank in OpenSesame and PsychoPy or showed the stimulus for no time in jsPsych. A fractional one made the R and Python jsPsych pages differ, because jsonlite kept four decimal places of it. A blank, negative or non-numeric value is now refused, naming the column and the trial’s set and condition. A column of fractional milliseconds is rounded with the rounder both engines share and written back to the stimuli and the loop tables, so 82.5 ms is presented as 83 in every target. The returned table records how many values were rounded in its"duration_rounding"attribute, andrun_pipeline()writes the count into the run log. Fixed durations are checked the same way. A negativeduration_ms,duration_framesortimingvalue reached OpenSesame asclock.sleep(-50), and a NaN asclock.sleep(NA), and both are now refused, naming the event or the key. A fractionalduration_msis rounded where it was truncated, so 33.7 ms is presented as 34, and the lower bound of a jitter must not be negative. The jsPsych page writes each trial’s numbers to 15 significant digits, as the Python engine does. No shipped design reads a fractional or blank duration, so no stimulus or experiment changes. The twin-edge casefractional_soapins both engines to one outcome.Trigger codes are checked when the experiment is generated. A literal must be a whole number from 1 to 255, written as a number or as a string of digits, and anything else is refused with a message naming the event. A misspelt token such as
conditonwas written into the OpenSesame experiment assend_trigger(NA), while the Python engine stopped. Codes such as 300, 0 and -5 reached the parallel port, which keeps only the low byte, and 20.5 was taken as 20. A literal of 254 or 255, which the PsychoPy experiment sends when the session starts and ends, is reported, and so is one equal to a condition code the design sends or one within the range of the item codes it sends. A design that sends item codes together with condition codes, or with a code of its own from 40 to 239, gets item codes that never coincide with those codes. Sets 65 and 66 of a 70-set design took 101 and 102, the condition codes. Such a design is refused before any file is written when it has more item sets than free codes, unless the schema sets the newtriggers.allow_code_overlap: true, which lets the item codes wrap and repeat within the free codes.assign_triggers()gainseventsandschemaarguments for this, andexport_experiments()passes them. Any other design keeps its item codes, so no shipped loop table changes. The experiments vignette now says that item codes follow the code-point order of the set labels, so that with sets 1 to 20 set 10 is 41 and set 2 is 51, and that they are decoded through the loop table. It said that condition codes stay unique regardless. The codes did, but the marker stream did not once both kinds were sent. The OpenSesame snippetinline_send_triggers.pysends only the condition code at the word’s onset. It sent the item code first, whose 10 ms pulse delayed the condition code. The schema’s comments describe the new key, so the 42 committed datasheets, which record the schema’s SHA-256, and their run logs are regenerated. No stimulus, report table or experiment changes. The twin-edge casetrigger_typopins the refusal in both engines.Generated OpenSesame experiments build every stimulus before the trial runs and time each event from its onset. Each inline script built its canvas while the trial ran and slept the event’s full duration after a trigger pulse that blocked for 10 ms, the pattern OpenSesame’s timing manual shows as the way not to time a display, so every event overran: at 60 Hz the 50 ms prime was predicted to last about 67 ms and the 83 ms stimulus-onset asynchrony from prime to target about 117 ms. Each event now ends half a frame before its duration is up, so that the next display lands on the refresh that ends it. On a simulated display at 60, 120 and 144 Hz, the prime and the mask then last the flips the PsychoPy script gives them, and the asynchrony stays 83.3 ms. These figures are predictions, and the experiments vignette now gives a photodiode checklist for measuring them. The experiment measures the display’s frame period at start-up and logs it as
lexsync_frame_ms. It holds each trigger code fortrigger_hold_ms, which it ignored, without delaying any display. A response kept after the stimulus withfrom: offsetopens its window once a code still held has been reset, since OpenSesame’skeyboard_responseruns none of lexsync’s code. The experiment also sends the 254 and 255 session markers, as the PsychoPy script does. The 32 committed.osexpfiles change, and so do the datasheets and run logs that record their checksums. No stimulus moves.Trigger codes in the PsychoPy script last the hold, and never less than 10 ms. The 254 and 255 session markers were cleared by the statement after their flip, and a code on an event shorter than the hold was cleared as soon as the event’s last flip returned, so the markers and a one-flip prime’s code lasted no time at all. A hold set to 10 ms was rounded to 6.9 ms at 144 Hz and 8.3 ms at 240 Hz. The default 50 ms hold was not affected. Every code is now counted down flip by flip and reset on the flip that ends its hold, in a later event when its own is shorter. Only the next onset’s code ends it sooner, and a key that ends a stimulus early no longer cuts its code short. The self-paced reading critical-region code is held the same way. It was held for
inter_trigger_ms, 10 ms, which is accepted and no longer used:validate_design()returns a note for the run log when a schema sets it, and the shipped schema no longer does. A literal code of 254 or 255 is reported as a code both laboratory targets send. The 21 committed PsychoPy scripts change, and the datasheets and run logs are regenerated. No stimulus moves.The generated PsychoPy experiment runs full screen (
presentation.fullscreen, true by default), as PsychoPy advises for frame timing. It always ran in a 1280 x 1024 window, whichfullscreen: falsekeeps. Text is 48 pixels high in all three targets (presentation.text_height_px, a whole number from 8 to 400), where the OpenSesame experiment used 32. In OpenSesame, the setting is the experiment’sfont_size, so its instructions grow with it. OpenSesame takes full screen from the mode it is launched in, as the experiments vignette now says. Every committed experiment changes, and so do the datasheets and run logs, which record the checksums of the experiments and the schema. No stimulus moves.triggers.mode(auto,hardwareormock) decides what the PsychoPy and OpenSesame experiments do when the trigger device cannot be opened.hardwarerefuses to start: the PsychoPy script stops before its window opens, and the OpenSesame experiment stops in its setup.auto, the default, falls back to printing the codes as before, but now says so on screen before the first trial, where the experimenter presses C to go on, and records the mode with every trial, astrigger_modein the PsychoPy data file and aslexsync_trigger_modein the OpenSesame log. Both experiments used to run on without triggers and with no notice on screen, and only the OpenSesame log said so, in itstest_modecolumn.mockprints the codes without trying the device, after the same notice.triggers.backend: serial, withtriggers.serial_portandtriggers.baudrate(115200 by default), sends each code as one byte over a serial port in both experiments. The OpenSesame setup had a serial branch that ran only once the script was edited by hand, and it then opened the first serial port it found. The OpenSesame snippetinline_trigger_setup.pytook the first serial port too, and now opens the one named at its top.validate_design()refuses a serial backend without a port, a port name that cannot be written into the experiments, a mode or backend lexsync does not know, and a baud rate, text height or full-screen setting of the wrong kind, before anything is written. The experiments vignette describes the delay a USB-serial adapter adds. The twin-edge casetrigger_serialpins both engines to one outcome.The jsPsych page loads jsPsych, its keyboard plugin and its stylesheet from unpkg with Subresource Integrity, each file’s SHA-384 and
crossorigin="anonymous", so a browser refuses a modified copy. The page loaded them with no integrity check, and the first two from bare package addresses that the CDN answers with a redirect. The page’s comment says how to run it offline, since a page opened from afile://address cannot check the hash of a local copy.
lexsync 0.1.1
CRAN release: 2026-09-25
-
assign_triggers()gains aconditionsargument that fixes the order of the condition codes, andexport_experiments()gains one that defaults to the order of the design’sconditions. The codes used to follow the order in which the conditions first appeared in the counterbalanced trials, so a different seed could swap them, andlow_frequencywas 101 in a design that listshigh_frequencyfirst. The pipeline now passes the design’s order, followed by any other condition in the order the item source lists it, so the first condition the design lists is 101. The condition codes of 12 of the 21 demonstration designs change with it.assign_triggers()called withoutconditionsbehaves as before. -
cohens_d_ci()uses the large-sample standard error of d,sqrt(1/nx + 1/ny + d^2 / (2 * (nx + ny)))(Hedges & Olkin, 1985; Borenstein et al., 2009). It left out thed^2term, which treated the pooled SD as known and made the interval on a manipulated dimension far too narrow. In the English frequency contrast, d = 5.27 had the interval [5.01, 5.53] and now has [4.72, 5.83]. A matched control, whose d is near zero, moves by at most 0.002 across the 21 demonstration designs, and no d, TOST p-value or verdict changes. The comparisons, datasheets and run logs of the designs with a manipulated dimension change with it. No stimulus moves. -
match_report()documents the direction of its signed statistics.cohens_dand its interval are the reference (first) condition’s mean minus the mean of the condition in that row, andvar_ratiois that condition’s variance over the reference’s. - New
lexsync_cache_clear()removes a fetched corpus from the cache, together with any partial download of it, or with no argument removes the whole cache. It returns the paths it removed, invisibly. -
lexsync_cache_dir()only reports the cache’s path. It created the directory on every call, so asking where the cache was left an empty directory behind.fetch_corpus()now creates the cache when a download into it starts, once the registry entry and its URL have been accepted, so a refused call writes nothing. That download begins by deleting any.partsidecar in the cache that has gone a day without a write. An interrupt or a process that died mid-transfer leaves one behind, and no handler removes it. A younger sidecar may belong to a download still running in another session, so it is left alone. Adestof the caller’s choosing behaves as before. -
citation("lexsync")gives the DOI that CRAN assigned to the package, https://doi.org/10.32614/CRAN.package.lexsync, with the CRAN page as its URL. The 0.1.0 build on CRAN predates the DOI and gives the repository’s address. The entry names CRAN as its publisher, and its text version is the APA reference for software: author, year, title, version, “[Computer software]”, CRAN and the DOI. - The tests no longer take another package’s sources for the lexsync repository. CRAN’s Linux hosts check each package beside the unpacked sources of the others, and the test holding the bundled templates to the repository’s
templates/directory found the unrelated ‘templates’ package there and failed. A test that compares the package with repository files now accepts a root only if it holds lexsync’s ownDESCRIPTIONunderR_workflow/and the Python package beside it, and skips on CRAN. - Suggests now asks for testthat 3.1.8, the first release whose
local_mocked_bindings()can replace an imported function, as the corpus download tests do withdownload.file().
lexsync 0.1.0
CRAN release: 2026-09-22
- First release: multilingual corpus access, parallel multidimensional matching, counterbalancing, item resampling, deterministic pseudoword generation, and generation of hardware-timed PsychoPy, OpenSesame and jsPsych experiments. The R and Python engines select byte-identical stimuli, and every run ships a materials datasheet (provenance, checksums, realised control) and a pre-registration template.
- See the top-level
CHANGELOG.mdfor the full, cross-language history and the planned methodological roadmap.
Changes made before the release
These notes follow the package through its development up to the release, so they describe each change against the state it replaced.
- The package now declares
Depends: R (>= 4.0.0).tools::R_user_dirdoes not exist before 4.0.0, andround()’s post-4.0 algorithm shapes artefact bytes, so an older R would fail obscurely or write different bytes without complaint. - The published median goes through the exact reductions.
describe_stimuli()was the one reduction still onstats::median, which averages the two middle values throughmean()’s long-double accumulator while the Python engine reduced through numpy. Both engines now sort and take the exact middle, with(a + b) / 2in plain double arithmetic for even n, and regenerating every design moved no byte. Reproducibility and parity claimed that every reduction shared one compensated-summation algorithm, which the median never did, and now describes the sort-and-middle rule beside it. - The dead half of the two-shortest-forms CSV guard now fires. Between 2^49 and 1e15 two one-decimal strings can round-trip to the same double, and the R check derived its digit count from a 15-digit format that never shows a fractional digit there, so R accepted values (844424930131968.2 among them) that the Python writer refused. R now refuses exactly the same values, and the change only adds refusals.
- A condition without
define_byselects identically in both engines. This engine always fell back to the whole pool. The Python engine crashed with a bare KeyError on the same design and now mirrors the fallback, pinned by twin tests. - A pair design’s tolerance-window relaxation reaches the run log and datasheet. The continuous-pairs selector’s re-expansion dropped the audit in both engines, so a relaxed window left no trace outside the console. No shipped pair design relaxes, so no committed artefact changed.
-
INTER_TRIGGER_Sis substituted through%.17glike its neighbours in the generated PsychoPy script. It usedas.character(), whose rendering of a non-integer quotient differs from Python’sstr(). The shipped default still renders “0.01”, so no committed experiment byte moved. - The counterbalancing hash keys convert to UTF-8 before hashing, as
hash_unit()always has, so a latin1-marked condition read from a user’s CSV can no longer rank by different digests in the two engines. - The command-line wrapper reports which copy of the package it loaded.
R_workflow/run_pipeline.Rprefers an installed lexsync over the edited sources, so edits silently did nothing until a reinstall. It now states the loaded copy and the remedy on stderr at startup. - The Shiny app writes its design YAML with LF endings through a binary connection (the datasheet hashes that file into
design_sha256, which must not depend on the operating system), and its parity caption now matches the keyed-hash guarantee: the engines produce byte-identical stimuli and trial order, where the caption used to promise only the stimuli. The Python engine’s twin fixes land in the same release: its writer refuses 16-digit integer columns as this engine always has, its scalar rounder passes an overflowing scale through, andrun_pipeline(verbose = FALSE)keeps its console silent. - A selection that cannot honour
n_per_conditionis now an error. Every selector used to clip the request to the available pool and said so at most through a verbose message, while the datasheet and the generated Methods text kept stating the requested n. The newmatching: shortfallpolicy defaults toerror, andallowaccepts the shrink. Its siblingmatching: on_insufficient_tolerance(defaultrelax) can likewise turn the silent tolerance-window widening into a refusal, and a relaxation that does happen is now recorded in the run log and the datasheet (selection.window_relaxations), where before it was only narrated to the console. No committed design experiences either condition. -
jointandoptimalmatching can no longer select the same word twice. With overlapping condition windows a word could be paired with itself at zero cost, or mirrored across two sets, appearing in both conditions. Both matchers now track used words as the anchored matcher always has, and every matcher asserts its output holds each word at most once. Every committed design has disjoint conditions, and those select identically, byte for byte. - The item-table loader refuses missing cells in both engines. A blank
conditionreached Python’s hash-key guard as the string “nan” and passed the very check built to refuse it, a blankitemcell turned numeric identifiers into “1.0”, and R failed on the same table with a bare “missing value where TRUE/FALSE needed”. Missingness is now checked before any coercion,itemis read as text, empties after trimming and duplicated item-and-condition rows are refused, and both engines say the same thing. - Cohen’s d no longer reports perfect balance for two unequal constant vectors. The standardised difference is undefined when the pooled SD is zero and the means differ, so it is now reported as missing. The old 0 with CI [0, 0] contradicted the TOST verdict on the same row.
- The datasheet’s Methods prose follows the recorded verdicts. The claim of being “within the 0.5-SD equivalence bound” was printed for any controlled dimension with a confidence interval, even where the datasheet’s own JSON recorded
equivalent: false. The sentence is now conditional on the stored verdicts, prints the signed worst difference and the configurable bound, and the analysis note names the suggested formula aslme4syntax, where it used to imply thatstatsmodelsaccepts it. The datasheet also gains the SHA-256 of the design and schema, the pairwise matchers’ candidate cap and whether it fired, the equivalence bound tested against, and theyaml,stringiand operating-system entries the environment record omitted. - A
continuousblock now works over a supplied pool, and two silent table-mode gaps are refusals.source: poolreaches the continuous selector (it previously fell through to the conditions matcher and crashed). A continuous table withoutmembersis an error now, as ispool_filterson a plain table design, where each used to do nothing silently while the provenance recorded otherwise. Misspeltpool_filtersordefine_bycolumns, duplicate condition names, reversed or non-finite ranges, negative tolerances and non-integer n are likewise refusals now. - Selection-path rounding goes through the shared decimal rule (
.round_dpand a new vectorised numpy twin), replacing a pairing of R’sround()with numpy’s, two rounders the project measured to disagree at boundaries. Regenerating all 21 designs moved no stimulus, report or experiment byte. A custom norm named inmatch_onnow also reaches the descriptives, comparisons and realised-control record, which previously listed only the built-in dimensions. - A design file could execute code on the machine that ran it. A design is meant to be shared, and the recipient runs it and then opens the generated PsychoPy script, OpenSesame experiment or jsPsych page. Stimulus text was always safe: it travels in the loop-table CSV the experiment reads at run time. Design metadata was not. The name, language label, font, parallel-port address and the column names on jitter and feedback events were substituted straight into code and markup positions, so a quote or an angle bracket there stopped being text and became syntax, and a crafted design could run arbitrary code on a lab machine or in the origin of a hosted browser study. Both engines now validate these values, under one rule that leaves every legitimate value byte-identical. A port address must be an address, a column name must be an identifier, and a stated
language_tagis shape-checked before it is used. Escaping them correctly would have taken three rules per engine..pyq()escapes newlines now as well: an.osexpis line-oriented, so a raw newline closed the inline-script block and let the rest of the value start a new top-level item. Pinned bytest-injection.R. No generated artefact changed. Adversarially attacking that fix then found four ways through it, all now closed and pinned. The largest: a response event’skeyswere joined into OpenSesame’sset allowed_responses "a;b"with no validation at all, and that is one line of a line-oriented format, so a key holding a double quote closed the string, a newline ended the line, and the rest of the value became new top-level items in the experiment, including aninline_scriptwhose body OpenSesame runs. Keys are validated now. The shape guards were also anchored with$, which in both Python’sreand R’s PCRE matches just before a final newline, so a port address ending in one passed the check. They are anchored at end-of-string now. A scalarkeys: spaceorblocks: practicewas iterated character by character in Python and kept whole in R, so one design gave two different allowed-response lists and a block-restricted event ran everywhere in one engine and nowhere in the other. And R’s HTML escape did not cover U+2028/U+2029, which end a line in JavaScript but are not ASCII controls: Python escaped them, R did not, so the same design produced different bytes and R’s<script>was a syntax error before ES2019. - A browser experiment could score a feedback screen against the previous trial’s keypress. The jsPsych feedback screen looked up “the last row marked scoreable”, which is that trial’s response only when the trial has one. An event may be restricted to a block, so a design running the response event in one block and feedback in another leaves a trial with no response row of its own, and the screen then reported a verdict computed from an earlier trial’s key. Each trial’s rows now carry its own identifier and the screen matches on it. The generated HTML changed for all 21 designs. No stimulus selection changed.
- A large number in a user’s own column was written differently by the two engines.
write_csv_utf8()reproduced readr’s format for small magnitudes and left the top end alone, since nothing lexsync computes reaches it. Nothing lexsync computes does. A joined norm table, a supplied pool or an item table carries whatever columns the user has, and those go straight into the stimuli CSV, so the guarantee held for the shipped designs and failed silently for the user’s own data. readr’s layout above 1e15 could not be reproduced in Python, which writes 1.5e16 as15e15, the largest double as17976931348623157e292, and the double nearest 5e22 as4.9999999999999996e+22. Both engines now refuse such a value and name the column. One engine accepting it would leave the two writing different bytes. A value with two equally short decimal forms is refused for the same reason. Everything verified to render identically, across 465 values compared against readr’s own output, writes as before. - A generated OpenSesame experiment with a
feedbackevent or ablocks:restriction would not run. The emitter wroteunicode(...), a Python 2 builtin that OpenSesame’s Python 3 inline workspace does not provide, so the experiment died withNameErroron the first trial. It also passedNoneas avar.get()default, which OpenSesame cannot distinguish from no default, so it raises where it should return. Both are fixed, both spellings are now pinned by a test, and afeedbackevent with no precedingresponseorquestionis refused at generation time. Left to run time, that one design error would surface as three different failures, one per target. - Reported statistics are rounded by an arithmetic definition both engines compute identically, because no pairing of built-ins agreed. Measured over 210,000 values, including every three-decimal halfway case in range, R’s
round()disagrees with Python’s builtin, Python’s builtin disagrees with numpy’s, and even R’ssprintf("%.3f")disagrees with Python’s"%.3f"on 274 of them. Some values move by one in the last published digit. No selection changes. - A hash-key component that cannot be rendered identically in both engines is now refused rather than hashed. A blank
conditioncell, a routine data error neither reader rejects, rendered"NA"in R and"nan"in Python, so the two engines produced different trial orders from the same design, reproducibly and with nothing to signal it. Booleans get a pinned spelling and are still accepted. - The overlap-cap centroid in the
jointandoptimalmatchers goes through the compensated reduction. The cap does fire on shipped designs, so it decides which candidates reach matching. It was verified to change no design’s selection. -
R CMD check --as-cranis clean: 0 errors, 0 warnings, 1 note (the standard new-submission note). Fixed on the way: three undocumented arguments onselect_continuous_stimuli(), an unqualifiedutils::head(), and the package’s only example, which called an unexported function from inside\dontrun{}and so could never have worked if a user copied it. The package now has executable, offline examples, andcran-comments.mdno longer misstates the check result. - Three factual corrections. The SUBTLEX-PT registry entry cited a DOI that resolves to an unrelated psychometrics paper. The matching vignette put Zipf 7 at a thousand occurrences per million where the correct figure is ten thousand, contradicting its own lower anchor. A design comment attributed “840-prime materials” to Rastle et al. (2004), a figure that appears nowhere in that paper.
- A design may declare
practice:andfillers:item tables. Those trials are presented but not analysed, so the stimuli file and the reports are written from the main rows while the generated experiments run every trial. Practice comes first. Fillers are interleaved with the main trials, because a block of fillers at the end is not a filler but a second block a participant can tell apart. A design declaring neither is unaffected, down to not gaining ablockcolumn. - A
feedbacktrial event, and ablocks:key that restricts any event to named blocks. Together these confine feedback to practice, which is the usual arrangement: feedback teaches the mapping, and would contaminate the reaction times it is measuring. Implemented for PsychoPy, OpenSesame and jsPsych alike. The PsychoPy runner now returns the pressed key so it can be scored, and each runner pauses at a block boundary. - The Python engine embedded a bare
NaNin the generated jsPsych experiment where a trial had no value for a field another block supplies (a main-block trial in a design whose practice items carry ananswer). That is not valid JSON, and the R engine dropped the key instead, so the two engines’ experiments differed. Both now drop it. - The generated artefacts were not byte-identical across the engines, and the parity test could not see it. It read both CSVs back with a parser and compared the values, under which
1and1.0are the same number. Thirteen of the 18 shipped designs differed byte for byte while the gate stayed green. Three differences were serialisation (a whole number written1and1.0, a boolean writtenFALSEandFalse, a small value written9e-4and0.0009) and one was not: two reported means differed in the last decimal published, because numpy sums pairwise and R’smean()does not. Every reduction in the package now uses one compensated-summation algorithm written out in both engines, whose agreement follows from IEEE-754 and no longer from measurement. The writers agree on every value, and the artefacts are now compared as bytes. No R golden moved, because R’s two-pass mean was already the correctly-rounded one. - A response key coded
fwas silently turned intoFALSE.readrreads a column whose values are allf,t,TorFas logical, while pandas keeps the string, so an item table using the commonest two-choice key pair had its correct answer corrupted in one engine. Item tables now read the condition label and the paradigm’s presented fields as text in both engines. -
jsonlite’s default of four digits was truncating the datasheet: a design declaringtolerance_k: 0.1111111111111111had it recorded as0.1111, which does not reproduce the run the record exists to describe. The JSON is now written at full display precision, and the Python engine writes the same precision. - New paradigm
categorisation: a category cue, then the word to judge against it, withanswerholding the correct response key so the data are scoreable. Counterbalanced by Latin-square rotation, so a participant never sees the same target twice. -
counterbalance.optimise(off by default) assigns item sets to lists so the lists are equated on the declared dimensions, where the old deal went by set rank. The search is a deterministic integer descent with a keyed-hash tie-break, so it uses no random number generator and both engines produce the same assignment.balance_lists()is exported. - New item source
pool: a supplied word list goes through the matcher without having to masquerade as a corpus lexicon.load_pool()is exported. The neighbourhood dimensions are computed against the lexicon’s words, because a word’s neighbours are its neighbours in the language. - A design may name norm tables in a
norms:block. They are joined onto the lexicon before the candidate pool is built, so a semantic dimension the corpus does not carry (concreteness, age of acquisition, valence) can be filtered on, matched on or spanned like any other. No norm data is bundled. Every joined table is recorded in the materials datasheet with its checksum and its per-column coverage, because a norm table can supply the very variable a design manipulates. - Datasheet version 1.1. It adds
materials_source$normsand, for a pair-keyed design, arelationalblock naming the members, the pair count, the member lexicon and its checksum, and the member-level dimensions separately from the relational ones.selection$cross_engineno longer reports “n/a (user-supplied items)” for a pair-keyed continuous design: that design does perform a selection, and it is byte-identical across engines. -
merge_norms()returns the lexicon with the norm columns appended, in the lexicon’s own row and column order. It previously usedmerge(), which hoists the join column to position 1 where pandas keeps the left frame’s order, so the two engines disagreed on column order wheneveronwas not already first. The join key is now case- and whitespace-folded on the lexicon’s side too, where only the norm table’s was folded before: a lexicon holdingDogused to match nothing and leave an all-NAdimension. A norm column whose name already exists on the lexicon is now an error. Renaming it tofrequency.x/frequency.y, asmerge()did, left the matched dimension under a name nothing looks for. -
write_datasheet()andwrite_run_log()now write LF on every platform. They used a text-mode connection, so on Windows the datasheet, its Markdown rendering and the Markdown run log came out CRLF while the Python engine’s twins were LF. The datasheet is the provenance record. Its bytes no longer depend on the machine that built it. -
add_pair_overlap()andresolve_trial_timing()are now exported in fact as well as in the documentation. Both were marked for export and documented, but theNAMESPACEhad not been regenerated, solibrary(lexsync)did not make them available. - The materials datasheet now reports the candidate-pool size per condition (selection transparency, making item-selection bias auditable) and a suggested crossed mixed-model formula that guards against the language-as-fixed-effect fallacy.
- Browser (jsPsych) experiments gain a welcome/instructions screen, per-row item metadata (condition, item id, list), and a completion screen that saves the collected data as a CSV download.
- The realised-control report and datasheet gain a variance ratio per dimension, a distributional balance check that complements Cohen’s d and TOST.
- Wuggy-style subsyllabic pseudoword generation (opt-in
items.generation.method: subsyllabic): whole onset/nucleus/coda constituents are swapped for attested same-role, same-length alternatives, preserving syllabic structure and length. Byte-identical across engines, and the default letter-substitution generator is unchanged. - Continuous (non-dichotomised) design mode: declare a
continuousblock andselect_continuous_stimuli()spans a predictor’s range evenly while holding the controls near-constant, with predictor-control correlations and a regression suggested-model in the datasheet. Byte-identical across the R and Python engines. - Two new matching methods:
mahalanobis(a covariance-aware distance that down-weights correlated dimensions) andoptimal(a globally optimal assignment for two-condition designs, using the suggestedcluepackage). Unlike the default methods, these use a covariance inverse and an assignment solver, so the R and Python engines agree closely but not byte-for-byte on them. - An unknown
matching.method, and a candidate pool too small for the requestedn_per_condition, now raise an actionable error. Each used to fall back silently, to a default method or to a short set. The Python engine raises the same message, and code that relied on the old fallback will now stop. -
stringiis a new hard dependency (Imports). The canonical word key is case-folded with ICU at the root locale, so the key no longer depends on the machine’s locale and matches the Python engine byte for byte.shiny,bslib,DTandzipare new Suggests, for the Shiny app. - Ten functions are newly exported, matching the Python package’s public surface:
PARADIGMS(),build_datasheet(),build_lexdec_stimuli(),count_syllables(),generate_pseudowords(),make_pseudoword(),methods_paragraph(),required_fields(),resolve_events()andwrite_datasheet(). -
tost_equiv()now defaults tobound_d = 0.5, the value the Python engine already used. The R default was 0.4, so the equivalence bound is wider and a comparison is easier to declare equivalent. Reported verdicts can change for the same data. -
describe_stimuli()now orders rows by each group’s first appearance, matching pandas. This changes the row order of generated descriptives. - OpenSesame experiments now present trials in the seeded counterbalanced order. The order was previously randomised again at run time, so what ran was not what the pipeline generated and recorded.
- Breaking change: trial order within each counterbalancing list now comes from a seeded, keyed-hash shuffle shared with the Python engine. Each row is ranked by the SHA-256 digest of
seed|replicate|list|set|condition, a tuple that identifies the trial uniquely under either counterbalancing recipe, so the permutation is a pure function of the design: byte-identical from both engines on any platform, and different for every seed. Previously the order was drawn fromsample(), which could never match numpy’s generator for the same seed, so the trial lists were the one engine-specific artefact. All 75 generated experiment files across the 15 designs bundled at the time are now byte-identical across the engines, and the parity gate now compares thetrialcolumn of the stimuli CSVs. Stimulus selection, pairing and lists are unchanged, but every design’s trial order changes relative to the previous artefacts. The package no longer uses R’s random-number generator at all, so a seeded run cannot perturb the calling script’s random stream and there is no RNG state to save or restore. -
merge_norms()preserves the lexicon’s row order, andparticipant_table()crosses factors inexpand.grid()order in both engines. - Datasheets record the tolerance windows and pseudoword generator that ran, filter dimensions as the Python engine does, and report the installed package version they were built by.
-
match_stimuli()raises where it used to re-pick an already-used row, when a relaxed window re-admits candidates missing a matched dimension. Such a candidate has no defined distance and is never assigned, yet it still counted towards the pool-size guard. An NA-depleted pool could therefore pass the guard and go on to emit the same word in several sets. -
match_stimuli()andselect_continuous_stimuli()no longer select an all-NA row when the tolerance window is NA (an anchor of a single item givessd = NA). An undecided comparison now resolves toFALSE, as it does in Python, so the window is relaxed and a real word is selected. - The generated PsychoPy script, OpenSesame experiment and jsPsych page are now byte-identical to the Python engine’s, trial lists included (see the keyed-hash shuffle entry above).
- Selected stimuli are unchanged for all 15 designs bundled at the time.
- Every vignette now turns console colour off and fixes the console width while it renders. pkgdown passes the calling terminal’s colour support into its build subprocess, so a coloured message or error would otherwise reach the reader as escape sequences in the middle of the text.