Conversation
A recipe that declares `runtime.modes: [wasm, native]` is promising that an operation means the same thing whichever runtime runs it, and BioChef leans on that promise in a way that is easy to miss: the editor runs the WASM build in the browser through Aioli, and the Agent runs the native build as a subprocess. The same workflow, run in two places, is expected to agree. Nothing has ever checked it. `hub.py test` runs `runtime/native/<bin>` and only that, so the artifact every browser user runs is the one nothing ran. Seven of the thirty-one recipes declare both runtimes, spanning 84 operations. `hub.py parity` builds one invocation per operation from its required parameters and declared example inputs, runs it against both builds, and compares stdout, exit status and any declared file outputs. Both sides get byte-identical inputs and identical argument strings -- file arguments are relative names, so a tool that echoes its own arguments cannot look divergent because the harness handed a path to one side and a basename to the other. It runs the WASM artifact exactly as published, built for `web,worker` with MODULARIZE=1 and INVOKE_RUN=0, by handing the module its wasmBinary directly so it never reaches the environment detection that would look for a browser fetch(). Rebuilding it in a node-friendly configuration would have been easier and would have compared an artifact nobody runs. Two outcomes are kept apart because conflating them is how this kind of gate rots. An operation that declared both runtimes and could not be compared anyway -- a missing artifact, an input type with no example -- did not keep the recipe's promise, and fails the run on its own. An operation from a single-runtime recipe was never in scope and must not fail a pull request that touches it. Both runtimes failing identically is parity but is not evidence the operation works, so it is counted separately and never satisfies --min-compared. --self-test asks the harness to prove it still fails on a divergence before its report is believed, because a comparison that quietly stops comparing looks exactly like a catalogue that agrees. Validated end to end against ksw2 by rebuilding its WASM side with a different default match score: the report says DIFFER with 196 against 294 side by side and the command exits non-zero. That exercise found a real bug here -- callMain RETURNS main's value rather than throwing when a program reports failure by returning, so reading only the thrown ExitStatus recorded 0 for every such tool. ksw2 with no arguments does exactly that, and it was reported as a divergence that was entirely the harness's own.
The harness runs after build and test, with a node toolchain because the published WASM build is JavaScript plus a .wasm file and there is no way to execute one without a JS runtime. Its self-check runs immediately before it, so a report is only believed once the harness has shown it can still fail. No --min-compared in this job on purpose: validation runs on changed recipes, and a pull request touching only single-runtime recipes legitimately has nothing to compare. What fails the step is a recipe that declared both runtimes and could not be compared anyway. The documentation is explicit about the coverage bound, because a green parity report is exactly the kind of thing that gets read as more than it says. One invocation per operation, from the required parameters. Anything reachable only through an optional parameter is not exercised -- and for ksw2 that is the uncomfortable part, since it selects among seven scalar and SSE implementations through an optional -t, and SSE is where divergence is most plausible: on x86 the native build compiles -march=native against real SSE while the WASM build goes through emscripten's SSE-over-wasm-SIMD compatibility headers. A manual sweep of 385 ksw2 invocations across all seven algorithms, four lengths, four divergence rates, band-width and Z-drop variants and seven adversarial shapes found no divergence. That is a result for ksw2, not a general one, and CI does not repeat it. Declaring cases per operation needs a schema key that does not exist yet, filed as #39.
Both existing workflows are `workflow_call` and run only when biochef-recipes invokes them, pinned at @master. A change to this repository is therefore exercised for the first time AFTER it is merged, inside somebody else's pull request -- which is a poor position for the repository that decides what gets published, and a worse one for a check meant to fail loudly. A parity harness whose own correctness is unverified is the thing it exists to prevent. Narrow on purpose: the hub's modules parse, the WASM runner parses, and the parity harness can still see a divergence. The recipe build and the tool tests need Docker, emscripten and a catalogue, and stay in the reusable workflow where they already run.
… does Two bugs, both invisible from reading the code, both found by compiling a probe -- a program that reads stdin, reports the byte count and writes out.txt -- to WASM and comparing it against its native build. ksw2 has neither a stdin input nor a file output, so nothing exercised these paths until something did. stdin was installed with FS.init() on the RESOLVED module, which throws ErrnoError: the filesystem is already initialised by then. The throw is not the damaging part. The program still ran and read ZERO bytes, so every stdin-driven tool in the catalogue -- seqtk, jq, sed, grep, gawk -- would have been reported as diverging from its native build when all that had happened is that the harness never fed it. Emscripten picks stdin up from Module['stdin'] during its own startup, so it has to be handed to the factory. Declared file outputs were read by their literal name while the native side searched by stem. A bundle declaring "out" against a tool writing "out.txt" would have held a file on one side and null on the other. The two sides now apply the same rule, and the native side no longer offers the copied binary as a candidate, because the WASM filesystem has no binary in it. That is three harness bugs now, counting callMain's return value, and every one of them would have produced false divergences on tools that were fine. A parity gate that cries wolf gets switched off, and a switched-off gate is indistinguishable from the one that was never written -- so runner_check.mjs pins both of these against a stub module encoding emscripten's actual contract, and needs no toolchain to run. Also: a missing node now says so instead of raising FileNotFoundError from inside subprocess, and a corrupt bundle.json is reported as an unreadable bundle that fails the run rather than taking the rest of the catalogue down with it. Both were reachable and neither said anything useful.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the harness half of E3 (biochef-agent#16).
The gap
A recipe declaring
runtime.modes: [wasm, native]promises an operation meansthe same thing whichever runtime runs it. BioChef depends on that in a way that
is easy to miss: the editor runs the WASM build in the browser through Aioli,
and the Agent runs the native build as a subprocess.
Nothing has ever checked they agree.
hub.py testrunsruntime/native/<bin>and only that, so the artifact every browser user runs is the one nothing ran.
Seven of the thirty-one recipes declare both runtimes — fastp, gto, ksw2, lastz,
prodigal, samblaster, seqtk — spanning 84 operations.
What this adds
One invocation per operation, run against both builds, comparing stdout, exit
status and declared file outputs. Both sides get byte-identical inputs and
identical argument strings, so a tool that echoes its own arguments cannot look
divergent because the harness passed a path to one side and a basename to the
other.
It runs the WASM artifact exactly as published —
web,worker,MODULARIZE=1,INVOKE_RUN=0— by handing the module itswasmBinarydirectly, so it never reaches the environment detection that would look for a
browser
fetch(). Rebuilding it node-friendly would have been easier and wouldhave compared an artifact nobody runs.
Two outcomes kept deliberately apart
Conflating those is how this kind of gate rots into a suggestion. It is also why
there is no
--min-comparedin per-PR validation: a PR touching onlysingle-runtime recipes has nothing to compare and must not fail for it.
Both runtimes failing identically is parity, but no evidence the operation
works, so it is counted separately and never satisfies the floor.
Checking the checker
hub.py parity --self-testasks the harness to prove it still fails on adivergence, and CI runs it immediately before the report. A comparison that
quietly stops comparing looks exactly like a catalogue that agrees.
End to end, I rebuilt ksw2’s WASM side with a different default match score:
That exercise found a real bug in my own runner.
callMainreturns main’svalue rather than throwing when a program reports failure by returning, so
reading only the thrown
ExitStatusrecorded 0 for every such tool. ksw2 with noarguments does exactly that, and it was reported as a divergence that was
entirely the harness’s fault. Fixed, and the comment says why.
What it does not cover, stated in the docs
One invocation per operation, from the required parameters. Anything behind
an optional parameter is not exercised — and for ksw2 that is the uncomfortable
part: it selects among seven scalar and SSE implementations through an optional
-t, and SSE is exactly where divergence is most plausible, since on x86 nativecompiles
-march=nativeagainst real SSE while WASM goes through emscripten’sSSE-over-wasm-SIMD compatibility headers.
I swept that by hand — 385 invocations across all seven algorithms, four
lengths, four divergence rates, band-width and Z-drop variants, and seven
adversarial shapes (homopolymers, tandem repeats,
Ns, one-long-one-short):no divergence. A result for ksw2, not a general one, and CI does not repeat
it. Declaring cases per operation needs a schema key that does not exist yet
(#39).
Not fixed here
hub.py testprints failing tools and exits 0, and passes vacuously in threeother ways (#38). Making those fatal turns the pipeline red for whatever is
failing today — a decision with a blast radius that should not ride along inside
a PR adding a new check.
Caveat for review
I have no Docker locally, so recipes using the
biowasmstrategy could not bebuilt here; the four emscripten dual-mode recipes could. Everything above was
run against a real ksw2 bundle built both ways with the pinned emsdk 4.0.18. CI
is the first place this runs over the actual catalogue.
The hub now has CI on its own pull requests
It did not before. Both existing workflows are
workflow_calland run only whenbiochef-recipes invokes them, pinned at
@master— so a change here isexercised for the first time after it is merged, inside somebody else’s pull
request. That is an awkward position for the repository that decides what gets
published, and a worse one for a check whose whole job is to fail loudly.
The added workflow is narrow on purpose: the hub’s modules parse, the WASM
runner parses, and the parity harness proves it can still see a divergence. The
recipe build and tool tests need Docker, emscripten and a catalogue, and stay in
the reusable workflow where they already run.
Green on this PR:
What is still unverified, and cannot be verified before merge
The change to
validate-recipes.yml— the steps that run parity over a realcatalogue — is not exercised by this PR, because that workflow only runs
when biochef-recipes calls it at
@master. The first real run over builtbundles happens after merge, on the next recipes PR.
What I could verify locally I did: a real ksw2 bundle built both ways with the
pinned emsdk 4.0.18, driven through
hub.py parityend to end, including aplanted divergence and the four ways the report could have been vacuous
(empty registry, all-skipped, out-of-scope-only, agreed-failure). Recipes using
the
biowasmstrategy need Docker, which I do not have here.Audit: two more harness bugs, both of which would have cried wolf
ksw2 has neither a stdin input nor a file output, so the first version was green
while two of its paths had never run. I compiled a purpose-built probe — reads
stdin, reports the byte count, writes
out.txt— to WASM and compared itagainst its native build.
stdin never arrived. It was installed with
FS.init()on the resolvedmodule, which throws
ErrnoErrorbecause the filesystem is already initialised.The throw is not the damaging part: the program still ran and read zero
bytes. Every stdin-driven tool in the catalogue — seqtk, jq, sed, grep, gawk —
would have been reported as diverging from its native build when all that
happened is the harness never fed it. Emscripten takes stdin from
Module[stdin]during startup, so it must be handed to the factory.File outputs used two different matching rules. The WASM side read the
literal declared name while the native side searched by stem. A bundle
declaring
outagainst a tool writingout.txtwould have held a file on oneside and
nullon the other. Both sides now apply the same rule, and the nativeside no longer offers the copied binary as a candidate, since the WASM
filesystem has no binary in it.
That is three harness bugs counting
callMain— and all three produce falsedivergences on tools that are fine. That is the failure mode that matters here:
a parity gate that cries wolf gets switched off, and a switched-off gate is
indistinguishable from the one nobody wrote.
So
runner_check.mjsnow pins both against a stub module encoding emscripten’sobserved contract. It needs no toolchain and runs in CI. Verified by mutation —
reverting either fix fails it:
Also fixed: a missing
nodenow says so instead of raisingFileNotFoundErrorfrom inside
subprocess, and a corruptbundle.jsonis reported as anunreadable bundle that fails the run rather than taking the whole catalogue down
with it. Both were reachable; neither said anything useful.
Full regression, every scenario: