Skip to content

fix(dataset): decide seq_encoding from the whole corpus, and record it - #232

Merged
jayhesselberth merged 1 commit into
mainfrom
fix/seq-encoding-fallback-230
Aug 26, 2026
Merged

fix(dataset): decide seq_encoding from the whole corpus, and record it#232
jayhesselberth merged 1 commit into
mainfrom
fix/seq-encoding-fallback-230

Conversation

@jayhesselberth

Copy link
Copy Markdown
Member

Closes #230.

What main does today

I reproduced all three defects before touching anything, and one detail changes the picture from the issue:

corpus main
no maps at all dataset falls back, model is still built for 36 channels → RuntimeError: expected input[4, 4, 11] to have 36 channels at the first forward pass
chunk 0 fine, later chunks empty passes the check, and those chunks encode to all-zero sequence channels — silent, per chunk
chunk 0 empty, rest fine whole corpus flips to base_onehot → same channel-count crash

The silent-train from 0.6.7 is now a confusing crash instead — except for the ragged corpus, which is still silently wrong, and except for sequence-blind arms, which train fine on the wrong input. The middle state is the worst of the three: the dataset half falls back and the model half does not, so the run dies several steps later on a message naming neither the corpus nor the encoding.

Changes

The decision is a whole-corpus count, never chunk 0. _signal_kmer_coverage is one vectorised pass over the CSR offsets and the context column on the streaming path — data already in memory — and the message names the affected fraction. A warning that does not say how much of the corpus it covers is the one that got read past for four releases.

A corpus carries the maps for every chunk or for none, and partial coverage raises. None is the version-skew case the fallback exists for. Anything in between is damage, where both available answers are the same silent representation change: encoding the uncovered rows gives them the all-zero channels above, and switching a 6.7M-chunk corpus to base_onehot on the strength of a few bad rows discards the encoding for every good one. data merge already refuses to merge a mapped corpus with an unmapped one (_validate_member_sets folds the three CSR spellings into one logical member), so ragged means a damaged file rather than skew. The escape hatch is --seq-encoding base_onehot, which is explicit.

An encoding named on the command line is not substituted. --seq-encoding signal_kmer over a corpus that cannot supply it stops the run; taking the default still falls back, which is the case the fallback exists for. click's get_parameter_source separates the two, and --encoding-fallback / --no-encoding-fallback overrides either way (LeechDataset(allow_encoding_fallback=...) in code, default True, so nothing outside training changes behaviour).

The model and config.json both come from LeechDataset.effective_seq_encoding. That is the issue's suggested minimum, and it is what makes a fallen-back arm auditable after the fact — one field, where before there was no way to find out at all. It also keeps #217's ONNX contract honest: the contract is derived from the same config and exists precisely so a non-Python consumer can trust the input spec.

This half has to land with the policy fix rather than without it. On its own it would make the whole-corpus fallback train coherently to completion — restoring exactly the silent success the issue is about.

Three other sites where a fallback can never work, because the trained model's sequence branch has a fixed channel count and any fallback guarantees a shape error several steps later: eval test and both calibration paths pass allow_encoding_fallback=False; model benchmark builds its model from the effective encoding.

Tests

16 new tests, all failing on main (run against the main checkout's src to confirm) except the two healthy-corpus controls:

  • test_signal_kmer.py::TestEncodingFallbackPolicy — ragged stops the run in both flag positions; one empty row is reported as one row, not as the whole corpus; a ragged corpus still reads as base_onehot when asked for by name; a corpus with no maps at all falls back and the warning carries the fraction; the CSR count and the per-dict count agree.
  • test_config_propagation.py::TestSeqEncodingProvenance — the config records the effective encoding; the checkpoint's weights load into a model rebuilt from the saved config alone; explicit --seq-encoding errors on the CLI while the default does not; --encoding-fallback overrides.
  • test_onnx_export.py::test_a_config_that_misstates_its_encoding_cannot_be_exported — the issue's open question ("I have not tested whether that fails loudly at export"). It does: refused at load_state_dict with a size mismatch, not published as a 36-channel contract. Unreachable from leech model train now, but it keeps a hand-edited or older config from being trusted.

Full suite: 1510 passed, 44 skipped (3:01, in a Slurm allocation). ruff format / ruff check clean. ty check src/leech/ reports the same 2 pre-existing diagnostics as main (crf/evaluate.py, signal_refine.py), neither in a touched file.

CHANGELOG, CLAUDE.md (new "The sequence encoding is decided once, from the whole corpus, and recorded" section) and docs/reference/cli.md updated.

🤖 Generated with Claude Code

`LeechDataset` fell back from `signal_kmer` to `base_onehot` on the
strength of chunk 0, at WARNING, and the saved config still recorded the
requested encoding. Three separable defects that combine into a run which
finishes, looks fine, and trained on a different model input than was
asked for — `(4, kmer_len)` where `signal_kmer` is `(36, signal_len)`.

The decision is now a whole-corpus count (`_signal_kmer_coverage`): one
vectorised pass over the CSR offsets and the context column on the
streaming path, and the message names the affected fraction. Chunk 0 was
wrong in both directions — one empty first row flipped an entire corpus
to `base_onehot`, and a corpus whose first row was fine encoded every
later row that had no map from data that isn't there, as all-zero
sequence channels, with nothing raised and nothing logged.

A corpus carries the maps for every chunk or for none. None is the
version-skew case the fallback exists for; partial coverage now raises,
because both available answers are the same silent representation change
and `data merge` already refuses to merge a mapped corpus with an
unmapped one — so ragged means a damaged file, not skew.

An encoding named on the command line is no longer substituted:
`--seq-encoding signal_kmer` over a corpus that cannot supply it stops
the run, taking the default still falls back, and
`--encoding-fallback` / `--no-encoding-fallback` overrides either way
(`LeechDataset(allow_encoding_fallback=...)` in code).

The model and `config.json` are both built from
`LeechDataset.effective_seq_encoding` rather than the request. That is
what makes a fallen-back arm auditable after the fact, and it keeps
config and exists precisely so a non-Python consumer can trust the input
spec. It also has to land with the policy fix rather than without it:
alone, it would restore the silent success this is about.

Three other sites where a fallback can never work, because the trained
model's sequence branch has a fixed channel count: `eval test` and both
calibration paths refuse it, and `model benchmark` builds its model from
the effective encoding.

Before this the middle state was the worst of the three — the dataset
half fell back and the model half did not, so a run died at the first
forward pass with a channel-count RuntimeError naming neither the corpus
nor the encoding.

Closes #230
@jayhesselberth
jayhesselberth force-pushed the fix/seq-encoding-fallback-230 branch from 87eacfc to c588c09 Compare August 26, 2026 11:43
@jayhesselberth
jayhesselberth merged commit 1db7dbb into main Aug 26, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the fix/seq-encoding-fallback-230 branch August 26, 2026 11:51
jayhesselberth added a commit that referenced this pull request Aug 26, 2026
Minor rather than patch: new capability throughout, and two behaviour changes
-- one confined to CRF training, one to how a corpus that cannot supply
`signal_kmer` is handled.

The release is the second half of the CTC-CRF port plus ONNX export:

- `leech.crf.evaluate` (#224) -- decode a corpus, match to references by edit
  distance, report per group. The generic half of evaluation; what a panel is
  stays with whatever defines the panel.
- ONNX export for the classifier arms and the CRF encoder (#217, #222), dynamo
  exporter at opset 18, each with a contract sidecar and a round-trip check
  against torch across the serialization boundary.
- `leech model train-crf` (#219) -- the CLI for the trainer, plus the corpus
  builder (`plan_corpus`/`build_corpus`) and `CrfTrainer` itself.
- The signal-level k-mer encoding now comes from escapepod-signal (#222)
  rather than being held in a cdylib no Rust consumer could link.

Two behaviour changes, both worth reading before upgrading:

`signal_kmer` no longer degrades quietly (#230/#232). The encoding is decided
from the whole corpus rather than chunk 0, an encoding named on the command
line is no longer substituted, and the config records what the run actually
used. This one is coupled to the ONNX work above and is why the release waited
for it: the contract is derived from the config and exists so a non-Python
consumer can trust the input spec, so a config that misstates its encoding is
now refused at export rather than published.

CRF batch order (#231). `CrfTrainer.train` re-seeded `default_rng(seed)` and
replayed the permutation `resolve_split` had already drawn, so epoch 1 trained
on `pi(pi(train))`. Fixed, which means a given seed now sees different batches
-- numbers from a seed will not reproduce against 0.8.0. Batch order alone
moves a 32-epoch run's final training loss by more than 2x, so a seed is one
draw from that spread, not a fixed point.

The CRF trainer was validated against the implementation it was ported from
over six paired seeds: balanced recall differs by -0.17pp +/- 0.28pp, sign test
p = 0.688, against a within-arm seed range of 0.71pp.

Also in this commit, not from the PRs:

- README listed neither `leech model train-crf` (a shipped command missing
  from the CLI table) nor ONNX export at all, including the single-BCE-logit
  contract point that makes a misread graph silently wrong.
- CLAUDE.md said "feature-complete (v0.7.0)" while listing CRF and ONNX.
- CHANGELOG's Unreleased section had accumulated three separate `### Added`
  headings from different PRs; consolidated to one Added/Changed/Fixed set.

Full suite 1512 passed, 44 skipped. Docs build clean. Both lockfiles verified
against their manifests (`cargo metadata --locked`, `uv lock --check`).
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.

dataset: signal_kmer silently degrades to base_onehot — a warning, decided from one chunk, and the saved config still says signal_kmer

1 participant