---
title: "Getting started with lexsync"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Getting started with lexsync}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = FALSE,
  comment = "",
  R.options = list(
    cli.num_colors = 1,
    cli.hyperlink = FALSE,
    crayon.enabled = FALSE,
    width = 80
  )
)
# Console colour carries no meaning on a rendered page. pkgdown turns it on for
# its own build, and the escape sequences then reach the reader as literal text,
# so colour is switched off here for a plain vignette render and a site build
# alike. The fixed width keeps printed output inside the documentation column.
```

`lexsync` selects stimuli that are matched in parallel across several lexical
dimensions and then generates the experiment scripts that present them. This
vignette runs a small high- versus low-frequency contrast on the bundled English
example lexicon.

The selection is deterministic, and it ships as two independent packages, one for
R and one for Python, which select byte-identical stimuli from the same lexicon
and design. No random number generator is involved: the engines agree because
every ordering is by byte and every tie is broken by a rule both apply. This holds
for the default matching methods. The optional `mahalanobis` and `optimal` methods
are equivalent but not byte-identical, because a covariance inverse and an
assignment solver differ in their last bits between the two platforms.

```{r setup}
library(lexsync)
schema <- yaml::read_yaml(
  system.file("extdata", "schema.yaml", package = "lexsync")
)
lex <- load_lexicon(
  system.file("extdata", "en_example.csv", package = "lexsync"),
  schema, language = "english"
)
nrow(lex)
```

## Define and run a design

A design names the conditions, the dimensions to match and the number of items,
together with the filters that narrow the lexicon to a candidate pool. The pool
is built first, exactly as the pipeline does, and matching then runs over it.

```{r design}
design <- list(
  name = "vignette_demo", language = "english", n_per_condition = 15,
  pool_filters = list(length = c(3, 7), frequency = c(3.8, 7)),
  conditions = list(
    list(name = "high", define_by = list(frequency = c(5.2, 7.0))),
    list(name = "low",  define_by = list(frequency = c(3.8, 4.4)))
  ),
  match_on = list("length", "n_density", "old20")
)
pool <- build_pool(lex, design$pool_filters)
stim <- match_stimuli(pool, design, schema)
head(stim[, c(
  "word", "condition", "length", "frequency", "n_density", "old20"
)])
```

## Anatomy of a design file

The list above is convenient in a vignette, but a real study writes its design as
YAML. That is what makes a design portable: `config/schema.yaml` holds everything
global and every numeric default, the design holds what is specific to the study,
and both are read identically by the R and the Python package, so a design file
can be attached to a pre-registration and handed to someone running the other
engine. Here is the English frequency contrast in full, from
`config/design_en_freqcontrast.yaml` in the repository.

```yaml
name: en_freqcontrast
language: english
lexicon: corpora/derived/en.csv
description: 'High versus low frequency English words, matched on length, neighbourhood density and OLD20.'
n_per_condition: 80
pool_filters:
  length: [3, 8]
  frequency: [3.8, 7.0]
conditions:
  - name: high_frequency
    define_by:
      frequency: [5.2, 7.0]
  - name: low_frequency
    define_by:
      frequency: [3.8, 4.4]
match_on: [length, n_density, old20]
counterbalance:
  lists: 1
timing:
  fixation_frames: 30
  word_frames: 30
  isi_frames: 15
