Skip to content

feat(export): ONNX for the classifier arms and the CRF encoder - #220

Merged
jayhesselberth merged 3 commits into
mainfrom
feat/onnx-export
Aug 25, 2026
Merged

feat(export): ONNX for the classifier arms and the CRF encoder#220
jayhesselberth merged 3 commits into
mainfrom
feat/onnx-export

Conversation

@jayhesselberth

@jayhesselberth jayhesselberth commented Aug 25, 2026

Copy link
Copy Markdown
Member

Closes #217, and delivers the CRF half of the export at the same time — the two
need the same exporter settings, the same round-trip check, and the same idea of
a contract, so the shared layer is justified by having two consumers rather than
by anticipation.

What lands

  • leech model export --format onnx, beside the existing --format torch
    (unchanged default)
  • leech.crf.export.export_crf_onnx — the encoder only; the decode is not
    expressible in standard ONNX ops, which is why escapepod-demux owns it
  • leech.onnx_export — the shared exporter, round-trip verifier, and contract
  • new onnx extra; CI installs it so the round-trip tests run rather than
    skip

The caveat #217 flagged, pinned

dynamo=False — the obvious first attempt — fails on these architectures with
Unsupported: ONNX export of operator adaptive_avg_pool1d. That is an exporter
limitation, not a model problem, but the message reads like one. It is
documented where someone will hit it, and test_export_uses_the_dynamo_path
exports exactly that shape so a regression to the legacy exporter says so.

What a graph cannot carry

Each export writes a contract beside it:

invisible in the graph consequence if guessed
which input is which seq_channels = sum(signal_kmer_context) * 4 + 4 re-derived by hand
output is a single BCE logit read as a 2-class softmax, every call is wrong and nothing errors
CRF standardisation in neither config nor checkpoint — a consumer without it decodes silently worse
CRF references are target[state_len:] full-length matching inflates every edit distance and compresses the margin

The consequential decision

#217 asks whether the signal_kmer encoding belongs in front of the graph or
stays a documented call. It stays a documented call, and the contract names
the function and its parameters.

The scatter needs the base-to-signal map, which comes from the move table. An
ONNX prefix would still have to take that map as an input — so it moves where
the scatter happens without removing the consumer's obligation, and it bakes
signal_kmer_context into the graph. Same choice escapepod-rs's charging bundle
makes: the recipe travels in metadata.json, never in flags.

Correction (1b8f40b)

An earlier revision of this PR said "leech-core already ships the encoder, so
depend on it rather than reimplementing". That is true for a Python consumer and
false for the one that matters: leech_core is crate-type = ["cdylib"], a
Python extension module, so escapepod-rs cannot link it. As written it read as
though the one-definition problem were solved. It is not, and the text now says
so.

The real fix is upstream, and is filed as rnabioco/escapepod-rs#271:
encode_signal_kmer_inner is pure, dependency-free and carries no model
vocabulary, and escapepod-signal already owns mapping — which produces the
map this consumes. Producer upstream, consumer downstream, which is backwards.
Until it moves, a Rust consumer transcribes the rule and the mitigation is a
cross-language golden, as with the CRF decode and the charging features.

The decision to keep the encoding out of the graph is unchanged and stands on
its own two reasons above; only the "already solved" framing is gone.

Verification

Across the serialization boundary, not in process:

CRF encoder      onnxruntime vs torch   3.58e-07
float32 eps                             1.19e-07

matching the 4.77e-07 / 1.19e-06 #217 measured on production classifier arms.

Testing

  • 18 export tests: round trip for both model families, the time-major output
    shape, dynamic batch on axis 1 (the CRF graph is time-major, so the naive
    axis-0 choice pins the graph to its traced batch), the contract fields, and
    the refusal to export without standardisation
  • Full suite 1460 passed, 44 skipped
  • ruff format --check, ruff check, ty check, zensical build clean

🤖 Generated with Claude Code

Closes #217.

`torch.export` makes a trained model loadable by anything with PyTorch and by
nothing else, so a runtime consuming ONNX — which is what escapepod-rs runs —
could not load a leech model at all. That gap has been recorded downstream as
though "not a shippable format" were a property of the models. It is not: the
graphs convert and agree with torch to within float32 rounding.

`leech model export --format onnx` beside the existing `--format torch`
(unchanged default), and `leech.crf.export.export_crf_onnx`, both over a shared
`leech.onnx_export`. The shared layer is justified by having two consumers, not
by anticipation: the CRF encoder and the classifier arms need the same exporter
settings, the same round-trip check, and the same idea of a contract.

ALWAYS THE DYNAMO EXPORTER, OPSET 18. `dynamo=False` is the obvious first
attempt and fails on these architectures with

    Unsupported: ONNX export of operator adaptive_avg_pool1d,
    output size that are not factor of input size

That is an exporter limitation and not a model problem, but the message reads
like one and sends whoever hits it looking in the wrong place, so it is
documented where they will be and pinned by a test that exports exactly that
shape.

EVERY EXPORT WRITES A CONTRACT beside the graph, because two things a consumer
needs are invisible in it. Which input is which: arity and channel counts are
visible, roles are not, and `seq_channels = sum(signal_kmer_context) * 4 + 4` is
not something a consumer should re-derive. And what the output means: leech
classifiers emit a SINGLE BCE logit, not a two-class softmax, and read as the
latter every call is wrong while nothing errors. The CRF's contract adds
standardisation — in neither the architecture config nor the checkpoint, since
the trainer derives it from the corpus — and its emitted references
(`target[state_len:]`), computed from the `state_len` the encoder declares so no
caller can hand it full-length targets and inflate every edit distance.

THE SIGNAL_KMER INPUT STAYS OUTSIDE THE GRAPH, deliberately. #217 raises this as
the consequential choice, so: it is `encode_signal_kmer` output, a scatter of
the one-hot k-mer context along the signal axis built in the dataset from the
base-to-signal map. An ONNX prefix would still have to take that map as an
input — the map comes from the move table — so it moves where the scatter
happens without removing the consumer's obligation, and it would bake
`signal_kmer_context` into the graph. leech-core already ships the encoder, so
depending on it keeps one definition; a reimplementation creates a second that
diverges silently, which is how a downstream repo reproduced a superseded
feature definition for two months. The contract names the function and its
parameters instead — the same choice escapepod-rs's charging bundle makes by
carrying its recipe in metadata.json rather than in flags.

Verification crosses the serialization boundary rather than asserting in
process: onnxruntime against torch, 3.58e-07 for the CRF encoder against a
float32 eps of 1.19e-07. It also pins onnxruntime's thread pool, whose default
affinity call fails inside any cgroup-restricted allocation and floods stderr
without affecting results.

New `onnx` extra (onnx, onnxruntime, onnxscript — the last is what
`torch.onnx.export(dynamo=True)` needs). CI installs it so the round-trip tests
RUN rather than skip; a skipped round trip is exactly the check being skipped.

18 tests. Full suite 1460 passed, 44 skipped.
The signal_kmer note said leech-core ships the encoder "so depend on it rather
than reimplementing". That is right for a Python consumer and wrong for the one
that actually matters here: `leech_core` is `crate-type = ["cdylib"]`, a Python
extension module, so escapepod-rs cannot link it. As written the note reads as
though the one-definition problem were solved. It is not.

State it accurately, and name the real fix. The primitive itself
(`encode_signal_kmer_inner`) is pure, dependency-free and carries no model
vocabulary — sequence ints, a base-to-signal map, a signal length and a k-mer
context in, a (4 * kmer_len, signal_len) scatter out. That is an
escapepod-signal primitive by every rule this stack already applies, and
escapepod-signal already owns `mapping`, which PRODUCES the map this consumes:
the producer is upstream and the consumer is downstream, which is backwards.

Until it moves, a Rust consumer has to transcribe the rule, and the mitigation
is a cross-language golden of the kind the CRF decode and the charging features
already have. The choice to keep the encoding out of the graph is unchanged and
still stands on its own two reasons; only the "already solved" framing goes.
jayhesselberth added a commit to rnabioco/escapepod-rs that referenced this pull request Aug 25, 2026
Closes #271.

