CI and Testing¶
Five workflows under .github/workflows/, plus a pre-commit config that mirrors
the fast half of them locally.
| Workflow | Runner | Trigger | Gates |
|---|---|---|---|
lint.yml |
GitHub-hosted | push to main, every PR |
black, isort, ruff, the mypy ratchet, cargo fmt, cargo clippy -D warnings, cargo test |
test-cpu.yml |
GitHub-hosted | push to main, every PR |
pytest tests/ on Python 3.10 + 3.12 with no GPU, behind a --min-passed coverage floor |
test-gpu.yml |
self-hosted oasr-gpu |
nightly + manual | the full suite with --strict-assets, split per family, plus the slow and concurrent markers |
test-gpu-modal.yml |
GitHub-hosted → Modal GPU | manual only | the same per-family split, on one rented accelerator per architecture (sm_80 / 89 / 90 / 100 / 120) |
docs.yml |
GitHub-hosted | PRs and pushes touching docs/, mkdocs.yml or the hook |
mkdocs build --strict (broken links and anchors, pages missing from the nav); deploys the site to GitHub Pages from main |
Running the gates locally¶
pip install -r requirements-dev.txt
black --check oasr/ tests/ benchmarks/ scripts/ ci/
isort --check-only oasr/ tests/ benchmarks/ scripts/ ci/
ruff check oasr/ tests/ benchmarks/ scripts/ ci/
python scripts/mypy_ratchet.py
cd rust && cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
cd rust && cargo clippy -p oasr-core --lib -- -D warnings
Or install the hooks and let them run on commit:
Tool versions are pinned in requirements-dev.txt and
.pre-commit-config.yaml, and the two files must agree: black and ruff reformat
differently across releases, so an unpinned toolchain turns a green PR red on an
upstream release with no change to this repo.
A full local GPU run additionally needs the external assets exported:
set -a; source .env; set +a # CKPT_DIR, AUDIO_DIR
export WAV_DIR="$AUDIO_DIR"
export AUDIO_PATH=$(ls "$AUDIO_DIR"/*.wav | head -1)
export ZIPFORMER_CKPT=/path/to/icefall-asr-librispeech-zipformer-large-cr-ctc-...
pytest tests/ -m "not slow and not concurrent" -q -rs
Read the asset table at the end before believing the count.
The asset gate¶
pytest tests/ used to report a fully green suite while silently skipping every
real-checkpoint test. Several environment variables gated those suites, each read
ad hoc in its own module with its own default path, and pytest's summary does not
distinguish "passed" from "skipped because the checkpoint was not on this box".
That is how the audio_scale defect — which drops the leading token of every
transcript — shipped and survived a green suite.
Two mechanisms close it.
Every gated input is declared once, in tests/assets.py, together with the
marker file that proves it is really there. An HF snapshot present as dangling
LFS symlinks with no payload is not a usable checkpoint, and the probe says so.
assets.require(...) is the single skip site, so every skip is counted and every
reason string is uniform.
Every run prints what it did not check. Not behind a flag — always:
external assets:
CKPT_DIR ok /.../20210610_u2pp_conformer_exp_librispeech
WAV_DIR ok /.../wavs [3 test(s) skipped: incomplete]
WHISPER_CKPT MISSING /.../whisper-tiny [18 test(s) skipped]
...
-> 21 test(s) skipped for missing or incomplete assets. A green run does not
cover them; use --strict-assets to make this fatal.
An asset can be present and still too small for a test: a WAV_DIR with fewer
clips than the test decodes, a release without the sub-path the test reads, an
audio file too short to chunk. That is the same gap as a missing asset, so it
goes through the same gate, is counted against the asset (incomplete) and
fails under --strict-assets.
--strict-assets makes it fatal, which is what the GPU workflows run. An asset a
runner genuinely cannot have is named one by one, so the gap sits in the workflow
file where it can be read rather than inside a skip nobody reads:
An unknown name is a usage error, not a silent no-op.
--min-passed N guards the passed count. A CUDA guard added at the wrong
scope, or an import that quietly turns a module into skips, otherwise shrinks
coverage without turning anything red. Raise the floor to just under the observed
count after a green run; never lower it to make a red run pass.
Adding a gated input¶
Add an Asset to tests/assets.py; nothing else changes. Then, in the test:
or, inside a fixture or body:
path = assets.require("MY_CKPT")
wavs = assets.require_wavs(4) # also gates on the file count
assets.require_contents("MY_CKPT", (Path(path) / "exp").is_dir(), "no exp/ subdir")
The tests/helpers/audio.py readers (waveforms(wav_dir, n), speech_corpus)
gate their own count the same way.
Never read the environment variable directly, and never pytest.skip on what
an asset holds. Both are exactly the pattern that produced the trap: the
variable is unset when the asset resolves through $OASR_ASSET_ROOT, and a
bare skip is not counted and stays green under --strict-assets. Taking [:n]
of a listing without the count gate is the quiet version, because the test then
runs on however many files there are. A skip that splits valid assets into
variants is different: of a causal and a non-causal release, one test of a pair
must skip whichever is supplied, and that skip stays a plain one.
The compiled extension is an asset too¶
oasr._C (the CPU decoders, the word-alignment pass, the streaming feature
append) is declared in tests/assets.py as OASR_C, kind build. It is
present when it imports with its alignment, decoder and streaming
submodules. Tests that need it carry @pytest.mark.requires_assets("OASR_C"),
never an importorskip. The two behave the same in a skip run, but only the
declared asset is counted, and under --strict-assets it fails.
The self-hosted nightly is where that matters. actions/checkout runs
git clean -ffdx, which deletes every gitignored build output, so a job with
no build step tests a checkout with no _C. With importorskip, those tests
skipped and --strict-assets stayed green. Every test-gpu.yml job now
rebuilds the extensions (pip install --no-deps --no-build-isolation -e .) and
runs python -c "import oasr._C, oasr._core" before pytest, so a broken build
fails that step and no test runs. The cargo target directory is kept outside
the workspace, so the clean does not discard the Rust build.
The accuracy gate¶
Everything else in this repo compares tensors. That is necessary and it is not sufficient: a parity oracle feeds identical features to both sides, so a bug in how audio becomes features cancels on both and the suite stays green.
tests/accuracy/test_accuracy.py measures WER on a fixed 200-utterance LJSpeech subset and
fails when a rate exceeds its recorded value in ci/wer-reference.json plus a
tolerance of 0.3 absolute.
Those rates are for regression detection, not publication. LJSpeech is out-of-domain for every architecture and the subset is 200 utterances. Do not quote them as OASR's WER.
The tolerance is slack for a different GPU or torch build, not for noise: the
offline rates are bit-identical across consecutive runs and across
max_batch_size 1 / 8 / 16 / 32, which also makes "batching does not change the
answer" a measured property rather than an assumption. Where that does not
hold the entry says so and records the spread instead of widening the tolerance —
see the transducer and nemotron_streaming comments.
An entry may pin decode_options, which is the decode configuration the rate was
measured under. The llm row does: for a speech-LLM the prompt is part of what
is being asked, and a different prompt moves the WER with no defect behind it.
That row is also the family's only end-to-end check — its decode path is the one
with per-row KV offsets, group merging, paged KV and step CUDA graphs behind it,
and a graph that captured the wrong thing returns a plausible transcript, which
is exactly what a tensor oracle cannot see.
Manifests ship; audio does not¶
A manifest is JSON Lines — {"id", "audio", "text"} — with audio resolved
against --audio-root, so 200 utterances is 31 KB of checked-in text. Build one
for a corpus you have:
python benchmarks/run.py --family accuracy --build-manifest out.jsonl \
--audio-root $WAV_DIR --transcripts .../metadata.csv --limit 200
One trap. Whisper's EnglishTextNormalizer deletes (...) and [...]
spans, because in many corpora they annotate non-speech events. In LJSpeech the
parenthetical is read aloud, so leaving brackets in the manifest drops those
words from the reference while the hypothesis keeps them — scoring a correct
transcription as a run of insertions. --build-manifest unwraps brackets by
default (--keep-brackets to opt out) and
TestManifest::test_no_bracketed_spans_survive keeps it that way.
oasr/testing/wer.py reports the corpus rate — total edits over total
reference words — not the mean of per-utterance rates. Averaging weights a
three-word utterance like a thirty-word one and is not comparable to any
published figure.
Sweeping rather than gating is benchmarks/run.py --family accuracy — see
benchmarks.md.
Two GPU backends¶
The GPU suite runs in two places from one family split, ci/gpu_suites.py. A
split maintained twice drifts, and the failure mode is a test file that runs
nowhere — so --check is both a pre-commit hook and a lint.yml step. Since
the families are directories under tests/, it checks two things: every
tests/**/test_*.py sits under exactly one family directory, and no two test
modules share a basename (pytest imports them by basename, so a clash is an
import error rather than a merge).
self-hosted (test-gpu.yml) |
Modal (test-gpu-modal.yml) |
|
|---|---|---|
| GPU | the reference box — one SM | one accelerator per architecture, ARCH_SWEEP |
| checkpoints | already on disk | a Volume the container fills itself |
| cost | none | GPU-minutes per run |
| when | nightly sweep | on demand only — per PR, or when the box is busy or wedged |
Use the self-hosted box as the primary. The assets are local and it is a
machine somebody can look at. Modal exists because that box is also the
benchmarking machine (CI competing for clocks is exactly the noise the perf work
fights), because its GPU has fallen off the bus in a way only a host reset
recovers, and — the reason that grew — because one SM is not a test matrix.
Kernel selection reads the compute capability in half a dozen places,
_default_cuda_cflags emits a different -gencode per arch, and a kernel that
compiles for an architecture can still take CUTE_INVALID_CONTROL_PATH on the
silicon — compiling is cross-targetable, running is not. Code that assumed one
architecture is invisible until a second one runs it.
What a matrix does not buy is coverage of the shape-aware rule tables. Those
are pure functions of (op, M, N, K, dtype, sm) — test_gemm_heuristic.py used
to skip its whole rule-table suite unless the running SM matched the rules, which
meant an SM120 table nobody could validate anywhere else. It now checks every
registered architecture's table from any box, and the matrix is left to do what
only it can: run the kernels.
Nothing triggers the Modal workflow automatically — no schedule, no
pull_request — because every run is rented GPU-minutes. Run it from
Actions → test-gpu-modal → Run workflow, or:
gh workflow run test-gpu-modal.yml -f pr=123 # test PR #123
gh workflow run test-gpu-modal.yml -f pr=123 -f gpus=H100 -f suites=kernels
gh workflow run test-gpu-modal.yml --ref my-branch # a branch, no PR
With pr set, the job resolves the PR's head commit once, at the start (a push
during the run is not what gets tested), checks that commit out, and posts the
outcome on it as the test-gpu-modal commit status, so the result shows on the
PR. A PR from a fork is refused unless allow_fork is ticked — see
Security. The same entry points run from a
developer box:
modal run ci/modal_app.py::main --gpus all # one per architecture
modal run ci/modal_app.py::main --gpus H100,B200 # a subset
modal run ci/modal_app.py::main --gpus L40S --suites kernels # one arch, one family
--gpus all expands to ARCH_SWEEP in ci/modal_app.py: one accelerator per
distinct SM rather than one per product, because two L40S-class cards run the
same kernels. Every (gpu, suite) pair is a separate container and they all run
concurrently.
| accelerator | SM | notes |
|---|---|---|
A100-40GB / A100-80GB |
sm_80 | Ampere; matches the A30 dev box's arch |
A10G |
sm_86 | |
L4 / L40S |
sm_89 | Ada |
H100 / H200 |
sm_90 | Hopper — the sm_90a arch-conditional path |
B200 |
sm_100 | Blackwell datacentre |
RTX-PRO-6000 |
sm_120 | GB202, the SM the reference box ships on |
The assets Volume fills itself¶
ci/modal_assets.py names every external asset by (source, pinned revision) —
a Hub repo id, a URL, a handful of raw files out of a git tree — and
modal run ci/modal_app.py::fetch_assets # ~20 GiB, once
modal run ci/modal_app.py::fetch_assets --dry-run # the plan, first
modal run ci/modal_app.py::fetch_assets --skip SPEECH_LLM_CKPT
downloads them inside the container, straight into the Volume they will be
read from. It is idempotent: an asset whose marker file is already in place costs
one stat. The workflow runs it on every dispatch so a new source reaches CI
without anybody remembering to.
This replaced a 29 GiB upload from whichever developer machine had .env set.
That upload was slow, it was not reproducible — whatever happened to be on that
box, at whatever revision — and it made a CI whose job is to check the code
depend on one person's filesystem. Revisions are pinned for the same reason
ci/wer-reference.json records a checkpoint: an asset that moves under CI turns
a WER regression into an unattributable one.
Not everything has a public source. Four assets are listed in
modal_assets.NO_PUBLIC_SOURCE with the reason — WeNet's librispeech
u2pp_conformer release (wenet.org.cn/downloads now 404s for it, and the only
Hub mirror ships an exported TorchScript final.zip the converter cannot read),
the generated tiny Qwen2-Audio fixture, and a locally built k2 HLG.pt. Those
are uploaded once, per asset:
set -a; source .env; set +a
modal run ci/modal_app.py::seed_assets --dry-run
modal run ci/modal_app.py::seed_assets --only CKPT_DIR,SPEECH_LLM_TINY
ALLOW_MISSING is derived from that same dict rather than written out again, so
an asset that gains a public source drops off the allow-list by itself — an
allow-list that outlives the gap it documented is the exact failure --strict-assets
exists to prevent.
A second Volume caches the JIT artifacts, partitioned per architecture under
/jit/sm<cc>. Without it every run recompiles the kernels from cold, because
they JIT on first call. The partition is not a correctness fix — the cache key
already covers the target, since _default_cuda_cflags puts -gencode=…sm_XX
and -DOASR_TARGET_SM into the flags JitSpec._content_hash hashes — it is so
that five suites on five GPUs are not five concurrent writers to one Volume
prefix.
The image, and what is not in it¶
One image serves every architecture. The CMake half of the build compiles no
CUDA (OASR_SOURCES is six .cc files), so CUDA_ARCHITECTURES is the whole
list 80;86;89;90;100;120 and costs nothing; the kernels that actually differ
per arch are JIT-compiled on the GPU that will run them. An image per arch would
mean an image build per arch for no coverage.
The base is nvidia/cuda:*-devel, not -runtime, because the JIT shells out to
nvcc at run time. Torch comes from the cu128 index — the first one with
sm_100 and sm_120 wheels.
3rdparty/cutlass is not uploaded. It is 26 069 files and 2.0 GiB, and it
was going into the image context on every modal run, dwarfing the code change
that prompted the run. The container fetches the pinned commit from GitHub as a
source tarball in its own cached layer, keeps the two include roots
oasr/jit/env.py collects, and symlinks it to /repo/3rdparty/cutlass — which
is the only place that function looks. The sha comes from
git ls-tree HEAD 3rdparty/cutlass, i.e. from the index, so it is correct
whether or not the submodule is checked out. That is why the workflow's
actions/checkout no longer asks for submodules.
Security — this repo is public¶
test-gpu.yml triggers on schedule and workflow_dispatch only. Never add
pull_request — a fork PR would execute arbitrary code on the maintainer's
machine. The Modal workflow does not have that problem: the GitHub-hosted job
only holds a token and the GPU work happens in Modal's sandbox. It is dispatch-only, so
only someone with write access can start it. A dispatch with pr=<n> for a PR
from a fork is refused unless allow_fork is ticked: the GitHub-hosted job
imports the checked-out ci/modal_app.py with the Modal token in its
environment, so a fork's code could read that token. Review the diff first.
Why test-cpu.yml builds nothing¶
oasr/_C*.so (CMake + CUDA) and oasr/_core*.so (setuptools-rust) are both
absent on a GitHub-hosted runner, and pip install -e . would need the CUDA
toolkit for a job that cannot use a GPU anyway. It does not need to: import
oasr works without either, because the CUDA kernels JIT-compile on first call
and _C is loaded lazily. The job puts the repo on PYTHONPATH and runs pytest
directly.
Files whose every test allocates on device="cuda" carry a module-level
pytestmark skip, the tests that need the compiled _C gate on the OASR_C
asset (see below), and @pytest.mark.cuda actually gates.
Three rules follow, each of which cost a red run to learn:
- "No GPU" and "no CUDA toolkit" are different conditions, and a developer box
has both. Reproducing a GitHub runner locally therefore needs four things
removed, not one — the GPU, the compiled extensions, the checkpoints, and
nvcc plus a warm
~/.cache/oasr/jit. Anything that JIT-compiles will otherwise pass locally off the cache and fail in CI. - No pipes in a step that is supposed to gate. GitHub runs steps under
bash -e, sopytest ... --collect-only | tail -5reports tail's exit status. A collection error then looks like a passing step, and the real failure surfaces later as a bareexit code 2— pytest'sINTERRUPTED, i.e. "a module failed to import", not "a test failed". A test module that imports a third-party package at module scope withoutimportorskipcan take down the entire session. - No source file may be gitignored. A bare
checkpoints/pattern matches a directory of that name at any depth, which once kept the wholeoasr/checkpoints/package out of every clone while the local worktree had it — and silently excluded those files from black, isort and ruff, all three of which respect.gitignore. Patterns are anchored now (/checkpoints/) andscripts/check_no_ignored_sources.pyruns as a pre-commit hook. It only works locally: on CI the files are already absent, so there is nothing left to find.
Python 3.10 is in the matrix because pyproject.toml declares
requires-python = ">=3.10", and a floor nobody tests is a guess. That floor is
real: oasr/decoder/wfst/graph_image.py annotates defaults with PEP 604 unions
(np.ndarray | None) and has no from __future__ import annotations, so
importing it on 3.9 raises TypeError at def time.
Why mypy is a ratchet, not a gate¶
mypy oasr/ reports several hundred errors. Nearly all are untyped-torch noise
(no-any-return, attr-defined on tensors) rather than defects, and the CuteDSL
kernels under oasr/kernels/cute/ are excluded outright because the
cutlass.cute decorators rewrite function bodies and mypy sees the DSL's
injected names as undefined.
Gating on zero would mean the job is red forever and nobody reads it. Gating on no file got worse is a check that passes today and still fails on a real regression:
python scripts/mypy_ratchet.py # check against ci/mypy-baseline.json
python scripts/mypy_ratchet.py --update # after a cleanup, lower the numbers
A file whose count drops is reported as slack to reclaim, not an error, so a
cleanup commit does not have to touch the baseline to stay green. A file whose
count rises fails the job and names the file. Fix the new error or narrow it
with a targeted # type: ignore[code] — do not raise the baseline.
Because the baseline counts errors coming out of third-party stubs, the
environment is part of it and is pinned in ci/mypy-requirements.txt:
pip install --index-url https://download.pytorch.org/whl/cpu torch
pip install -r ci/mypy-requirements.txt -r requirements-dev.txt
Two settings there are load-bearing:
[tool.mypy]sets nopython_version. It applies to third-party stubs too, so pinning it below the interpreter aborts the run before a line of this repo is checked ("3.8"dies on torch'smatchstatements;"3.10"dies on numpy's PEP 695typestatements). mypy runs at the interpreter's version; CI pins that to 3.12. Real 3.10 compatibility is covered bytest-cpu.ymlactually running the suite on 3.10.oasr/decoder/wfst/lattice.pyis excluded. mypy hits an INTERNAL ERROR on itsnp.lexsortagainst recent numpy stubs, which kills the whole run; a per-moduleignore_errorsdoes not help, because the crash happens during checking rather than reporting.
Known lint deviations¶
B905(zip(..., strict=)) is in ruff's ignore list. It is a real bug class and enabling it is a worthwhile follow-up, but decidingstrict=Trueper call site requires knowing the lengths always match, and there are dozens of them across the engine and the kernels. Turning it on blind would be a guess, not a fix.oasr-coreallowsclippy::useless_conversionat crate scope — it fires inside#[pyfunction]'s own expansion, where a function-level#[allow]does not reach.oasr-server-grpcallowsclippy::result_large_err—tonic::Statusis 176 bytes and is tonic's type, not ours.
Rust: never cargo --workspace¶
oasr-core enables pyo3/extension-module while oasr-server enables
pyo3/auto-initialize; the two are mutually exclusive and Cargo unifies features
per build, so one invocation covering both fails to compile. oasr-core is
excluded from default-members and linted in its own step. Run cargo from
rust/, not the repo root, so the target-dir redirect applies — see
AGENTS.md.