```

### The keys

`name` and `language` are required, and together they form the slug every written
file is named after. `en_freqcontrast` plus `english` becomes
`en_freqcontrast_english`, and from that come
`en_freqcontrast_english_stimuli_R.csv`, `en_freqcontrast_english.osexp` and the
rest. `language` is a free-text label rather than a code, because it is what the
experiment displays. When the browser target needs a real BCP 47 tag it maps the
common labels and falls back to `und`, or you can state `language_tag` outright.

`lexicon` names the derived corpus to read. It may also be written as
`items.lexicon`, which is the form the lexical-decision designs use, since they
also have to say where the items come from.

`items` selects where the stimuli come from, and there are four sources.

| `items.source` | Where stimuli come from | Also needs |
| --- | --- | --- |
| `corpus` (the default) | Words selected from the lexicon by matching, or by spanning a predictor. | `lexicon`, `conditions` or `continuous`, `match_on` |
| `generate` | Real words plus a deterministically generated pseudoword for each. | `lexicon`, optionally `items.generation.method` |
| `table` | A CSV of prepared items (prime-target pairs, sentences). Add `items.members` to make it pair-keyed. | `items.path` |
| `pool` | A candidate word list of your own, matched over as if it were a pool. | `items.path`, normally `items.lexicon`, plus `conditions` and `match_on` as for `corpus` |

`pool_filters` narrows the lexicon to the candidates the design will consider at
all. Each key is a column and each value an inclusive `[min, max]` range for a
numeric column, or a set of permitted values otherwise. This is a real step and
not a formality: `match_stimuli()` never reads `pool_filters` itself, so a script
that skips `build_pool()` matches over the whole lexicon and quietly ignores the
design's bands.

`conditions` is a list, each entry with a `name` and a `define_by` block that
carves the condition out of the pool by the same filter syntax. Two conditions
make a contrast. Four make the 2 × 2 that `design_en_andrews_repro.yaml` uses to
reproduce Andrews (1989). A design may instead declare a `continuous` block and
dispense with conditions altogether.

`match_on` lists the dimensions to equate across conditions and `n_per_condition`
how many items each should hold. Both are the heart of the design and both are
covered at length in *Matching, dimensions and designs*.

`matching` overrides the schema defaults for this design alone: `matching.method`
picks one of the four methods, and `matching.tolerance_k` sets the half-width, in
standard deviations, of the tolerance window on each dimension. Overriding a
single dimension is common when reproducing a published study's exact windows.

`counterbalance.lists` sets the number of lists, and `counterbalance.optimise`
asks for an assignment whose lists are equated on the item dimensions rather than
dealt by set rank. `practice` and `fillers` each name an item table whose trials
are presented but not analysed. `timing` sets the fixation, critical-word and
inter-stimulus durations. Milliseconds are canonical (`fixation_ms`, `word_ms`,
`isi_ms`), which is why a design means the same interval on any display. This
design predates that change and still uses the `*_frames` form above, which is
accepted and converted on load at the schema's `presentation.assumed_refresh_hz`,
so its 30 frames become the 500 ms they were written for. `font` overrides the
presentation font, which
matters for a non-Latin script: `design_zh_freqcontrast.yaml` sets `SimHei`,
because the Latin default has no glyphs for Han characters.

`paradigm` names one of the five registered paradigms and inherits its trial-event
sequence and its counterbalancing recipe. Omitting it gives `factorial`. A design
may instead supply an explicit `events` list and describe its own trial. Both
routes are covered in *Experiments, paradigms and EEG triggers*.

### Running one

`run_pipeline()` takes a design path and does everything this vignette has done
by hand, plus the datasheet and the run log. From a clone of the repository:

```r
out_one <- tempfile("lexsync_one_")
run_pipeline("config/design_en_freqcontrast.yaml", outdir = out_one)
out_all <- tempfile("lexsync_all_")
run_all(outdir = out_all)  # every design_*.yaml in config/
```

Paths inside a design are resolved relative to the working directory, so run these
from the root of a checkout.

## Inspect the match quality

Matching always returns a set, so the report is what tells you whether the set is
any good. It belongs before anything is generated from the stimuli.

```{r report}
report <- match_report(
  stim, c("length", "frequency", "n_density", "old20"), schema
)
knitr::kable(
  report$descriptives,
  caption = "Descriptive statistics per condition"
)
knitr::kable(
  report$comparisons,
  caption = paste(
    "Standardised mean differences with 90% confidence intervals, plus the",
    "complementary TOST equivalence test"
  )
)
```

The manipulated dimension (frequency) differs strongly, while the controlled
dimensions (length, neighbourhood density and OLD20) are closely equated. The
report leads with the standardised mean difference (`cohens_d`) and its 90%
confidence interval (`d_ci_low`, `d_ci_high`). A non-significant difference test is
not evidence of matching, and its outcome depends on the number of items. The
effect size and its interval, whose upper limit is the largest imbalance still
consistent with the stimuli, are therefore the primary summary. The TOST equivalence test
is reported alongside as a bound-referenced verdict.

## Generate the experiment scripts

The trial is described once, as a sequence of events, and each presentation target
renders that same description. One call therefore produces all three.

```{r export}
out <- file.path(tempdir(), "lexsync_demo")
dir.create(out, showWarnings = FALSE)
files <- export_experiments(stim, design, schema, out)
basename(unlist(files))
```

Three experiments are generated from one description of the trial. The PsychoPy
script binds each onset trigger to the stimulus flip with `win.callOnFlip`. The
OpenSesame `.osexp` draws and shows the word inside an `inline_script` and sends
the marker immediately after `show()` returns, which it does at the display
refresh. The jsPsych HTML runs the same procedure in a browser and records the
trigger codes in the trial data, since a browser cannot address a parallel port.
Neither the matching nor the script generation requires PsychoPy, OpenSesame or
any hardware to be installed.

## Where next

The pipeline does all of the above in one call. `run_pipeline()` on a design
configuration writes the stimuli, the reports, the three experiments, a materials
datasheet recording the provenance and realised control of the run, and a run log.

Four further articles go into depth. *Matching, dimensions and designs* covers
the lexical dimensions and their units, the four matching methods and when each
one suits, tolerance windows, continuous designs and how to read the validation
report. *Experiments, paradigms and EEG triggers* covers the trial-event model,
the paradigm registry, the three presentation targets and the flip-locked trigger
timing. *Reproducibility, parity and the materials datasheet* covers the
cross-engine guarantee, how it is achieved and where it stops. *The app* covers
the Shiny front-end in the repository, which assembles a design through a browser
tab, runs this same pipeline and exports the code that reproduces the run.
