Changes in version 0.1.1 (2026-09-25) - assign_triggers() gains a conditions argument that fixes the order of the condition codes, and export_experiments() gains one that defaults to the order of the design's conditions. The codes used to follow the order in which the conditions first appeared in the counterbalanced trials, so a different seed could swap them, and low_frequency was 101 in a design that lists high_frequency first. 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 without conditions behaves 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 the d^2 term, 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_d and its interval are the reference (first) condition's mean minus the mean of the condition in that row, and var_ratio is 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 .part sidecar 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. A dest of the caller's choosing behaves as before. - citation("lexsync") gives the DOI that CRAN assigned to the package, 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 own DESCRIPTION under R_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 with download.file(). Changes in version 0.1.0 (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.md for 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_dir does not exist before 4.0.0, and round()'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 on stats::median, which averages the two middle values through mean()'s long-double accumulator while the Python engine reduced through numpy. Both engines now sort and take the exact middle, with (a + b) / 2 in 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_by selects 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_S is substituted through %.17g like its neighbours in the generated PsychoPy script. It used as.character(), whose rendering of a non-integer quotient differs from Python's str(). 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.R prefers 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, and run_pipeline(verbose = FALSE) keeps its console silent. - A selection that cannot honour n_per_condition is 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 new matching: shortfall policy defaults to error, and allow accepts the shrink. Its sibling matching: on_insufficient_tolerance (default relax) 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. - joint and optimal matching 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 condition reached Python's hash-key guard as the string "nan" and passed the very check built to refuse it, a blank item cell 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, item is 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 as lme4 syntax, where it used to imply that statsmodels accepts 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 the yaml, stringi and operating-system entries the environment record omitted. - A continuous block now works over a supplied pool, and two silent table-mode gaps are refusals. source: pool reaches the continuous selector (it previously fell through to the conditions matcher and crashed). A continuous table without members is an error now, as is pool_filters on a plain table design, where each used to do nothing silently while the provenance recorded otherwise. Misspelt pool_filters or define_by columns, 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_dp and a new vectorised numpy twin), replacing a pairing of R's round() 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 in match_on now 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_tag is shape-checked before it is used. Escaping them correctly would have taken three rules per engine. .pyq() escapes newlines now as well: an .osexp is 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 by test-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's keys were joined into OpenSesame's set 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 an inline_script whose body OpenSesame runs. Keys are validated now. The shape guards were also anchored with $, which in both Python's re and 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 scalar keys: space or blocks: practice was 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