webR spike — can the real pipeline regenerate a display in the browser?
Run 2026-07-25 against dev @ fa51269. Requirement: obot.roadmap#113, task "webR spike: can the real pipeline regenerate a display in the browser?". Goal: #112.
Answer: yes — and it is numerically faithful. But it must not be the authority.
The spike selects a hybrid: webR regenerates in the browser for preview, GitHub Actions regenerates in the pinned environment for the committed iteration. Neither of the two candidates in #113 wins outright; the reason is provenance, not capability, and it is set out in §5.
1. What was tested
The question was deliberately narrow: can webR run the real opencsr pipeline — {cards} on {pharmaverseadam} — well enough to regenerate a display from an edited analysis.yaml? Not "does webR work", which is already known, but "does our pipeline work, on our data, producing our numbers".
Four harnesses, all reproducible:
| Harness | Environment | What it answered |
|---|---|---|
spike.mjs |
webR 0.6.0 under Node 24 | availability, install cost, first breakages |
spike3.mjs |
same | all six displays' ARDs, exported and diffed against the committed ARDs |
spike4.mjs |
same | render_display() and full regenerate() end to end |
local.html / persist.html |
Chrome, local HTTP server with no COOP/COEP headers | real browser boot time, exact bytes over the wire, package persistence |
The browser harness deliberately served the runtime from a plain static server sending no cross-origin-isolation headers — that is the GitHub Pages condition, and it constrains which webR channel is usable (§4).
2. Dependency availability — no gaps
repo.r-wasm.org serves 22,741 WebAssembly package builds for R 4.6. Every opencsr dependency is present, and so is the full transitive closure:
- Direct
Imports:cards,cardx,dplyr,digest,gt,jsonlite,rlang,tibble,yaml— all present. Suggeststhat matter:pharmaverseadam,gtsummary,testthat— all present.- Transitive closure of the nine runtime deps: 58 packages, 56.0 MB of tarballs.
There is no missing-package blocker. {pharmaverseadam} — the one that could plausibly have been absent, being a data package — ships a wasm build.
3. Does the pipeline actually run? Yes, all of it
Mounting pipeline/R/*.R plus library/tfl/<slug>/* into the webR virtual filesystem and sourcing the package:
| Step | Result | Time |
|---|---|---|
prepare_data() |
adsl 254 rows, adae 1191 rows — matches the committed manifest |
4.4 s |
build_ard() × 6 displays |
88 / 236 / 84 / 176 / 3048 / 33 rows — every count matches the committed ARDs | 1.7–3.7 s each |
render_display(), both variants |
valid gt HTML, real values (AE event counts 281 / 427 / 414 / 1122) |
1.0–2.2 s |
regenerate("t-ae-overview") |
wrote v002/ with analysis.yaml, display.yaml, ard.json, manifest.json, table.html, table-in-text.html |
1.9 s |
| spec edit → rebuild | t-ae-common 3048 rows → 504 rows after adding filter: AESEV == 'SEVERE' |
2.2 s |
The last row is the product question, and it passes: a real change request applied to analysis.yaml in the browser produced a different, correct ARD. A warm spec-edit round trip is ~2–4 seconds. That is interactive.
3.1 Numeric fidelity — the decisive result
Each display's ARD was built in webR, serialised through the repo's own write_ard(), exported, and diffed row-by-row against the committed ARD that local R 4.3.3 produced.
3,665 statistics across six displays. Zero value differences. Zero missing rows. Zero extra rows.
t-ae-overview (v001) 88 rows shared 88 value diffs 0
t-demographics (v001) 236 rows shared 236 value diffs 0
t-disposition (v001) 84 rows shared 84 value diffs 0
t-exposure (v001) 176 rows shared 176 value diffs 0
t-ae-common (v002) 3048 rows shared 3048 value diffs 0
l-ae-serious (v001) 33 rows shared 33 value diffs 0
This held despite a materially different package set:
| committed (local) | webR | |
|---|---|---|
| R | 4.3.3 | 4.6.0 |
cards |
0.6.1 | 0.8.0 |
dplyr |
1.1.4 | 1.2.1 |
gt |
1.0.0 | 0.11.1 |
pharmaverseadam |
1.1.0 | 1.3.0 |
Two minor-version jumps in the statistical engine and a data-package jump, and not one number moved. That is a strong signal both for webR and for the pipeline's determinism — and it is the reason a browser preview can be trusted to show the truth.
4. Cost, measured in a real browser
Chrome, PostMessage channel, runtime served from a header-less static server (the Pages condition):
| webR runtime download | 12.66 MB gzipped, 5 files (R.wasm alone 11.75 MB) |
| Boot to R prompt | 1.5 – 2.9 s |
| Install the 58-package closure | 7.7 – 12.8 s (56.0 MB; ~13 s cold) |
| First rendered table | 18 s warm, 24 s cold |
| R heap after the six-display pipeline | ~200 MB |
Two things worth knowing:
- The base R library is embedded in
R.wasm— only five files are fetched at boot, not a 26 MB virtual-filesystem tree. Boot is cheaper than the on-disk distribution size suggests. - Package install repeats on every page load. Attempting to fix this with an IndexedDB-backed library (
FS.mount('IDBFS')+syncfs) mounted cleanly and reported all 57 packages installed — but after a reload only 1 of 57 survived. Persistence is available in principle and not working in practice; the HTTP cache still shortens the reinstall to ~8 s, but "load once, use forever" is not on the table today without more work.
Deployment consequence: 12.66 MB + 56 MB cannot be loaded eagerly. webR has to be lazy — fetched only when a visitor opens the spec editor, behind an explicit "start the R engine" affordance that names the cost.
5. What breaks
Four findings, in ascending seriousness.
(a) gt::as_raw_html(inline_css = TRUE) is hard-unsupported under webR. It is the only call in the pipeline that fails, at pipeline/R/render.R:473 in standalone_html(). inline_css = FALSE works and produces valid HTML. In-browser, inline CSS is unwanted anyway — the fragment renders into the app's own styled DOM. One argument, and the browser wants the other value.
(b) Sys.which() does not exist in webR. The git-commit probe warns and git_commit degrades to null — which contracts §10 already permits by design ("an artifact that cannot name its commit says so instead of guessing"). No fix needed; the contract anticipated this.
(c) GitHub Pages cannot send COOP/COEP headers, so SharedArrayBuffer is unavailable (crossOriginIsolated: false, verified). The SharedArrayBuffer channel is therefore out; the PostMessage channel works and was used for every browser measurement above. The cost is webr::interrupt() and stdin — neither of which a regenerate-on-click app needs.
(d) The wasm repo is a rolling snapshot with no version pinning — this is the real problem. repo.r-wasm.org serves one version per package per R release, and it moves. The consequence is not wrong numbers (§3.1 settles that) but a divergent provenance envelope:
committed: adsl 254 × 60 cols sha256:f9a1c360… pharmaverseadam 1.1.0
webR: adsl 254 × 61 cols sha256:31c804e9… pharmaverseadam 1.3.0
Row counts agree; column counts and therefore data hashes do not. So ARD value equality holds while ARD snapshot equality (D4) breaks. A browser-generated ard.json is not byte-identical to a CI-generated one, and its provenance.environment records a package set that no CI run will ever reproduce. D5's envelope stops being an audit record and becomes a description of one visitor's browser.
Mitigation found: old tarballs stay served after the index moves on — pharmaverseadam_1.1.0.tgz still returns 200 from the R 4.4 and 4.5 trees even though both indexes now list 1.3.0. A pinned, self-hosted wasm repo is feasible: mirror the exact 58 tarballs (~56 MB) as a release asset and point webr::install(repos = …) at it. That is real work and a real hosting cost, and it should not be done speculatively — see §6.
6. Recommendation
Not option (a) or option (b) from #113, but both, with a clean division of authority:
The browser previews. CI commits.
- webR is a preview engine. It regenerates faithfully and fast enough to feel live (~2–4 s warm). It renders the edited display next to the current one so a visitor sees their change take effect, on a static Pages site, with no server. This is what makes the spec editor real rather than a mock, and it is the honest answer to "what makes an edit live".
- webR is not a provenance authority. It must not write to
iterations.yamloroutputs/<slug>/vNNN/. Its output is labelled a preview and carries no hash claim. - The commit path is the GitHub Actions round trip. "Propose this change" dispatches the workflow; the pipeline regenerates in the pinned environment; a PR opens with the ARD diff. D4, D5, D9 and D10 stay intact, and the audit trail remains the version history.
Sequencing that follows:
- Change
render.R:473to takeinline_cssfrom an argument defaulting toTRUE, so the browser can ask forFALSE. One-line change, no behaviour change for CI. - Build the spec editor against the round trip first — it is the path that satisfies the invariant, and it needs no new runtime.
- Add the webR preview behind a lazy "start the R engine" affordance once the round trip works. It is an enhancement to a correct loop, not the loop itself.
- Pin the wasm repo only if the preview proves it earns its 69 MB. Do not pay for pinning to make a preview auditable — the preview is not the audit record.
Option (c) from #113 — precomputed variants — is rejected, as #113 anticipated. It is unnecessary: genuine regeneration in the browser is demonstrated above.
7. Reproducing this
The harnesses are not committed (they carry a 47 MB node_modules). To rebuild:
mkdir webr-spike && cd webr-spike && npm init -y && npm i webr
# then the four harnesses from this session; each mounts pipeline/R/*.R and
# library/tfl/<slug>/* into the webR VFS and sources the package by hand,
# because webR has no compiler and cannot R CMD INSTALL a source package.
The load shim that made it work, and which any future webR integration needs:
e <- new.env(parent = globalenv())
for (f in sort(list.files("/csr/pipeline/R", full.names = TRUE))) {
src <- readLines(f, warn = FALSE)
src <- gsub("inline_css = TRUE", "inline_css = FALSE", src, fixed = TRUE)
eval(parse(text = src), envir = e)
}
e$csr_root <- function(path = NULL) "/csr"
for (nm in ls(e, all.names = TRUE)) if (is.function(e[[nm]])) environment(e[[nm]]) <- e
attach(e, name = "opencsr", warn.conflicts = FALSE)
build_ard() needs custom_env = "/csr/library/tfl/<slug>" — the display's custom.R is not found otherwise, which is what produced the first harness's misleading failure.
This spike was run and written by Claude Code using Opus 5 in an unattended session and not yet reviewed by @jwildfire.