jspsych-ado
v0.2.0
Published
In-browser adaptive design optimization (ADO) for jsPsych experiments — Stan/WASM, no server, no Python.
Maintainers
Readme
Overview
jsPsychADO runs adaptive jsPsych experiments: instead of a fixed trial list, it
picks each trial's stimulus to be the most informative one for estimating your
participant's parameters — so you learn more from fewer trials.
Under the hood, after each trial (or block of trials) a Stan model — compiled to WebAssembly and run in a Web Worker via tinystan — estimates the posterior over your model's parameters, and the next design is chosen by maximizing mutual information over a grid of candidate designs. There is no server and no Python: everything runs client-side, so an experiment deploys as static assets.
You bring a task (design grid + presentation) and a model (Stan likelihood + a
small JS adapter); jsPsychADO checks that they are compatible and turns them into an
adaptive jsPsych timeline. Or start from one of the bundled task/model packages, ready
to run out of the box.
Status
🚧 Early release — published on npm as jspsych-ado
(npm install jspsych-ado; current version in the badge above). The in-browser engine
and three bundled examples — binary delay discounting, 3IFC categorical line-length, and
Halberda-style dot comparison — work and are covered by CI (unit tests + real headless
Worker/WASM smokes + a bundler build smoke). The committed WASM is bundler-safe and the
package builds under Vite and webpack 5 (see Using with a bundler).
Still pre-1.0: the task/model/controller extension APIs may change before 1.0.
Quick start
No build step — serve the repo with any static server (VS Code Live Server, etc.) and open the example:
demos/delay_discounting/index.html?controller=stan&strategy=ado&debug=1
demos/line_length_discrimination/index.html?controller=stan&strategy=ado&debug=1
demos/halberda_dot_comparison/index.html?controller=stan&strategy=ado&debug=1See demos/README.md for a guided tour: the three demos
above are "drop-in" examples (packaged task + packaged model), and two more show how
to bring your own task (demos/byo_task_money_choice/) or bring your own model
(demos/byo_model_exponential/). It also explains the tasks/ (packaged, shipped)
vs demos/ (example pages) distinction and contrasts a plain jsPsych timeline with
an ADO one.
controller=stan(default) — live in-browser Stan inference;controller=mock— a deterministic, no-WASM controller for fast UI work.strategy=ado(default) — MI-optimal designs;strategy=random— a random baseline drawn from the same grid.debug=1— per-trial console summary, selection diagnostics when available, and live posterior-convergence charts.simulate=data-only|simulate=visual— run a simulated participant. Data-only simulation stays fast for validation; visual simulation uses slower shared timing defaults so the stimulus, response, and debug updates are watchable.
Usage
An experiment is a thin consumer: register a task package and a model package, then
ask the façade for the timeline. The example below is for a bundler project
(npm install jspsych-ado); see Using with a bundler for
the required setup, and Quick start above for running the in-repo
examples by serving the repo statically.
import { initJsPsych } from "jspsych";
import htmlButtonResponse from "@jspsych/plugin-html-button-response";
import callFunction from "@jspsych/plugin-call-function";
import { jsPsychADO } from "jspsych-ado";
import hyperbolic from "jspsych-ado/models/hyperbolic/model.js";
import delayDiscountingTask from "jspsych-ado/tasks/delay_discounting/task.js";
import "jspsych-ado/tasks/delay_discounting/task.css"; // task styles (see Tasks)
const jsPsych = initJsPsych();
jsPsychADO.registerTask(delayDiscountingTask.id, delayDiscountingTask);
jsPsychADO.registerModelPackage(hyperbolic, {
stan: { num_chains: 2, num_warmup: 500, num_samples: 500, seed: 123 },
n_trials: 42,
});
const ado = jsPsychADO.createTimeline(jsPsych, {
task: delayDiscountingTask.id,
model: hyperbolic.id,
// Inject the jsPsych plugin classes the timeline builds trials from. A static
// page that loads the plugins' UMD <script> builds can omit this — the timeline
// falls back to the globals those scripts define.
plugins: { htmlButtonResponse, callFunction },
});
jsPsych.run([ /* instructions, */ ...ado /*, end screen */ ]);Using with a bundler
The package is ESM and runs client-side only (it spawns a Web Worker that loads the Stan WASM). It is tested against Vite and webpack 5.
- jsPsych plugins. Install the plugins your task uses and pass them via
createTimeline(..., { plugins }):@jspsych/plugin-html-button-responseand@jspsych/plugin-call-functionfor button tasks (delay discounting, line length), plus@jspsych/plugin-canvas-keyboard-responsefor canvas tasks (dots). They are declared as optionalpeerDependencies. (On a static page that loads their UMD<script>builds instead, the timeline reads them fromglobalThisand you can omitplugins.) - Task styles. Import the task's stylesheet, e.g.
import "jspsych-ado/tasks/delay_discounting/task.css". - Vite. The worker and WASM are emitted from
new URL(..., import.meta.url)inside the installed dependency. If Vite's dep pre-bundling interferes with that emission, exclude the package:optimizeDeps: { exclude: ["jspsych-ado"] }. - webpack 5. Works out of the box (first-class
new Worker(new URL(...))and WASM asset support); no extra config needed. - SSR / Next.js. Build the timeline only in the browser (e.g. behind
useEffect/ a"use client"component) — the Worker and WASM are not available during server rendering.
API
registerTask(name, spec)— register task presentation, design grid, and response labels.registerModel(name, spec)/registerModelPackage(model, overrides)— register a statistical model.prepareModels({ compileServer })— compile any models registered from Stan source.createTimeline(jsPsych, { task, model, ... }, run_context)— validate and build the adaptive timeline fragment.
Adaptive stopping
Beyond choosing each design, the loop can decide when to stop. The criterion uses
the same currency as design selection — the expected information gain (EIG = the
mutual information I(θ; y | d) between the parameters and the response under a
design). It stops once the best available next design's EIG falls below a
fraction of the maximum achievable EIG (ln(K) nats for a K-category response):
i.e. no remaining stimulus is expected to teach much more. Using a fraction keeps one
threshold meaningful across binary and categorical tasks.
Pass a stopping config to createTimeline (or as a registerModelPackage override):
stopping: {
eig_fraction: 0.1, // stop when best next-design EIG < 0.1 * ln(K); omit to disable
min_trials: 8, // never stop before this many trials
max_trials: 42, // hard cap (defaults to n_trials)
consecutive: 1, // require this many sub-threshold refits in a row (de-bounce)
}Omit stopping (or eig_fraction) for a fixed-length run of n_trials. Each row
records ado_should_stop and ado_stop_reason ("eig_fraction" or "max_trials");
the EIG that drove the decision is the grid-max MI in ado_max_mutual_info. A
complementary precision-target rule is tracked in
#101.
Debug traces
debug=1 prints a readable console summary after each adaptive update — the design
presented, the response, posterior mean/sd for the active model parameters, the next
selected design, and the local sampling time. In DevTools each summary also has a
collapsed details group with tables.
With controller=stan, debug output also includes posterior draw histograms and an
on-page information-gain panel. The panel plots the mutual information of the design
that was actually selected on each trial plus realized information gain after the
response. Under strategy=ado, the selected-design MI is the max-MI design by
construction; under strategy=random, it is the MI of the randomly sampled design,
so it should not be read as an optimality claim. The fast controller=mock path does
not fabricate these quantitative validation metrics; it remains for timeline/UI smoke
testing without WASM.
How it works
The timeline talks to an adaptive controller with two async methods —
start(context) and update(trial_data) — each returning the next design plus the
current posterior. Swapping the deterministic mock controller for the in-browser Stan
controller is the entire abstraction; the timeline never sees Stan or WASM.
jspsych-ado/ado/mi_engine.js— model-agnostic mutual-information design selection.jspsych-ado/ado/stan_worker.js— one generic Web Worker that runs NUTS off the main thread.jspsych-ado/ado/ado_timeline.js— the generic, stimulus-agnostic timeline.jspsych-ado/ado/experiment_shell.js— shared experiment-page run-mode and simulation wiring.jspsych-ado/controllers/— the in-browser Stan controller and the mock controller.jspsych-ado/index.js— thejsPsychADOfaçade.
Repository layout
jspsych-ado/— the general, model- and stimulus-agnostic library (engine, worker, controllers, generic timeline, façade). It knows nothing about any task.jspsych-ado/tasks/<name>/— a pluggable task package: design grid, presentation, choices, response labels, and response mapping.jspsych-ado/models/<name>/— a pluggable model package: amodel.jsadapter (params,prior,responseProborresponseProbs,stanData, …) plus its compiled.stanartifacts. Shipped models:hyperbolic(delay discounting),weber_dots(ANS acuity),line_length_discrimination_3ifc(3-way categorical).demos/<name>/— example pages that consume (or author) those packages; seedemos/README.md. Thedemos/byo_model_exponential/demo even authors its own model (exponential discounting) in-folder. These are how-to examples, not part of the published library.
Adding tasks and models
Drop task packages under jspsych-ado/tasks/<name>/ and model packages under
jspsych-ado/models/<name>/. The engine, controller, and timeline stay generic.
Model compilation steps are in jspsych-ado/models/README.md;
the task package contract is in jspsych-ado/tasks/README.md.
For runnable end-to-end walkthroughs, see the bring-your-own-task and
bring-your-own-model demos in demos/README.md.
Binary models expose responseProb(design, params) -> P(response = 1).
Finite categorical models expose responseProbs(design, params) -> [p0, p1, ...].
Continuous responses are not supported yet.
Development
node --test tests/js/*.test.mjs # unit tests: MI engine, model adapter, façade, controller + timeline failure paths
node tests/js/stan_recovery.smoke.mjs # real-WASM smoke: ADO recovers parameters (hyperbolic)
node tests/js/weber_recovery.smoke.mjs # real-WASM smoke: recovers the Weber/ANS model
node tests/js/line_length_3ifc_recovery.smoke.mjs # real-WASM smoke: recovers a 3-param categorical model
node tests/js/exponential_recovery.smoke.mjs # real-WASM smoke: recovers the BYO-model demo's authored model
node tests/js/likelihood_parity.smoke.mjs # real-WASM smoke: JS responseProb == compiled Stan, + fixed-seed determinism
node tests/js/stopping_recovery.smoke.mjs # real-WASM smoke: EIG-fraction adaptive stopping
node tests/js/locate_file.smoke.mjs # real-WASM smoke: emscripten honors the wasm locateFile patch
npm install && npm run test:browser # headless Worker/WASM browser smokes (puppeteer)
npm run test:bundler # npm pack -> Vite build -> headless: hashed WASM loads
npm run patch:wasm # re-apply the bundler-safety glue patch after recompiling a modelThe likelihood_parity smoke is a correctness guard: every .stan exposes its
per-trial choice probability as a transformed/generated quantity, so it checks the
JS responseProb/responseProbs (used by the MI engine and the simulator)
against the compiled Stan likelihood draw-for-draw — if the two ever diverge, ADO
would optimize designs against the wrong model. (The Weber model's JS Phi is an
erf approximation, so its parity bound is 2e-6, not machine epsilon.)
CI runs the unit tests, the recovery + locateFile smokes, the headless browser
smoke, and the bundler smoke on every PR and push to main. After recompiling any
model's main.js, run npm run patch:wasm (CI's unit job fails if a committed
main.js is left unpatched).
Releases publish to npm by pushing a vX.Y.Z tag, which triggers
.github/workflows/release.yml to re-run the full
gates and npm publish --provenance. See RELEASING.md and the
CHANGELOG.
Deploying
Serve a demo page such as demos/delay_discounting/index.html or
demos/line_length_discrimination/index.html from any static host — no backend.
The experiment code, the compiled WASM model, and the vendored sampler
(core/tinystan/) are local static assets; the demos load jsPsych and its plugins
from a pinned CDN (unpkg), so a deployment needs network access for those. For a
fully self-contained / offline build, install jsPsych from npm and bundle it (see
Using with a bundler).
Compatibility
Browser/Web-Worker only — the WASM is built with emscripten -sENVIRONMENT=web,worker.
Targets the jsPsych 7-era plugin API (jspsych is a peerDependency, >=7); the
in-repo demos pin jsPsych 7.3.4 + plugins from a CDN.
Citation
A JOSS paper is in preparation (see paper/). Until it is published, please
cite this repository.
License
MIT © The jspsych-ado contributors.
