Compile models from Stan source as a preload step (#137) - #145
Compile models from Stan source as a preload step (#137)#145kiante-fernandez wants to merge 9 commits into
Conversation
77cfaeb to
bbdfd17
Compare
A model may supply stanCode instead of committed moduleUrl/wasmUrl artifacts:
- The prior is derived from the Stan source (parseStanPriors) so validation,
grid probes, and first-design selection work unchanged and synchronously.
- Compilation kicks off EAGERLY at createController against a compile server
(compile: { server, authToken }, defaulting to the public stan-playground
server) so it overlaps welcome/instruction screens; the readiness chain
also verifies the compiled artifact downloads (and warms the HTTP cache
for the worker's import). Mock-mode handles stay network-free; a
per-timeline controller:'stan' override starts the compile lazily.
- The stan controller's model_ready now chains on the (possibly in-flight)
artifact URLs; committed models resolve immediately. wasmUrl is forced
null for source models (the server-hosted main.js fetches its sibling
wasm), and validateModel rejects stanCode+wasmUrl / stanCode+moduleUrl
combinations and non-object priors on source models.
- ado.preload(opts): a jsPsychPreload-style gate trial on a self-contained
~40-line plugin (no plugin dependency) that shows a message until
ado.ready() resolves, renders the compiler's actual error message (stanc
syntax errors are meant to be READ) and aborts on failure, with an
optional max_load_time deadline. Optional: without it the first posterior
update awaits readiness.
- Stale registerModel:/prepareModels: error prefixes in stan_source.js
renamed to parseStanPriors:/prepareModel:.
- New demo: demos/byo_model_exponential/from_source.html (same exponential
model, zero offline compile steps; documents the compile-server CORS
reality). Committed artifacts remain the production path.
- Tests: 13 unit tests (eager compile, prior derivation, custom server,
worker receives compiled URL, preload finish/error/timeout, mock network-
free, artifact-download failure, session cache, wasmUrl/prior validation)
+ a browser smoke driving the FULL chain against a local mock compile
server serving the committed exponential artifacts (real WASM inference;
no external service in CI). Types + CHANGELOG + README updated.
205 unit tests, typecheck, prettier, and the compile-preload browser smoke
pass. Branch stacked on feat/controller-api; merges after #141.
5a39b70 to
a9258b8
Compare
…ighter interfaces, comment retouch
Bundled cleanup alongside the preload feature:
- Remove src/models/compile_stan_model.js (compileStanModel): an unreferenced
pre-controller-API helper fully superseded by prepareModel(spec, { compileServer }).
- Lazy-load the debug UI. The ~1400 lines of chart/SVG/panel code under
src/ado/debug/ are now dynamically imported by ado_timeline.js only when
run_context.debug is set, so a production bundler splits them into a chunk
participants never fetch (verified: entry chunk drops ~18 KB of chart code).
Behavior with debug on is unchanged.
- Slim the index.js facade (~780 -> ~695 lines): outcome-label resolution ->
src/ado/response_labels.js; the debug-flag/?debug resolver -> src/ado/debug_flag.js.
- Tighten internal module interfaces: dropped over-exports that were used only
internally (validation.js: TASK_ONLY_FIELDS/continuousModelProblems/
validateResponseSpace; debug modules: makeDebriefStimulus/removeAdoDebugPanels/
showDebugDebriefPanel/formatPosteriorDrawChart; the two helpers added this PR).
- Trim redundant named exports from the shipped model files (public surface):
a model's interface is its DEFAULT export (the package object). The duplicate
named exports of default properties (responseProb(s)/stanData/buildData/
responseDensity*/responseMoments/conditionalEntropy/responseSampler/
subjectiveValues/simulationData) and the default-alias exports
(lineLengthDiscriminationModel/magnitudeEstimationModel) are removed; access
via model.responseProb etc. Standalone math helpers stay named exports. Model
unit tests re-derive the likelihood from the model object (bodies unchanged).
CHANGELOG notes the public-surface change.
- Retouch comments repo-wide: rewrite provenance/history comments — bare
issue-number tags, and phrasings like "fixed"/"used to"/"the old ..."/"no
longer ..." — to describe present behavior rather than how the code changed.
A few genuine pointers to substantial external context are kept: compile-server
CORS (#137), the bundler wasm-resolution path (#57), and upstream Vite #10837.
Issue tags are also dropped from test names. Comment / doc-string / test-title
strings only — no executable code changed (the diff touches only comment and
test-name lines).
No behavioral/API change beyond the documented model-export trim. Validated:
206 unit tests, typecheck, prettier, 8 real-WASM recovery+parity smokes, bundler
smoke (chunk split), and all 10 browser smokes (every demo, debug on and off).
a9258b8 to
d584fec
Compare
Measured readability pass — no content dropped that a user needs: - Loosen dense paragraphs in Overview / Status / Usage and cut the double cross-references in the Usage intro. - Move the API reference directly under the usage example; keep the bundler, adaptive-stopping, and debug sections but tighten the prose. - Fold the binary/categorical/continuous model interface into a short list under "Adding tasks and models" instead of repeating it. - Replace the nine hand-listed recovery-smoke commands in Development with the actual npm scripts, plus one line noting the extra smokes CI runs. All demo paths, npm scripts, and relative links verified. 317 -> 287 lines; no behavioral or API change.
…ocs/usage.md The README had become the only home for the API, bundler-consumer gotchas, adaptive-stopping config, and internals — too much for a landing page. Move that reference material verbatim into docs/usage.md (re-linked with ../ paths) and reduce the README to overview + quick start + doc links + status/compatibility/license (~200 words). No content lost; all links, demo paths, and npm scripts verified.
The landing page pointed at the usage guide but never showed the API shape. Add a compact end-to-end example — createController + evaluateDesignVariable in the trial + recordResponse in on_finish + createTimeline — so a reader sees the whole adaptive loop at a glance before clicking through.
|
Next I think there may be one artifact-pairing issue in the lower-level Reviewing my understanding... There are two ways a Stan model can reach The normal deployed path is a precompiled model package. In that case the model object points at committed artifacts: moduleUrl: new URL("./main.js", import.meta.url).href,
wasmUrl: new URL("./main.wasm", import.meta.url).href,Those two files are a matched pair: the worker imports The source/prototyping path is different. A user supplies Stan source code ( It looks like the direct Now the possible gap... The exported lower-level const { stanCode: _code, stanUrl: _url, ...rest } = spec;
return { ...rest, prior, moduleUrl };If {
moduleUrl: "https://compile-server/download/new-model/main.js",
wasmUrl: "https://some-old-or-local/main.wasm"
}Then I think |
|
Next I think there may be one readiness issue around Reviewing my understanding from scratch... When a user supplies Stan source code instead of committed As I understand it, That participant-facing role seems important. If the preload trial succeeds, I would expect the model-loading path needed for adaptive trials to have been checked. Now the possible gap... Right now, model_ready = module_source.then(({ moduleUrl, wasmUrl }) => client.init(moduleUrl, wasmUrl));That means the preload trial can finish with I think preload should include that worker initialization step. The code comments already describe I realize this may not be a one-line preload-plugin change, because worker initialization currently happens inside the Stan controller created for the adaptive timeline. But I think the readiness promise should eventually cover that same initialization step, whether by moving the initialization earlier, sharing the initialization promise with preload, or otherwise making the preload success flag correspond to the worker-loaded model state. If that is too invasive for this PR, then narrowing the comments/data wording would at least make the behavior explicit, though that seems like the less useful outcome. |
|
Reviewing my understanding... There are two ways a user can provide a Stan model. The deployed/self-contained path is to precompile the model and ship the generated The Stan-source path is different. A user supplies Now the possible gap... A few docs/demo strings describe the source path as “in-browser compile” or “compiles it in the browser.” For example:
I think that wording could confuse users about the deployment requirements. For source-supplied models, they need access to an allowlisted public compile server or a self-hosted compile server. The no-compile-server path is the committed-artifacts path. The surrounding docs already mention compile servers and CORS, so I think this can probably be fixed with wording rather than a larger docs change. Maybe use “compiled through a compile server” in docs, and “Preparing the model...” in participant-facing demo text? |
… review) Addresses review feedback on the compile-from-source path: - prepareModel(spec) now rejects a wasmUrl on a source spec (stanCode/stanUrl), mirroring validateModel's rule. The exported helper previously spread the input spec through unchanged, so a leftover wasmUrl could ride onto the server-compiled moduleUrl and pair fresh JS glue with a stale binary. createController's own source path already forwarded wasmUrl: null; this closes the gap for direct callers. + test. - Narrow the preload/ready readiness wording: ready()/preload gate on the compiled glue being fetched and cached, but the worker's import + wasm-load still happen when the Stan controller starts. Reworded the overclaiming "USABLE" comment and the model_preload ready @PARAM so ado_preload_ok's meaning is honest. (Having preload actually exercise worker init is a larger controller-lifecycle change; follow-up.) - Fix misleading "in-browser compile" wording: compilation is server-backed (the browser sends Stan source to a compile server). Reworded docs/usage.md and the byo_model_exponential demo text + README to say "compile server". Validated: 207 unit tests, typecheck, prettier, compile-preload browser smoke.
… review) Addresses review feedback on the compile-from-source path: - prepareModel(spec) now rejects a wasmUrl on a source spec (stanCode/stanUrl), mirroring validateModel's rule. The exported helper previously spread the input spec through unchanged, so a leftover wasmUrl could ride onto the server-compiled moduleUrl and pair fresh JS glue with a stale binary. createController's own source path already forwarded wasmUrl: null; this closes the gap for direct callers. + test. - Narrow the preload/ready readiness wording: ready()/preload gate on the compiled glue being fetched and cached, but the worker's import + wasm-load still happen when the Stan controller starts. Reworded the overclaiming "USABLE" comment and the model_preload ready @PARAM so ado_preload_ok's meaning is honest. (Having preload actually exercise worker init is a larger controller-lifecycle change; follow-up.) - Fix misleading "in-browser compile" wording: compilation is server-backed (the browser sends Stan source to a compile server). Reworded docs/usage.md and the byo_model_exponential demo text + README to say "compile server". Validated: 207 unit tests, typecheck, prettier, compile-preload browser smoke.
c7d8584 to
1fcb184
Compare
… stanUrl gaps (#145 review) Extract the source-SHAPE + no-wasmUrl-on-source rule into one pure, stanUrl-aware validateSourceSpec(spec) in validation.js, shared by validateModel and prepareModel, so the rule and its message live in one place instead of three divergent copies. - validateModel delegates the neither/both/source-wasmUrl checks to the seam (keeping the #57 committed missing-wasmUrl warn); prepareModel throws the first problem. One canonical WASM_URL_ON_SOURCE_MESSAGE, replacing the copy that had already drifted. - Fixes two pre-existing stanUrl blind spots in validateModel, exposed by unifying: a {stanUrl} spec was misread as 'neither moduleUrl nor stanCode', and {stanUrl, wasmUrl} slipped past the wasmUrl rule (its gate keyed on the stanCode-only isSourceModel). The prior-omission exemption is likewise made stanUrl-aware — prepareModel derives the prior from a fetched stanUrl exactly as from inline stanCode — so a prior-less stanUrl spec is now a valid package. - isSourceModel stays stanCode-only by design: createController derives the prior synchronously and cannot fetch a stanUrl. A new createController guard rejects a stanUrl-only model with an actionable 'compile with prepareModel first' message instead of a late Stan-init crash, now that validateModel accepts the shape. Adds validateSourceSpec matrix tests + stanUrl regression pins. Validated: 211 unit tests, typecheck, prettier, compile-preload browser smoke.
|
@githubpsyche — re: the Rather than only guarding
Unifying the two validators surfaced two latent
New tests: a (Your other two points are handled too: the |
review) Move Stan Web Worker ownership from the per-timeline controller to the handle so ado.ready()/ado.preload() certify the model is actually LOADABLE — the worker has imported the compiled module and instantiated its wasm — not merely compiled and downloaded. Closes the reviewer's gap where ado_preload_ok could be true before the worker had loaded the model (a broken committed binary greenlit preload and only failed at the first update). - index.js: a lazy, memoized ensureStanRuntime() owns ONE shared worker client per handle, created + init'd on the first ready()/preload/timeline (never at construction, so createController stays worker-free for validation-only use). ready() awaits its init (mock handles short-circuit); createTimeline passes the shared worker_client + worker_ready to the controller. Practice->main timelines reuse the same worker (init'd once). - stan_ado_controller.js: the controller no longer creates or inits a worker — it adopts the handle's worker_ready (start() sets model_ready = worker_ready; the first update() awaits it before sampling). One worker lifecycle, owned where ready()/preload live. - Load failures still surface visibly on BOTH paths: compile/download/worker-load failure rejects ready() (preload renders it + aborts) AND the first update() (mid-run abort). The committed-model bundler wasmUrl (#57) still reaches the worker, now via ensureStanRuntime. Design selected via a judge panel over eager-shared / lazy-shared / source-only candidates (lazy-shared won: covers committed + source, keeps createController worker-free, removes worker ownership from the controller). Adversarially reviewed; review fixes: an honest ready() docstring for the mock-default + per-timeline-stan override caveat, and behavioral tests for the committed-stan init path and the shared worker being init'd once across reused timelines. Tests updated for handle-owned worker init (failure tests inject a shared client; compile-succeeds tests install a fake worker now that ready() loads it; the #57 invariant greps index.js). CHANGELOG also documents the earlier validateSourceSpec unification. Validated: 214 unit tests, typecheck, prettier, 10 browser smokes (preload now waits for the real worker load), bundler smoke, recovery + parity smokes.
Quality cleanup of 301b368 (no behavior change): - stan_ado_controller.js: drop the vestigial `model_ready` local — it was a one-hop alias of the `worker_ready` param now that the handle owns worker init. update() awaits worker_ready directly (also strictly more correct: an update before start() no longer awaits null), and start() is purely run-state reset + first-design-from-priors. The local `model_ready.catch` was redundant — both callers already guard worker_ready. Also drop the single-use `const client = worker_client` alias. - index.js: normalize the handle-level controller ONCE (handle_controller = normalizeControllerMode(config.controller)) and use it for both the eager-compile gate and ready(), instead of two raw config.controller compares of opposite polarity. This also validates an invalid handle-level controller early (at createController) rather than late at createTimeline. - tests: add a `fail` option to the shared installFakeWorker harness (mirroring installFakeCompileServer({ fail })) and use it in the worker-load-failure test, dropping the inline one-off FailingWorker class + manual save/restore. Skipped (noted): bundling worker_client/worker_ready into one `runtime` object — the controller signature is a flat options bag and the two always travel together, so the flat pair stays convention-consistent. Validated: 214 unit tests (no unhandled rejections), typecheck, prettier, compile-preload browser smoke.
|
@githubpsyche — circling back on your The change: worker ownership moved from the per-timeline controller to the handle. Previously each
Failure propagation is preserved on both paths: a compile / download / worker-load failure rejects One honest caveat: I picked the "handle owns a lazy shared worker" shape over eager-init and source-only alternatives (it's the only one that certifies both committed and source models while keeping |
|
I think there may be a validation mismatch in the documented Reviewing my understanding... Before a Stan model can run in the browser, it must be compiled into two matching files: For the production path, those files are shipped with the experiment. Build tools such as Vite and webpack may rename The compile-server path works differently. The documented use looks like: const model = await jsPsychADO.prepareModel(sourceSpec, { compileServer });
const ado = jsPsychADO.createController(jsPsych, { model, design_grid });Now the possible gap...
That warning appears to be applying the local-artifact rule to the server-hosted artifact pair. An author following the documented Could validation retain or recover enough information to distinguish a compile-server result from a locally bundled model, so this workflow receives no warning or a warning appropriate to its actual deployment requirements? A regression test covering I realize this behavior appears to predate the PR; I only noticed it while reviewing these changes. Since this PR changes |
|
I did a fresh pass over the current head. I think there are three additional issues worth checking. These are separate from the 1. The packed npm package does not include the usage guide that the README now relies onReviewing my understanding... This PR intentionally shortens the root The files included in the npm package are controlled by the "files": [
"src",
"core/tinystan",
"README.md",
"CHANGELOG.md",
"LICENSE"
]Now the possible gap...
That means someone reading the README from an installed copy of the package is directed to a local file that does not exist. This matters more after this PR because the full API and bundler guidance previously lived inside the shipped README itself. Could the package either ship the intended documentation files or use links that remain valid from the packed artifact? Because the usage guide itself links into omitted demo files, it may be worth deciding explicitly whether package documentation should be self-contained or should link to canonical GitHub pages. 2. Source compilation begins before design-grid validation finishesReviewing my understanding...
The current order in if (handle_controller !== "mock") {
ensureModuleReady();
}
const candidate_designs = enumerateDesigns(config.design_grid);
validateDesignGridForModel(candidate_designs, adapter, adapter.id);
Now the possible gap... A source model with an invalid design grid still sends its Stan source to the compile server before Could the eager-compilation trigger move below the synchronous model/grid validation? That would preserve essentially all of the intended overlap while ensuring a rejected controller configuration does not perform network work. 3. The public readiness descriptions still reflect the earlier behaviorReviewing my understanding... Following the earlier readiness fix, Now the possible gap...
Could these descriptions be updated to match the implemented meaning of readiness: Worker import and WASM initialization for both artifact paths, preceded by compile-server work for source models? I did not find a new failure in the core source-compilation or inference path. The unit, type, browser, and packed-bundler checks all passed; these three points concern package documentation, validation ordering, and keeping the public readiness description aligned with the implementation. |
Summary
Implements #137: a model may be supplied as Stan source (
stanCode) instead of committedmoduleUrl/wasmUrlartifacts, and is compiled to WASM during an explicit preload step — the same pattern asjsPsychPreloadgates media, applied to model compilation.No offline compile step, no committed artifacts, no
patch:wasm, and no hand-writtenprior(derived from the Stan source viaparseStanPriors). Closes #137.Design
createController, so it overlaps the welcome/instruction screens. The readiness chain is: POST source → content-addressedmodel_id→ download + verify the compiled glue → the handle's shared Stan worker imports the module and instantiates its wasm.ado.ready()/ado.preload()await that whole chain — so a green preload certifies the model is actually loadable, not merely compiled (see the review-response note below; the browser-smoke preload wait is ~54 ms, dominated by the real worker load).ado.preload(opts)/ado.ready(). The gate trial is a ~40-line self-contained plugin (no new plugin dependency). On failure it renders the compiler's actual error message — a student's stanc syntax error is meant to be read — and aborts visibly;max_load_timegives it a jsPsychPreload-style deadline. The trial is optional: without it the first posterior update simply awaits readiness.ado.ready()is public for custom loading UI.controller: "mock"never contacts the compile server (an offline dev loop can't be killed by a service it doesn't need); a per-timelinecontroller: "stan"override starts the compile lazily.moduleUrlXORstanCode; a leftoverwasmUrlon a source model is rejected (it would pair the server-compiled glue with a stale local binary); non-object priors on source models are rejected.model_idis a content hash: the first request for a given source ever pays the compile; every identical request — i.e. all participants — is a cache hit plus a ~1 MB artifact download.Deployment reality (documented, not hidden)
This is the dev/teaching/prototyping path. The public compile server currently allows browser requests only from its own allowlisted origins (works today from
127.0.0.1:3000); deployed experiments need a self-hosted compile server (docker run -p 8083:8080 ghcr.io/flatironinstitute/stan-wasm-server:latest) or an upstream CORS change (see #137 for the analysis). Committed artifacts remain the production path — compile once, commitmain.js+main.wasm— which is also the right answer for reproducibility. Graduating between the two is swappingstanCodeformoduleUrl/wasmUrlin the same model object.Demo
demos/byo_model_exponential/from_source.html— the bring-your-own-model demo with zero offline compile steps, sharingresponseProb/stanDatawith the committed-artifacts variant so the two pages can never fit different models.Validation
test:browser.application/wasmMIME, posterior recovery).ModelPackage.stanCode,CompileConfig,preload/readyon the handle) + CHANGELOG + README.Review response (@githubpsyche)
Three review comments, all addressed — with an explanatory comment on each in the thread:
prepareModel(...)artifact pairing. Fixed at the root: the source-shape + no-wasmUrl-on-source rule is unified behind onestanUrl-awarevalidateSourceSpecshared byvalidateModelandprepareModel(one canonical message, not three drifting copies). This also closed two latentstanUrlgaps in the publicvalidateModel, andcreateControllernow rejects astanUrl-only model with an actionable "compile withprepareModelfirst" message.ado.preload()didn't cover the worker load. Deep fix: Stan worker ownership moved from the per-timeline controller to the handle (one lazy, shared worker).ado.ready()/preload()now await the worker's module import + wasm instantiation — for committed models too — so a green preload certifies loadability (browser-smoke preload wait went ~2 ms → ~54 ms).createControllerstays worker-free; practice→main timelines reuse one worker (inited once); a compile/download/worker-load failure rejectsready()/preload and the first update.The #2 design was chosen over eager-init and source-only alternatives (handle-owned lazy-shared worker is the only one that certifies both committed and source models while keeping
createControllerside-effect-light); #1 and #2 were both adversarially reviewed. Unit suite + 10 browser smokes + bundler + recovery/parity smokes green.Also in this PR: measured codebase cleanup
The second commit (
d584fec) is a round of readability-oriented cleanup bundled alongside the feature — no behavioral or API change beyond one documented model-export trim, so it can be reviewed quickly and separately from the preload work:compile_stan_model.js, fully superseded byprepareModel.index.js~780 → ~695 lines) — outcome-label resolution and the debug-flag/?debugresolver extracted to small focused modules (response_labels.js,debug_flag.js).src/ado/debug/now load only when?debugis set, so a production bundler splits them into a chunk participants never download (~18 KB off the entry bundle). Debug-on behavior is unchanged.docs/usage.md. No content dropped; all links, demo paths, and npm scripts verified.Fully re-validated after the cleanup: 206 unit tests, typecheck, prettier, 8 real-WASM recovery + parity smokes, the bundler smoke (chunk split intact), and all 10 browser smokes (every demo, debug on and off).