`mapping` (#262) already **produces** a base→signal map. The primitive
that
**consumes** one — scattering the one-hot k-mer context along the signal
axis,
i.e. the 36-channel `sequence` input of a leech
`seq_encoding="signal_kmer"`
model — lived downstream in `leech_core`, a `cdylib` Python extension
module
Rust cannot link. Since that tensor is computed in the dataset it is not
in
leech's exported ONNX graph (rnabioco/leech#220), so a Rust runtime has
to build
it before it can call the model at all. Transcribing was the only
option, and
this stack's track record on transcribing is in the issue.

## What lands

`escapepod_signal::seq_encoding`:

| | |
|---|---|
| `encode_signal_kmer` / `_into` | the encoding itself, row-major `(4 *
kmer_len, signal_len)`; the `_into` form so a loop over chunks allocates
once rather than per chunk |
| `sequence_ints_with_context` | cuts the `seq_len + before + after`
window a chunk needs, padding off either end of the read with
`UNKNOWN_BASE` — leech's `extractor.py` rule, written once |
| `base_to_int` / `sequence_to_int` / `UNKNOWN_BASE` | the `A/C/G/T=U`
alphabet both take. `resquiggle::kmer_table` now shares this definition
instead of carrying its own copy |
| `KmerContext` | names the `(before, after)` pair and owns `kmer_len()`
/ `channels()` |

`KmerContext` is a named struct for the reason `CigarOp` is: transposing
an
asymmetric context displaces every k-mer window by `before - after`
bases and
still returns a correctly shaped tensor. Writing the tests turned up the
honest
version of that claim — given an already-cut `seq_ints`, the encoder
only
depends on the *sum*; the split matters where the context is **cut**. So
the
cut is now a function here too, and both the docs and
`before_and_after_are_not_interchangeable` say which step is which.

## Parity

`tests/signal_kmer_parity.rs` pins 35 cases bit-exactly against leech's
NumPy
reference (`tests/fixtures/gen_signal_kmer_golden.py`, same generator
pattern as
`gen_kmer_levels_golden.py`; numpy-only, no `leech_core` build). The
encoding is
exactly zeros and ones, so there is no tolerance to argue about. Cases
cover the
default `(4, 4)` context, asymmetric contexts, unknown bases, uracil,
empty
spans, spans off both ends, a zero-width window, and a seeded random
sweep.

The golden comes from the **NumPy** path deliberately, because leech's
two
implementations of this function disagree. The extension clamps *after*
an
`as usize` cast, so a negative start lands on `signal_len`, the span
comes out
empty, and the base disappears. Measured against `leech_core` 0.8.0:

| map | `leech_core` | leech NumPy | this PR |
|---|---|---|---|
| `[-8, 10, 20, 30]` | 60 hot | 90 hot | 90 hot |
| `[-30, -20, 40, 60]` | 0 hot | 90 hot | 90 hot |
| everything else tested | agree | agree | agree |

Which one runs today depends on whether `leech_core` is importable in
the
environment — the divergence is invisible from Python. This crate keeps
the
surviving tail: it is the readable definition, and it is what a
reference-anchored map, whose entries legitimately go negative once the
aligned
region is cropped, needs. leech's own chunking never produces a negative
entry
(`chunk_seq_to_sig[0]` is snapped to 0), so no trained model is
affected.

## Follow-up, not in this PR

Having `leech_core` delegate here, the way it already delegates the
refinement
preset, the POD5 reader cache, `span_stats` and the mapping primitives.
leech
pins `escapepod-signal` by git tag, so that waits for the next release —
at
which point the divergence above disappears rather than being pinned
twice.

## Verification

- `cargo nextest run --workspace` — 706 passed
- `cargo test --doc --workspace` — 26 passed
- `cargo clippy --workspace --all-targets` — clean
- `cargo fmt --all`
@jayhesselberth
jayhesselberth merged commit 66963f9 into main Aug 25, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the feat/onnx-export branch August 25, 2026 22:20
jayhesselberth added a commit that referenced this pull request Aug 26, 2026
#222)

* refactor(rust): take the signal-level k-mer encoding from escapepod-signal

leech held the only copy of this rule, inside a `crate-type = ["cdylib"]`
Python extension module — so a native runtime for a leech `signal_kmer` model
could not link it and had to transcribe it, which is a second definition that
diverges silently. That is not hypothetical in this stack: `extract_levels` was
written twice with different centring conventions and moved 25 of 100 features,
and escapepod-classify reproduced a superseded feature definition for two
months, its golden missing it because all 19 fixture reads took the other
branch.

It is also the natural pair to `escapepod_signal::mapping`, which *produces*
the base-to-signal map the encoding consumes. The producing half was already
upstream and the consuming half was not. escapepod-rs#272 fixed that; this is
leech taking the call (escapepod-signal v0.16.0).

Two primitives delegate, both byte-identical to what was here:

- `encoding.rs::encode_signal_kmer_inner` -> `seq_encoding::encode_signal_kmer`
- `features.rs::sequence_to_int` -> `seq_encoding::sequence_to_int`, the same
  A/C/G/T-U table with the same `-1` for anything else (upstream's
  `UNKNOWN_BASE` is `-1`)

A third overlap is deliberately NOT delegated. The k-mer context slice in
`signal_mapping.rs` covers the same range with the same padding as upstream's
`sequence_ints_with_context`, but leech needs it as BASES — it serializes
`sequence_with_kmer_context` as a string — where upstream returns ints. Same
rule, different type; swapping it would change a serialized chunk field.

This is a delegation, so identity is the only acceptable outcome, and the
evidence is that nothing had to change to accommodate it: 198 parity tests pass
untouched, including `test_backend_parity.py`, which compares every array in the
npz between the Rust and Python backends — and the Python reference is not
touched by this commit, so it is an independent implementation.

Also here, because the pin moves anyway:

- `escapepod>=0.16.0`, and the full suite re-run against that Python package as
  well as against the Rust crate (they are independent: leech_core links the
  crate statically, `leech.io` imports the wheel). 1467 passed on both.
- `uv.lock` gains onnx/onnxruntime/onnxscript/protobuf. #220 added the `onnx`
  extra to pyproject and never locked it — CI installs from pyproject so it
  worked, but `uv sync --extra onnx` would not have.
- The ONNX contract stops telling consumers to reimplement the encoder and
  names the crate that owns it.

* refactor(rust): take the k-mer context windowing from escapepod-signal too

The third and last overlap, and the one that needed an upstream change first
(escapepod-rs#274, released in 0.16.1). It could not delegate before: leech
needs the context window as BASES, because the corpus serializes
`sequence_with_kmer_context` as a string, where upstream only offered ints.
escapepod-signal now exposes both forms over one windowing rule, with
`sequence_to_int(bases) == ints` pinned by a test there.

So all three halves of the signal-level k-mer path live upstream now:
`mapping` produces the base-to-signal map, `sequence_bases_with_context` cuts
the window it covers plus the k-mer context, and `encode_signal_kmer` scatters
the one-hot context along the signal axis. leech held the middle and the last
of those, inside a cdylib no Rust consumer could link.

This is the one of the three most worth getting out of a second copy. It is
where `before` and `after` are not interchangeable — swap them and every k-mer
is read from a window displaced by `before - after` bases, silently, and
`encode_signal_kmer` cannot detect it because it only sees the total width.

Identical by construction and by test: the ranges agree
(`(seq_start - before)..(seq_end + after)` is upstream's
`core_start - before` plus `n_bases + before + after` with
`n_bases = seq_end - seq_start`), and both pad rather than shift, leech with
`b'N'` and upstream with `UNKNOWN_BASE_CHAR`, which is `b'N'`. 198 parity tests
pass untouched, and `sequence_with_kmer_context` is one of the fields
`test_backend_parity.py` compares array-by-array between the Rust and Python
backends — the Python side is not touched here, so it is an independent check.

Only `rust/Cargo.toml`'s git tag moves to v0.16.1; `Cargo.lock` pins it at
b4c9afae. The `escapepod` Python pin stays `>=0.16.0`, because the new function
is in the Rust crate and not in the Python bindings, and the two are
independent — leech_core links the crate statically, `leech.io` imports the
wheel.

Full suite 1467 passed, 44 skipped.
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.

model export: add ONNX beside torch.export — the graphs already convert, with one exporter caveat

1 participant