Skip to content

MEME hit-likelihood estimate: verified JS evaluator, no ONNX runtime in the bundle - #150

Merged
stevenweaver merged 11 commits into
mainfrom
feat/prescreen-gate
Aug 10, 2026
Merged

MEME hit-likelihood estimate: verified JS evaluator, no ONNX runtime in the bundle#150
stevenweaver merged 11 commits into
mainfrom
feat/prescreen-gate

Conversation

@stevenweaver

@stevenweaver stevenweaver commented Aug 3, 2026

Copy link
Copy Markdown
Member

Reworks the AxoMEME stage-1 gate (#147) into a "Before you run" outlook row, and removes the ONNX runtime from the browser bundle entirely.

Why

The original wiring shipped an advisory badge above the Run button. Reviewing it turned up twelve problems, the load-bearing ones being that it cost every user 13.5 MB whether or not they used MEME, presented an unsourced probability to researchers who publish results, and told people their analysis was unlikely to work without telling them what to do instead.

Behaviour

  • One RunOutlook panel holds both the runtime estimate and the MEME estimate, instead of two near-identical coloured boxes stacked above Run.
  • A discouraging estimate now routes to a better-suited method — or says plainly that no method will help, which is the honest answer for a genuinely thin alignment — rather than only grading the data.
  • The bare percentage is gone from the primary display, replaced by the three inputs it was computed from. The score, the bucket edges and the model's limits sit behind a disclosure.
  • not-applicable, loading, ok, cannot-assess and error are now distinguishable, so a broken estimator no longer renders identically to a healthy one.
  • Adds an out-of-distribution guard, and discloses when branch lengths came from an inferred neighbour-joining tree rather than the user's own — those are different units and the difference is not cosmetic.
  • Nothing user-facing says "gate" any more; it never gated anything.

Payload

onnxruntime-web and onnxruntime-node are both dropped. The 150-tree ensemble is walked directly by treeEnsemble.js over a 37 KB coefficient table.

before after
non-MEME method (e.g. FEL) 13.5 MB 0 bytes
MEME 13.5 MB ~59 KB

Measured with a page.on('response') byte counter against both the dev server and a production build. Removing the native binding also unbreaks the unit suite on x64 Node, which previously could not load onnxruntime-node at all.

Verification

Replacing a runtime with 40 lines of JS is only safe if the coefficients provably match the model the ML and QC teams validated, so that is now enforced rather than assumed:

  • scripts/prescreen/onnx_to_ensemble.py regenerates the coefficient table from the .onnx. It reproduces the committed table exactly — zero difference across all 2,204 nodes.
  • scripts/prescreen/verify_parity.py scores 20k deterministic vectors plus adversarial rows through both onnxruntime and the shipped JS evaluator, and fails on any level disagreement or hash mismatch. Currently: max |Δ| 1.5e-07, zero level disagreements.
  • .github/workflows/model-parity.yml runs both on any change under src/lib/services/prescreen/ or scripts/prescreen/.
  • The .onnx hash pin is now compared against the file on disk instead of pattern-matched, so regenerating against the wrong model is caught.

Both failure modes were tested by deliberately tampering — a corrupted hash and a shifted leaf weight each produce a non-zero exit.

Retraining is now npm run regen:model && npm run verify:model-parity.

Known limitation — please read before merging

The model is not monotone in its inputs: adding sequences, codons or tree depth can lower the estimate, occasionally across a bucket boundary. At 200 codons and branch length 0.01, going from 28 to 29 sequences drops it from likely to uncertain. Tracked in #151, with a monotone retrain already prepared by the ML team.

This PR discloses the behaviour to the user rather than hiding it, but a reviewer should decide whether that is sufficient to ship or whether it waits on the retrain.

Review notes

  • Ground-truth check: MEME was run on all five bundled datasets. The estimator's ranking matched the outcome 5/5 — zero selected sites on the four it rated unlikely, 8 sites on the one it ranked highest. It also correctly preferred a 6×200 alignment over a 20×85 one.
  • frac_p_defined is retained in the feature vector but uses 0 of the model's 2,205 split nodes; it is a post-hoc feature the training never selected. The monotone retrain drops it, which is a [1,4][1,3] contract change.
  • src/lib/services/prescreen/meme_hit_likelihood.onnx is committed but never fetched at runtime — it exists so the coefficients can be regenerated and re-verified.

stevenweaver and others added 3 commits August 3, 2026 13:05
- static/prescreen/meme_power_gate.onnx (82 KB) — the stage-1 gate model,
  generated from axomeme/dm3-prescreen (clientside/export_gate_onnx.py).
- static/prescreen/ort/ort-wasm-simd-threaded.wasm (13 MB) — the single default
  ORT-web WASM runtime, vendored so it loads locally (CSP-safe, offline), served
  on demand only when a MEME gate runs.
- src/lib/services/prescreen/gate.js — the runtime-agnostic gate module.
- src/lib/services/prescreen/*.fixtures.json — parity fixtures for the tests.
- onnxruntime-web dep + onnxruntime-node dev dep (tests).
Adds the client-side AxoMEME stage-1 gate as a GREEN/YELLOW/RED advisory badge
that renders above the runtime estimate when MEME is selected (issue #147).

- PreScreenGate.svelte: the badge. Client-side only (browser/onMount guard —
  onnxruntime-web can't run under Cloudflare SSR); MEME-only; non-blocking. Uses
  the CPU-only ORT build (onnxruntime-web/wasm) so we ship one 13 MB WASM, not
  the 26 MB WebGPU/jsep variant. Fails silent (advisory is non-essential).
- gateAdvisory.js: testable wrapper — loadGateSession (lazy ORT + vendored
  wasmPaths), computeAdvisory (MEME-only + branch-length-tree gate, returns null
  otherwise). Injectable ORT for tests.
- MethodSelector.svelte: render <PreScreenGate> above <AnalysisTimingEstimate>,
  fed the in-memory alignment (.canonicalFasta) + tree
  (.usertree || .nj).
- vite.config.ts: exclude onnxruntime-web from optimizeDeps (it ships its own
  vendored WASM).
- prescreen-gate.test.js: 16 unit tests scoring through the REAL onnx via
  onnxruntime-node (shares kernels with -web) — feature parity, tier boundaries,
  MEME-only + no-tree degradation, shallow-tree-gates-RED end to end.
- 18-prescreen-gate.spec.js: e2e — badge shows for MEME (verified RED on the
  CD2-slim demo), absent for FEL. Component rendering is covered here because the
  repo's vitest toolchain doesn't mount Svelte components.

Verified: 268 unit tests + 2 e2e pass; badge renders RED above the timing
estimate in-browser.
Reworks the AxoMEME stage-1 gate into a "Before you run" outlook row and
takes the ONNX runtime out of the browser bundle entirely.

Behaviour
- Merges the estimate into one RunOutlook panel with the runtime estimate,
  instead of two competing coloured boxes stacked above Run.
- A discouraging estimate now routes to a better-suited method, or says
  plainly that no method will help, rather than only grading the data.
- Replaces the bare probability with the three inputs it was computed from;
  the score and its limits sit behind a disclosure.
- Distinguishes not-applicable / loading / ok / cannot-assess / error, so a
  broken estimator no longer renders as a healthy one.
- Adds an out-of-distribution guard, and discloses when branch lengths come
  from an inferred neighbour-joining tree rather than the user's own.

Payload
- Drops onnxruntime-web and onnxruntime-node. The 150-tree ensemble is
  walked by treeEnsemble.js over a 37 KB coefficient table, so selecting a
  non-MEME method downloads 0 bytes of estimator payload, measured in both
  dev and a production build; MEME downloads ~59 KB. Previously every
  method pulled 13.5 MB.
- Removing the native binding also unbreaks the unit suite on x64 Node,
  which could not load onnxruntime-node at all.

Verification
- scripts/prescreen/onnx_to_ensemble.py regenerates the coefficient table
  from the .onnx and reproduces the committed table exactly.
- scripts/prescreen/verify_parity.py scores 20k vectors through both
  onnxruntime and the shipped JS evaluator, and fails on any level
  disagreement or hash mismatch. Wired up as .github/workflows/model-parity.yml.
- The .onnx hash pin is now compared against the file on disk rather than
  pattern-matched, so a regenerate against the wrong model is caught.

Known limitation: the model is not monotone in its inputs, so adding
sequences can lower the estimate. Tracked in #151.
@stevenweaver stevenweaver changed the title Client-side MEME pre-screen gate (AxoMEME stage-1) — issue #147 MEME hit-likelihood estimate: verified JS evaluator, no ONNX runtime in the bundle Aug 5, 2026
Resolves the package.json conflict by keeping both sides: main's fast/slow
e2e split and this branch's model-parity scripts.

The conflict was why CI never ran on #150. GitHub builds pull_request
workflow runs from the refs/pull/N/merge commit, and a conflicting PR has
no such ref, so no run is triggered at all — silently, with nothing shown
in the Actions tab.

Also picks up main's e2e coverage tooling: 18-meme-hit-likelihood.spec.js
now imports the coverage fixture like every other spec, so the estimator
is included in the coverage report rather than being the one gap in it,
and both describe blocks get a 180s timeout. Under 4 workers sharing one
dev server the setup alone can exceed the global 60s cap, which made the
suite flaky-and-retried instead of either passing or failing honestly.
The first assertion that mounts MemeHitLikelihood is also what makes the
dev server transform it and its four-module import graph for the first
time. That cold cost lands on one assertion; 30s cleared it locally but
failed on a cold CI runner and only passed on retry, which reads as an
unstable feature rather than a one-off compile.
The previous commit blamed a cold module-graph transform and raised a
timeout. That was wrong: the failure snapshot shows the app back on the
Data tab with "Upload or select a file to get started" and no method
dropdown at all, so no wait length could have helped.

Two things combine. goToAnalyzeTab returns false instead of throwing when
the tab is not clickable yet, so a missed click is silent. And the marker
loadDemoFile waits on, sequence-info, is the unconditional root element of
dataReaderResults, so it appears when that component mounts rather than
when the file becomes the active selection. Between those, clicking
Analyze can land back on Data, and the spec only discovers it much later
as a missing estimator row.

Retry the navigation with expect().toPass() and assert the dropdown as the
real precondition. Three consecutive local runs are clean; it previously
flaked about every other run.
This is the actual cause of the estimator spec's flake, and it was not a
timeout or a missed click.

MemeHitLikelihood was the only file in the codebase using the deep-subpath
form (lucide-svelte/icons/signal, /help-circle, /alert-triangle,
/loader-2). Every other component imports from the lucide-svelte barrel,
which is already in the eager graph. Because this component is dynamically
imported, those four subpaths were node_modules dependencies the dev
server never saw at startup, so first touching them mid-session triggered
a Vite dependency re-optimization and a full page reload. That dropped the
selected file and returned the app to the Data tab, which is why the
failure snapshot showed "Upload or select a file to get started" and no
method dropdown, and why no amount of waiting helped.

Reproduced deterministically by clearing node_modules/.vite, which is the
state CI is always in after npm ci. Two cold-cache runs are now clean, and
faster (38.7s vs 51s) because the reload is gone. Production output is
unaffected: the estimator is still a separate chunk, and the e2e byte
budget still holds.

This also fixes a real dev-mode papercut — the same reload hit anyone
running npm run dev the first time they selected MEME.
The model was trained on one label: whether a MEME run reported at least
one selected site. It has never scored BUSTED, aBSREL or FUBAR, so it
cannot support claims about them — and five of the six guidance strings
made exactly those claims. "BUSTED can detect selection here", "FUBAR
holds up better than MEME here", and "a different method will not help"
are all predictions about methods this estimate has no evidence on,
carrying the authority of a number that only describes MEME.

The reasoning behind them was defensible phylogenetics, which is the
problem: it was the author's judgement presented as the model's finding,
and a reader cannot tell the two apart.

Guidance now stays on what the estimate can speak to — whether MEME has
enough substitutions to fit its site-level tests, and what about the DATA
would change that. "Collect more divergent sequences" is a claim about
this alignment relative to MEME's needs. "Run BUSTED instead" is a claim
about BUSTED.

Removes the switch-method routing and its plumbing (the dispatch chain
through RunOutlook to MethodSelector.switchMethod) rather than leaving it
computed and unused. Adds a regression guard at both the unit and e2e
level asserting no other method is ever named in user-facing text.
Two guards for the two ways this branch nearly embarrassed itself.

Scope. The cross-method claims were caught by a person reading the copy;
nothing in the suite noticed. Adds a guard that walks every state the
estimate can reach — non-MEME, no alignment, topology-only tree, out of
distribution, scored at each level, each tree-source caveat, and the error
path — and asserts no other analysis method is named in any field that can
reach a screen. State enumeration rather than a source grep, so a new
string in a new file is still covered as long as some state renders it.
Verified to fail: reintroducing "BUSTED would detect it gene-wide" turns
it red.

Note the two scope tests are complementary, not redundant. The state walk
does not reach the generic 'unlikely' branch, because no realistic
alignment lands there; the per-level test constructs feature vectors
directly and does.

Staging. Datamonkey_2_0, video-frames, benchmark-results,
hyphy-wasm-artifact and src/benchmark/test-alignments are local scratch at
the repo root, so a reflexive `git add -A` sweeps 265 files and ~270 MB
into a commit. That happened twice here and was caught both times before
pushing. Ignoring them makes the safe command the easy one. The loose
scripts/*.sh and e2e/video-frames.spec.js are left tracked-able on
purpose: they look like real figure tooling, not artifacts.
The estimate now reads XGBoost's own save_model() output directly. There is
no converter and no intermediate format between the file the ML team
exports and the bytes the browser parses.

Why the pipeline changed
Previously: sklearn -> skl2onnx -> .onnx -> onnx_to_ensemble.py ->
ensemble.json -> treeEnsemble.js. Every hop could drift, and twice did:
skl2onnx emits a different graph shape per estimator family, and
HistGradientBoosting would not export at all. Three model families arrived
in three days, two of which broke the converter. XGBoost's JSON is a
stable, documented, first-party format, so the whole chain collapses to
export-then-parse. xgbEnsemble.js is ~40 lines and is the entire runtime.

Why the thresholds changed
The old cuts were derived against meme_validation.tsv, which turns out to
be the file train_gate_xgb.py fits on — so those rates were measured on the
model's own training rows. Re-measured against the 5,982 production jobs
the model has never seen, the 0.30 cut gives a 28.6% hit rate, which does
not support the word "unlikely". UNLIKELY_MAX moves to 0.10, where the rate
is 5.4% [2.0, 11.4] on 1.9% of submissions. LIKELY_MIN is unchanged.

Out-of-sample band rates, n=5,982: 5.4% / 51.9% / 92.6%.

Copy states each band's own observed rate and nothing else. The overall
base rate was removed: it is a claim about the corpus rather than about the
alignment on screen, and repeating it in all three bands added nothing.

Verification
- verify_parity.py now proves the shipped JS walker reproduces XGBoost's
  own scoring of meme_gate.json, and self-tests: it was confirmed to fail
  on a perturbed leaf, on a flipped comparison operator, and on broken
  base_score parsing.
- New out-of-sample calibration test binds the copy's claims to the
  held-out population; confirmed to fail when the band cuts widen.
- The scope guard now reaches all three bands; confirmed to fail when a
  cross-method claim is injected into the uncertain copy.
- meme_gate.json is committed byte-identical to the exported artifact, and
  the production chunk was verified to contain those exact bytes.
- Estimator CSS moved out of the component so a non-MEME method fetches
  zero estimator bytes in a production build, not just in dev.

369 unit tests, 5/5 e2e, parity gate green.
The `test` script is already `npm run test:unit -- --run`, so the job's extra
`--run` reached vitest twice and it exited with "Expected a single value for
option --run, received [true, true]" before running a single test.

The job has failed on every push since it was added, and the message reads like
a vitest bug rather than a mistake in the invocation.
@stevenweaver
stevenweaver merged commit 8cdd66d into main Aug 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant