                 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 <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 as 15e15, the largest double as
    17976931348623157e292, and the double nearest 5e22 as
    4.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 feedback event or a blocks:
    restriction would not run. The emitter wrote unicode(...), a
    Python 2 builtin that OpenSesame's Python 3 inline workspace does
    not provide, so the experiment died with NameError on the first
    trial. It also passed None as a var.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
    a feedback event with no preceding response or question is 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's sprintf("%.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 condition cell, 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 joint and optimal matchers 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-cran is clean: 0 errors, 0 warnings, 1 note (the
    standard new-submission note). Fixed on the way: three undocumented
    arguments on select_continuous_stimuli(), an unqualified
    utils::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, and cran-comments.md no 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: and fillers: 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 a
    block column.
  - A feedback trial event, and a blocks: 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 NaN in 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
    an answer). 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 1 and 1.0 are 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 written 1 and 1.0, a boolean written FALSE and False, a
    small value written 9e-4 and 0.0009) and one was not: two reported
    means differed in the last decimal published, because numpy sums
    pairwise and R's mean() 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 f was silently turned into FALSE. readr reads a
    column whose values are all f, t, T or F as 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 declaring tolerance_k: 0.1111111111111111 had it recorded as
    0.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, with answer holding 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$norms and, for a
    pair-keyed design, a relational block naming the members, the pair
    count, the member lexicon and its checksum, and the member-level
    dimensions separately from the relational ones.
    selection$cross_engine no 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 used merge(),
    which hoists the join column to position 1 where pandas keeps the
    left frame's order, so the two engines disagreed on column order
    whenever on was 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 holding Dog used to match
    nothing and leave an all-NA dimension. A norm column whose name
    already exists on the lexicon is now an error. Renaming it to
    frequency.x / frequency.y, as merge() did, left the matched
    dimension under a name nothing looks for.
  - write_datasheet() and write_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() and resolve_trial_timing() are now exported in
    fact as well as in the documentation. Both were marked for export
    and documented, but the NAMESPACE had not been regenerated, so
    library(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 continuous
    block and select_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) and optimal (a globally
    optimal assignment for two-condition designs, using the suggested
    clue package). 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
    requested n_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.
  - stringi is 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, DT and zip are 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() and write_datasheet().
  - tost_equiv() now defaults to bound_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 from sample(), 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 the trial column 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, and
    participant_table() crosses factors in expand.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() and select_continuous_stimuli() no longer select an
    all-NA row when the tolerance window is NA (an anchor of a single
    item gives sd = NA). An undecided comparison now resolves to FALSE,
    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.
