Introduction
Status: Current Book last changed: 2026-09-16 (commit 34d249d8) This page last changed: 2026-09-01 (commit bb0bb3c9)
TalkBank is the world’s largest open repository of spoken language data. This repository (talkbank-tools) is the Batchalign3 workspace: the machine-learning pipeline that turns audio into richly annotated CHAT transcripts (automatic speech recognition, forced alignment, neural morphosyntactic tagging, and utterance segmentation), together with its web dashboard and an experimental desktop app.
The CHAT-format core (the chatter CLI, the Rust CHAT parsing and validation crates, the tree-sitter-talkbank grammar, and the CLAN command reference) now lives in the separate chatter project and is documented in its own book. This book covers Batchalign3 only.
What Batchalign3 does
| Task | Surface | Support Status |
|---|---|---|
| Transcribe, align, or morphotag CHAT with audio and ML | batchalign3 CLI / server | 🔷 Public preview; wheels for Windows, macOS, Linux |
| Standalone desktop GUI for Batchalign | Batchalign Desktop (apps/dashboard-desktop/) | ⚠️ Experimental only; build from source |
Legend: 🔷 = Public preview, ⚠️ = Experimental (not supported for end-users).
Platform and support detail live in the repo-root docs/PLATFORM-SUPPORT.md and docs/RELEASE-CONTRACT.md.
Who this book is for
- Researchers and clinicians transcribing, aligning, or morphotagging audio into CHAT: start with the Batchalign3 User Guide.
- Users coming from Batchalign2: see the Migration Book.
- Contributors to the pipeline: see the Developer Guide.
For CHAT validation, normalization, conversion, or CLAN-style analysis without audio or ML, use the separate chatter project, which has its own CLI and documentation.
Repository layout
crates/ batchalign-* (runtime, types, PyO3 bridge); the CHAT-core talkbank-* crates are consumed from the chatter project
batchalign/ Python worker code (ML inference hosting)
apps/ Tauri v2 desktop app (dashboard-desktop, experimental)
frontend/ React dashboard for the Batchalign server
book/ This documentation (mdBook)
This page last changed: 2026-09-01 (commit bb0bb3c9). The whole book last changed: 2026-09-16 (commit 34d249d8).
Install
Status: Current Last updated: 2026-06-19
Installation for Batchalign3, the audio and ML pipeline for CHAT.
- Batchalign3 (audio + ML): Installation
For CHAT validation, normalization, or conversion without audio or ML (and for the CLAN command reference), use the separate chatter project, which has its own installation guide.
This page last changed: 2026-06-19 (commit 358773df). The whole book last changed: 2026-09-16 (commit 34d249d8).
Quickstart
Status: Current Last updated: 2026-06-19
Transcribe or align media into CHAT. The fastest path is the Batchalign3 Quickstart.
To validate, normalize, or convert existing CHAT without audio or ML, use the separate chatter project and its quick start.
This page last changed: 2026-06-19 (commit 358773df). The whole book last changed: 2026-09-16 (commit 34d249d8).
Batchalign2 -> Batchalign3 Migration Book
Status: Current Last updated: 2026-05-19 17:18 EDT
Scope
This migration book explains the transition from batchalign2 baseline commit
84ad500b09e52a82aca982c41a8ccd46b01f4f2c to the current
batchalign3 architecture.
Secondary comparison point when needed:
- released
batchalign2master-branch point:e8f8bfada6170aa0558a638e5b73bf2c3675fe6d
Audience:
- users migrating command-line workflows,
- developers/contributors migrating implementation work.
Why this migration is not a patch release
Batchalign3 is not “batchalign2 plus fixes.” It is a structural rewrite of the format/runtime core:
- CHAT parsing/validation/serialization moved from ad-hoc Python text logic to Rust AST operations.
- job orchestration expanded from local dispatch to daemon + server job modes.
- an intermediate plugin phase was retired; the current release ships in-tree engines and has no public Python plugin or extension loader.
- avoidable runtime dynamic-programming remap paths were narrowed or removed in favor of deterministic identity/index/interval mapping.
The migration also includes durable user-visible improvements that matter to existing BA2 users:
- higher correctness for
%mor/%gra, retokenization, and timing writeback, - faster repeat runs from explicit daemon/server execution and first-class utterance-level caching,
- clearer operational surfaces for long jobs (
serve,jobs,logs;openapiis contributor-facing), - stricter data-structure boundaries so token/word identity is preserved instead of reconstructed after flattening.
The key engineering theme across commands is the elimination of string-based pipelines that produced silently wrong output:
- no more ad-hoc string surgery on CHAT text,
- no more parallel-array patching that drifted when tokenizers disagreed,
- no more broad “just run DP on the flattened text” recovery that masked upstream errors with plausible-looking guesses,
- replaced by stable word identity, explicit indexing, structured AST iteration, and typed validation throughout.
This page is the summary layer. Detailed command-by-command changes now live in:
- User Workflow Migration
- Developer Architecture Migration
- Algorithms, Language, and Alignment Migration
Quick delta map
| Area | batchalign2 @ 84ad500 | batchalign3 |
|---|---|---|
| Core CHAT handling | Python lexer/parser/generator + string transforms | Rust parser + typed AST + serializer + structured validation |
| Alignment remap strategy | DP-heavy fallback remapping and post-hoc reconstruction | Identity/index-first deterministic mapping with narrower, explicit fallback policies |
| Runtime topology | Primarily local CLI dispatch | Local daemon, HTTP server, jobs/logs tooling, contributor-facing OpenAPI export |
| Concurrency | Sequential file processing (Jan 9); concurrent dispatch added in Feb 9 but job-scoped | Daemon/server job lifecycle, persistent worker subprocesses, resumable state |
| Extensibility | Forking / custom branches | In-tree engines only; no public Python API and no public plugin or entry-point loader |
| UI/ops | Terminal-centric | Web dashboard plus health/jobs/log surfaces; desktop/Tauri launcher deferred from first release |
| Test posture | Lower coverage and fewer corpus gates | broad golden/integration suites + policy guards |
Comparison states and policy
This book now works from a dual-baseline policy:
| State | What it represents |
|---|---|
Jan 9, 2026 batchalign2-master 84ad500... | the primary migration anchor for core / non-HK behavior |
Jan 9, 2026 ~/BatchalignHK 84ad500... | the primary migration anchor for HK / Cantonese behavior |
Feb 9, 2026 batchalign2-master e8f8bfa... | the later released BA2 master-branch tracking point |
current batchalign3 | the present Rust-first control plane and worker architecture |
The primary comparison is Jan 9 anchor → current BA3:
- use the Jan 9
batchalign2-masteranchor for core / non-HK migration claims - use the Jan 9
BatchalignHKanchor for HK / Cantonese migration claims - use the Feb 9 BA2 point only when you specifically need the later released BA2 master-branch surface as secondary context
Transient unreleased intermediate states are not cataloged here.
Comparison discipline for release work
The canonical migration baseline is always one of the Jan 9 anchors above.
- Use Feb 9 BA2 only as secondary context when you specifically need the last released BA2 master-branch behavior.
- Do not use later Python operational packaging, fleet wheels, or other deployment artifacts as the migration baseline. Those are useful deployment references, but they blur the actual Jan 9 anchor → BA3 migration delta.
- For HK material, remember that the historical baseline command is
batchalignhk, not stockbatchalign. - For preserved Jan 9 runners, keep the native legacy CLI shape:
command inputfolder outputfolder.
For local BA2-vs-BA3 parity verification, point a baseline executable explicitly pinned to the correct Jan 9 anchor:
- core / non-HK: a pinned
batchalignrunner forbatchalign2-master - HK / Cantonese: a pinned
batchalignhkrunner forBatchalignHK
Both should be run side-by-side against current batchalign3 on the
same input. Differences fall into three buckets: BA2 bugs that BA3
fixed (expected), BA3 regressions (file an issue), and intentional
behavior changes (cross-reference the corresponding section of this
migration book).
How to read this migration book
- Start with User Workflow Migration for command/runtime behavior and release-surface deltas.
- Then read Developer Architecture Migration for control-plane, typed-contract, and codebase-structure changes.
- For algorithmic behavior (alignment, retokenization, multilingual/Japanese, DP), read Algorithms, Language, and Alignment Migration.
- For engine-extension details, read Cantonese and CJK, Architecture and Adding New Engines.
Relationship to existing detailed references
This book is the migration crosswalk. Deep subsystem specifics remain in the existing architecture/reference chapters (CHAT parsing, forced alignment, multilingual, MWT, Japanese morphosyntax, HK engine architecture, server architecture, dynamic-programming policy).
“Every change” interpretation and audit method
The migration scope is broad enough that “every change” is covered by subsystem catalog, not by line-by-line patch replay. This book therefore provides:
- explicit baseline anchoring (
84ad500...), - optional later-BA2 release anchor (
e8f8bfa...) when needed for the last shipped master-branch behavior, - command/runtime/architecture/algorithm/engine-extension change catalogs,
- pointers to subsystem references where each class of change is fully specified,
- practical user and contributor migration checklists.
Intermediate migration campaign notes (for example branch-by-branch progress logs and implementation spikes) are not treated as canonical book content. The book keeps only current-state behavior plus baseline migration crosswalks.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
User Workflow Migration (batchalign2 -> batchalign3)
Status: Current Last updated: 2026-09-07 07:04 EDT
This page describes durable differences between:
- the Jan 9 2026
batchalign2-masterbaseline84ad500b09e52a82aca982c41a8ccd46b01f4f2cfor core / non-HK behavior, - the Jan 9 2026
BatchalignHKbaseline84ad500b09e52a82aca982c41a8ccd46b01f4f2cfor HK / Cantonese behavior, - the later released
batchalign2master-branch pointe8f8bfada6170aa0558a638e5b73bf2c3675fe6d(2026-02-09) where relevant, and - current
batchalign3.
It does not document transient unreleased migration-stage behavior.
1) Command surface: what changed for daily usage
Binary/package naming
- batchalign2 CLI entrypoint:
batchalign - batchalign3 CLI entrypoint:
batchalign3(plus Rust binary integrations)
Historical HK command nuance
For HK / Cantonese work, the preserved legacy command history is slightly different from stock BA2:
- stock legacy CLI:
batchalign - HK legacy CLI:
batchalignhk - preserved Jan 9 legacy runners use native directory-I/O invocation:
command inputfolder outputfolder
Current batchalign3 unifies the modern surface under batchalign3, but live
parity checks should still compare against the historically correct legacy
command.
Command continuity and expansion
Core commands are preserved (align/transcribe/translate/morphotag/coref/utseg). Relative to the Jan 9 BA2 baseline, batchalign3 also adds operational commands that were not yet first-class there:
serve,jobs,logscache
Command crosswalk (BA2 baseline -> BA3)
BA2 command @ 84ad500 | BA3 equivalent | Notes |
|---|---|---|
align | align | same top-level purpose; newer runtime contracts and deterministic remap behavior |
transcribe | transcribe | same top-level purpose; expanded engine/runtime routing |
translate | translate | same |
morphotag | morphotag | same command name, stronger token/validation contracts, multilingual parallel dispatch, and default-on per-word L2 dispatch |
coref | coref | same purpose; public in BA3, still English-only, and still local-oriented |
utseg | utseg | same |
benchmark | benchmark | same high-level goal |
opensmile | opensmile | same high-level goal |
avqi | avqi | same high-level goal |
| (none) | compare | Added to BA2 master post-Feb 9; present in both current BA2 and BA3 |
setup | setup | still initializes local config |
version | version | still available |
| (none) | serve | BA3-only server control surface |
| (none) | jobs / logs | BA3-only job/log operational UX |
| (none) | openapi | BA3-only contributor-facing API schema export tooling |
| (none) | cache | BA3-only first-class cache management relative to the Jan 9 BA2 baseline |
| (none) | bench | Not in Jan 9 baseline; present in Feb 9 BA2 and BA3 |
models | models | still available; current implementation is behind the Rust CLI/runtime |
Important nuance:
- this table is anchored to the Jan 9 BA2 baseline on purpose;
- by the later released Feb 9 BA2 point,
cacheandbenchwere already public and runtime support around--serverhad expanded substantially; - batchalign3 should therefore be read as a further rewrite/hardening of that direction rather than as the first moment operational tooling appeared.
Comparison discipline for user-facing validation
When validating migration behavior or rebaselining expectations, use the correct Jan 9 anchor for the material you are testing:
-
core / non-HK: Jan 9
batchalign2-masterpinned to84ad500... -
HK / Cantonese: Jan 9
BatchalignHKpinned to84ad500... -
Use Feb 9 BA2 only when the specific question is about the later released BA2 master-branch surface.
-
Do not use later Python operational packages as the migration baseline; those represent later deployment/package choices, not the Jan 9 migration anchor.
For practical local checks:
- use
scripts/stock_batchalign_harness.pyfor curatedbenchmarkcases - use
scripts/compare_stock_batchalign.pyfor raw transcript/tier diffs
Both tools should be pointed at the correct 84ad500... baseline executable,
and preserved legacy runners should keep their native
command inputfolder outputfolder syntax.
For HK material in particular, that means comparing against batchalignhk, not
stock batchalign.
transcribe for daily English work: nothing changed
If you transcribe English audio, your commands work exactly as before:
# BA2: --lang defaults to "eng", no need to type it
batchalign transcribe recordings/ output/
# BA3, same default, same behavior
batchalign3 transcribe recordings/ -o output/
The only required change is the binary name (batchalign3) and the preferred
output flag (-o instead of positional). --lang still defaults to "eng".
You do not need to type --lang eng unless you want to be explicit.
New in BA3: --lang auto. This is an optional feature for bilingual or
code-switched recordings where you don’t want to pick a single language.
Whisper’s multilingual model auto-detects the spoken language from the audio.
You never need --lang auto for monolingual English work.
= sign syntax. Both --lang eng and --lang=eng are identical and have
always been identical (this was true in BA2 as well). Use whichever you prefer.
Flag behavior and defaults
The Feb-9-BA2-era global flags listed below were not carried into BA3. A Feb-9 BA2 script that passes any of them gets a clap parse error, not a silent no-op. They were never in the Jan 9 BA2 baseline either; they were added in Feb 9 master and removed in BA3.
--memlog--mem-guard--adaptive-workers/--no-adaptive-workers--pool/--no-pool--adaptive-safety-factor--adaptive-warmup--shared-models/--no-shared-models--lazy-audio
There are hidden per-command BA2 aliases that still parse and map onto the current typed options:
align:--whisper,--rev,--whisper-fa,--wav2vectranscribe:--whisper,--whisperx,--whisper-oai,--rev,--diarize,--nodiarizebenchmark:--whisper,--whisper-oai,--rev
--whisperx and --whisper-oai still parse, and the engines they select are
not implemented in BA3: the job is refused at submission with a message naming
the engines that work.
transcribe keeps BA2’s opt-in surface but intentionally improves its
diarization semantics. Both systems keep --diarize as an opt-in path with a
False / auto default.
If you use Rev.AI (the default engine), speaker labels are already part of the
ASR response and are always applied, you get multi-speaker output in both BA2
and BA3 without --diarize, and BA-side utterance segmentation still runs
separately. When --diarize is explicitly requested, both Jan 9 BA2 and
current BA3 run a separate speaker stage on top of the ASR output, including
Rev-labeled transcripts. BA3 now defaults that stage to pyannoteAI Precision-2
and projects its segments onto timed ASR words before utterance segmentation;
BA2 relabels already-built utterances. Use --speaker-engine pyannote or
--speaker-engine nemo for BA3’s local alternatives. The flag remains most
important for Whisper-based workflows where the ASR engine does not produce
speaker labels at all. BA2’s old help text claiming Rev ignored --diarize
was stale; the implementation did not.
Utility command migration
The operational command surface also changed in stages:
| Command family | Jan 9 BA2 | Feb 9 BA2 | Current BA3 |
|---|---|---|---|
setup, version, models | present | still present | still present, but behind Rust CLI |
cache | absent as public CLI | public command | public command with Rust CLI/server integration |
bench | absent | public command | public command |
serve, jobs, logs | absent | not public CLI commands in released BA2 master | public BA3 utility/ops surfaces |
openapi | absent | not public CLI command in released BA2 master | contributor-facing BA3 utility surface |
Per-command details (see comparison states for the three-state framing):
setup:- Jan 9 / Feb 9 BA2: Python-side config wizard for
~/.batchalign.iniand Rev.AI defaults - current BA3: same public purpose, but implemented in Rust with explicit interactive/non-interactive validation
- Jan 9 / Feb 9 BA2: Python-side config wizard for
models:- Jan 9 / Feb 9 BA2: public training entrypoint mounted directly from the Python training runtime
- current BA3: still fundamentally a Python training surface; the Rust CLI forwards to the Python runtime rather than re-implementing training logic
version:- Jan 9 / Feb 9 BA2: version surfaced through Click root-command metadata
- current BA3: explicit
versionsubcommand with package version plus build hash
cache:- Jan 9 BA2: no public cache-management command
- Feb 9 BA2: Python
cachecommand for stats/clear/warm against Python-side cache-manager state - current BA3: Rust
cachecommand for analysis/media cache inspection and clearing aligned to the current SQLite/media-cache runtime - practical delta:
cache clear --allalso removes permanent UTR cache entries, while BA2’scache warmprewarm flow is not carried forward
bench:- Jan 9 BA2: no public benchmarking command
- Feb 9 BA2: Python repeated-dispatch timing helper with runtime toggles
- current BA3: Rust repeated-dispatch benchmarking with typed options and structured output for regression work
serve,jobs,logs:- absent from Jan 9 and released Feb 9 BA2 as public CLI commands
- current BA3: real server/job operations surface, reflecting the shift from one-shot local execution toward explicit daemon/server/job control
openapi:- absent from Jan 9 and released Feb 9 BA2
- current BA3: contributor-facing API/schema export tooling rather than a normal end-user workflow
- daemon/server routing:
- released Feb 9 BA2 already had richer local runtime controls
- current BA3: dispatch distinguishes commands that prefer the local daemon
from commands that can target a remote server directly. The set that
prefers local-daemon execution is
transcribe,transcribe_s,benchmark, andavqi; for these, an explicit--serverflag is ignored in favor of the local daemon. (Seecommand_prefers_local_daemonincrates/batchalign/src/cli/dispatch/mod.rs.) A separate sidecar daemon profile exists incli/daemon.rsfor transcribe workloads that need a different Python environment, but the current dispatch code does not auto-route commands to it on capability mismatch, it is started and stopped throughservelifecycle commands.
1.1) Biggest durable user-visible changes
If you are coming from BA2, the changes most likely to affect real corpus results are:
morphotagcorrectness is stronger:%mor/%grageneration now runs against a structured CHAT representation and preserves token provenance more consistently. Multilingual files process all languages in parallel (semaphore-bounded cross-language dispatch, seemorphosyntax/batch.rsincrates/batchalign/), and large single-language batches are split across multiple workers (up toDEFAULT_MAX_WORKERS_PER_KEY), the observed practical effect is substantially faster wall-clock time on multilingual corpora, though no published benchmark anchors a specific multiplier.- retokenization is more predictable: Batchalign3 no longer relies on runtime global DP remapping to reconcile Stanza output back to CHAT.
- alignment and timing writeback now preserve stable identity and explicit order more often instead of reconstructing results from flattened strings later.
- repeated runs are materially faster: utterance-level caching and daemon/server execution remove much of the Jan 9 BA2 per-file process startup cost.
- long runs are easier to operate: job/log/status surfaces replace much of the Jan 9 BA2 “watch one terminal and inspect files later” model.
Some of these improvements were already present in the Feb 9 BA2 release (see comparison states); BA3 adds the Rust-first control plane and stronger CHAT-ownership boundaries.
2) Runtime mode: local CLI vs daemon/server discovery
In batchalign2, most workflows were “run command locally, wait, inspect files.” Batchalign3 supports that, but also supports:
- local daemon-backed execution,
- server-managed job queues and status APIs,
- explicit operational commands such as
serve,jobs, andlogs.
UI consequence: in addition to terminal progress, the modern stack supports
dashboard-style and API-style operational visibility (jobs, logs, health and
OpenAPI surfaces), which substantially changes how teams monitor long runs.
2.1) UI migration notes
- CLI UX: still primary for batch workflows, but now with explicit operational subcommands rather than implicit one-shot process assumptions.
- Server/API UX: job/status endpoints support automation and remote control
workflows that BA2 users previously handled with custom shell glue;
openapiis the contributor-facing schema export surface for that API. - Dashboard UX: the server-hosted web dashboard is real when dashboard
assets are installed. What is deferred from the first public
batchalign3release is the separate desktop/Tauri launcher path, not the web dashboard itself. - Editor UX (ecosystem): downstream editor integrations now prefer structured alignment sidecars where available, reducing regex-only timing extraction drift.
This changes how users should think about failures/retries:
- prefer
jobs/logsinspection over searching ad-hoc terminal output, - use explicit cache controls for reproducibility and reruns,
- treat processing as resumable jobs instead of monolithic one-shot runs.
3) Alignment behavior users will notice
This section is about user-visible consequences. The mechanism-level story
(ID-first timing transfer, retokenization mapping, %gra validation, and the
reduced role of broad runtime DP remap) lives in
Algorithms, Language, and Alignment Migration.
Realign-after-edit behavior
Old BA2 workflows often resolved transcript edits by broad remap over flattened text. With repeated words, retraces, or overlap, that could produce unstable timing reassignment.
Current BA3 prefers deterministic transfer and explicit untimed outcomes:
- fewer surprise timing jumps across utterances,
- clearer unresolved cases instead of silent “best fit” remaps,
- more stable
%worand bullet writeback ordering.
The released Feb 9 BA2 point had already improved align materially relative to
Jan 9, but current BA3 is where the transfer/writeback policy becomes much more
consistently identity-aware and validation-driven.
Retokenization and %mor / %gra differences
If you compare corpus outputs, expect some %mor / %gra differences to be
corrections, not regressions.
The user-visible changes that matter most are:
%graroot attachment now followshead=0,- invalid root/head structures are rejected instead of written out,
- MWT and contraction handling are more stable,
- special forms such as
@c,@s, andxbxxxare handled more explicitly, - reflexive pronouns emit
reflx, retokenize=falsepreserves original tokenization instead of silently rewriting it.
Important comparison nuance:
- some special-form and pronoun behavior already existed in BA2,
- the durable BA3 change is that these behaviors now sit inside structured mapping and validation rather than positional repair.
Alignment and morphotag migration in two steps
| Area | Jan 9 BA2 -> Feb 9 BA2 | Feb 9 BA2 -> current BA3 |
|---|---|---|
align | released BA2 already improved cache use, failure handling, and runtime robustness | FA grouping, timing injection, %wor, monotonicity handling, and much of the parse/cache/infer/inject flow move into Rust orchestration |
morphotag | released BA2 already improved caching, DP/robustness edges, and internal cleanup | %mor/%gra mapping and injection gain explicit root/head/chunk validation, a clearer Rust-owned CHAT boundary, and semaphore-bounded concurrent file dispatch |
For users, the practical current-state rule is:
- current alignment prefers deterministic transfer and explicit untimed outcomes over silent global remap choices;
- current morphotag output is more strongly validated, so some BA2-to-BA3 corpus diffs should be treated as bug fixes.
Other commands
| Command | Jan 9 BA2 -> Feb 9 BA2 | Feb 9 BA2 -> current BA3 |
|---|---|---|
transcribe | Python pipeline becomes faster and more robust, especially in dispatch/startup/long-audio handling | Python stops owning transcript construction; Rust owns postprocess, CHAT assembly, and optional downstream stages |
translate | mostly lazy-load/runtime cleanup, not a major algorithm shift | Rust takes over CHAT extraction, cache, validation, and %xtra injection; Python becomes pure text inference |
utseg | same Python constituency + DP alignment algorithm, with cache/lazy-load cleanup | Python returns raw trees; Rust computes assignments and mutates CHAT directly |
coref | essentially same document-level Python+DP remap path | Python returns structured chains; Rust injects sparse %xcoref and enforces output policy |
benchmark | runtime/dispatch and benchmarking UX improve, but still Python-owned command flow | Rust now owns benchmark orchestration end to end; Python only contributes raw ASR inference when needed, and current BA3 still honors --wor / --nowor |
opensmile | mostly lazy-load/runtime cleanup | still pure feature extraction, but now behind typed prepared-audio V2 contracts with explicit non-CHAT output handling |
avqi | mostly lazy-load/runtime cleanup | still pure AVQI computation, but now behind typed prepared-audio V2 contracts and explicit paired-audio inputs |
4) Multilingual and language-specific changes users will notice
Code-switching and @s policy
Relative to BA2, current BA3 changes both the analysis path and the transcript
contract for @s:
- per-word
@s/@s:LANGrouting is now default-on for morphotag; use--no-l2-morphotagif you need the olderL2|xxxplaceholder behavior for reproducibility - explicit
@s:LANGstill routes toLANGeven whenLANGis missing from@Languages, but validation emits warn-only E254 so the header mismatch is visible - whole-utterance same-language all-
@sruns are no longer accepted as an utterance-language shorthand; BA3 validates them as E255 and expects[- lang]instead chatter debug fix-sis the companion repair tool for migrated corpora: it rewrites qualifying whole-utterance@sruns, appends missing explicit languages to@Languages, and skips already-correct files
Stanza multi-word token (MWT) outputs
Batchalign3 handles tokenizer expansions (one orthographic token -> multiple UD units) more predictably than BA2-era remap-heavy paths.
This matters directly for migration because BA2-era outputs could drift when tokenizer-created words were later forced back onto CHAT via heuristic remap. Batchalign3 keeps the original CHAT structure as the primary truth and maps UD analysis back onto it deterministically. The mechanism details live in Algorithms, Language, and Alignment Migration.
Japanese and other language preprocessing/postprocessing
Key upgrades include stronger language-aware normalization and postprocessing
guards (including Japanese-specific morphology handling and punctuation/token
cleanup pipelines) to reduce downstream alignment and %mor drift.
4.1) Structural theme across text commands
The command table above already covers the command-by-command migration story. The shared implementation consequence is:
- Python is now a pure model server: it returns raw inference outputs and contains zero CHAT parsing, zero text normalization, and zero domain logic.
- Rust owns all CHAT parsing, payload extraction, validation, reinjection, and output policy, a complete inversion of BA2’s Python-owns-everything architecture, enforced by allowlist tests.
That pattern is why transcribe, translate, utseg, and coref now behave
more predictably than the older Python-monolithic paths. The same separation
also explains why benchmark is easier to reason about in BA3: Rust owns the
composed workflow boundary instead of hiding it inside Python dispatch glue.
Media-analysis commands (opensmile, avqi)
These are lighter migration targets than the CHAT-processing commands above. For most users the command lines did not change:
opensmile input_dir output_diris still the shapeavqi input_dir output_diris still the shape- feature-set and language options are preserved
The important durable differences are operational rather than algorithmic:
- BA3 runs them behind typed prepared-audio V2 worker contracts
- failures surface through job/log tooling instead of ad hoc
.error.txthunting during server/daemon runs avqino longer needs on-disk temporary mono WAV files
opensmile CSV output matches the BA2 shape exactly: feature,value
header followed by one row per feature, single-column. BA2-era scripts
that parse this layout continue to work without modification.
5) Migration checklist for existing users
- Update the binary name in your scripts:
batchalign→batchalign3. Output is now an-oflag instead of a positional argument. - Remove any Feb-9-BA2-era global flags from your scripts. The
following were never carried into BA3 and will produce a parse
error:
--memlog,--mem-guard,--adaptive-workers,--no-adaptive-workers,--pool,--no-pool,--adaptive-safety-factor,--adaptive-warmup,--shared-models,--no-shared-models,--lazy-audio. (Per-command hidden BA2 aliases like--whisper,--rev,--diarizestill work, they translate to current typed flags.) - Validate your expected outputs against current golden behavior, especially if your corpus has overlap / retraces / repetitions.
- Rebaseline any
%mor/%graexpectations that depended on BA2 bugs or unstable remap behavior. Compare against current intended outputs, not Jan 9 BA2 accidental ones. - If you previously relied on out-of-tree Python integration code,
port it to subprocess calls into
batchalign3. There is no public Python API in BA3, see No Python API. - (Reserved, was previously a note about opensmile CSV layout
changing during the rewrite. That divergence has been corrected;
opensmileoutput now matches the BA2feature,valueshape and no script changes are required.) - Adopt
jobs/logs/cacheoperational commands for repeatability. - For editor media workflows, use sidecar / timing-index aware tooling where available (instead of bullet-regex-only extraction).
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Developer Architecture Migration (batchalign2 -> batchalign3)
Status: Current Last updated: 2026-07-30 18:21 EDT
Comparison anchors for this page:
batchalign2-master84ad500b09e52a82aca982c41a8ccd46b01f4f2cfor core / non-HK behaviorBatchalignHK84ad500b09e52a82aca982c41a8ccd46b01f4f2cfor HK / Cantonese behavior- later released
batchalign2master-branch pointe8f8bfada6170aa0558a638e5b73bf2c3675fe6dwhere needed - current
batchalign3
This page excludes transient unreleased migration-branch states.
See also:
- BA2 Architecture Reference for the frozen Jan 9 baseline architecture
- BA2 Compare Migration for the dedicated
batchalign2-master compareto BA3 compare rewrite map - Dispatch and Execution for the current contributor-facing command architecture (command model, planning, recipe-driven execution kernel)
Comparison discipline for contributors
Contributor-facing parity and regression checks should anchor to the correct Jan 9 preserved baseline:
-
core / non-HK: Jan 9
batchalign2-master -
HK / Cantonese: Jan 9
BatchalignHK -
The later Feb 9 BA2 point is secondary context only.
-
later Python operational installs are deployment references, not migration baselines.
Use the repo-local comparison tools accordingly:
scripts/stock_batchalign_harness.pyfor curatedbenchmarkcasesscripts/compare_stock_batchalign.pyfor raw side-by-side output diffs
Both should be pointed at the historically correct executable explicitly pinned
to 84ad500....
- For HK material, that means
batchalignhk, not stockbatchalign. - Preserved Jan 9 legacy runners should keep their native CLI shape:
command inputfolder outputfolder. - When older legacy
benchmarkruns emit.asr.cha/.wer.txt/.diffrather than modern.compare.csv, the curated harness should normalize them by rescoring the emitted.asr.chathrough currentbatchalign3 compare.
1) Core architecture shift
batchalign2 mental model
- Python-centric runtime and pipeline composition.
- CHAT parsing/manipulation through ad-hoc string transforms and parallel arrays.
- Every command flattened structured data to strings for engine calls, then attempted to reconstruct structure from engine output, an architecturally lossy round-trip that produced silent drift whenever tokenizers disagreed.
current batchalign3 mental model
- Rust-first CHAT core (parser, validator, AST, serializer).
- Python and the Rust control plane integrate via explicit typed contracts.
- identity-preserving data flow (word IDs, utterance/index metadata, spans).
- service-oriented operations (daemon/server/job lifecycle).
The key contributor shift is: preserve structure, do not reconstruct it later from flattened strings.
1.1) Durable engineering deltas
The durable contributor-facing changes since the Jan 9 BA2 baseline are:
- data structures moved from Python object/string surgery toward typed Rust AST ownership and explicit worker payloads;
- command internals moved away from array-position repair and flatten-then-fix workflows toward stable IDs, explicit indices, chunk maps, and AST walks;
- orchestration moved from monolithic local dispatch toward daemon/server/job routing with explicit boundaries;
- morphosyntax, FA, and related passes now favor deterministic provenance mapping over runtime remap heuristics;
- regression defense is stronger: more golden cases, tighter invariants, and explicit policy against reintroducing runtime DP remap paths.
See comparison states for the Jan 9 BA2 → Feb 9 BA2 → BA3 framing. Feb 9 BA2 already gained cache, dispatch, and morphotag/alignment cleanup; BA3 moves orchestration, typed contracts, and CHAT ownership into Rust.
2) Concurrency and worker model
batchalign2 model
Jan 9 baseline (84ad500b): Purely sequential. dispatch.py (196 lines)
processes files one at a time in a simple for-loop, no ProcessPoolExecutor,
no ThreadPoolExecutor, no adaptive workers, no file sorting, no shared-models
mode. The pipeline is created once and called on each file sequentially.
Feb 9 master (e8f8bfad): Adds full concurrent dispatch (1,044 lines in
dispatch.py). Uses Python’s concurrent.futures:
flowchart TD
cli["CLI dispatch\n(Feb 9 only)"]
classify{"Engine pool-safe?"}
thread["ThreadPoolExecutor\n(shared pipeline, mutex)"]
process["ProcessPoolExecutor\n(forked, model copy per worker)"]
file1["Worker: process file A"]
file2["Worker: process file B"]
cli --> classify
classify -->|"Rev.AI, Google Translate"| thread
classify -->|"Whisper, Wave2Vec, Stanza"| process
thread --> file1
thread --> file2
process --> file1
process --> file2
Key characteristics (Feb 9 only, none of these exist in Jan 9):
- Pool-safe engines (Rev.AI, Google Translate):
ThreadPoolExecutor: one pipeline loaded in the main process, threads share models via a mutex. Memory-efficient but limited to API-backed or thread-safe engines. - Pool-unsafe engines (Whisper, Wave2Vec, Stanza, Pyannote):
ProcessPoolExecutor, each worker is a forked subprocess with its own model copies. N workers = N× model memory. - Adaptive worker capping: monitors RSS peaks and throttles new submissions when available memory drops below a reserve (10% of system RAM).
- File sorting: largest files dispatched first to prevent straggler effects.
- No persistent workers: executors are job-scoped, all workers die after each job completes. Next job reloads models from scratch.
- Optional shared-models mode (
--shared-models): usesfork()to inherit parent’s loaded models. Linux-only, disabled on macOS+MPS, crash-prone.
Memory characteristics (from Feb 9 BA2 benchmarks):
| Workload | Per-worker peak | Workers | Total |
|---|---|---|---|
align (Whisper+Wave2Vec) | 3.0-4.2 GB | 4 | ~16 GB |
morphotag (Stanza) | 1.1-2.5 GB | 4 | ~10 GB |
batchalign3 model
batchalign3 uses a Rust control plane with persistent Python worker subprocesses:
flowchart TD
server["Rust server (batchalign)"]
pool["WorkerPool"]
profile{"WorkerProfile"}
gpu["SharedGpuWorker\n1 process, ThreadPoolExecutor\n(ASR + FA + Speaker models)"]
stanza["WorkerGroup\nN processes, exclusive checkout\n(Stanza NLP models)"]
io["WorkerGroup\n1 process, exclusive checkout\n(Translation, OpenSMILE, AVQI)"]
t1["Thread 1: FA file A"]
t2["Thread 2: FA file B"]
t3["Thread 3: ASR file C"]
server --> pool
pool --> profile
profile -->|GPU| gpu
profile -->|Stanza| stanza
profile -->|IO| io
gpu --> t1
gpu --> t2
gpu --> t3
Key differences from BA2:
| Dimension | BA2 | BA3 |
|---|---|---|
| Worker lifetime | Job-scoped (die after each job) | Persistent (idle timeout 10 min) |
| Model loading | Fresh per worker per job | Load once at startup, reused |
| GPU model sharing | Fork-based (crash-prone) or none | ThreadPoolExecutor inside one process (GIL-release) |
| CPU parallelism | ProcessPoolExecutor (N copies) | Stanza profile: N persistent subprocesses, two-level parallelism (cross-language + intra-language chunking) |
| Concurrency control | Adaptive RSS monitoring | Auto-tuned + memory gate + per-profile limits |
| Worker health | None | Health checks every 30s, auto-restart |
| Worker pre-spawn | None (cold start every file) | Per-job pre-scaling before file dispatch |
| File ordering | Largest-first sorting | Submission order (largest-first planned) |
Memory comparison (mixed English workload, align + morphotag):
| System | GPU workers | Stanza workers | Total |
|---|---|---|---|
| BA2 (4 process workers) | 4 × ~4 GB = ~16 GB | 4 × ~2.5 GB = ~10 GB | ~26 GB |
| BA3 (profiles) | 1 × ~5 GB (shared) | 2 × ~2 GB = ~4 GB | ~9 GB |
The ~3× memory reduction comes from two sources:
- GPU profile shares ASR, FA, and Speaker models in one process (vs 3 separate)
- Persistent workers eliminate per-job model reloading overhead
3) Codebase crosswalk for contributors
| Legacy concern (BA2) | Current concern (BA3) |
|---|---|
batchalign/cli/cli.py command wiring | Rust CLI argument tree + command router (crates/batchalign) |
local dispatch in batchalign/cli/dispatch.py | server + local-daemon dispatch + job APIs (crates/batchalign) |
| Python CHAT parser/generator modules | Rust CHAT crates + serializer/validator path in core |
| ad-hoc alignment remap glue | contract-driven UTR/FA handlers with deterministic fallback policies |
| monolithic Python command pipelines | task-local Python inference + Rust orchestration/injection/postprocess |
| provider-specific modifications in forks | in-tree provider modules under batchalign/inference/; CHAT-aware orchestration lives in the Rust runtime, not Python |
Baseline anchors used for this crosswalk:
- BA2 CLI commands:
batchalign/cli/cli.py@84ad500 - BA2 dispatch/runtime bridge:
batchalign/cli/dispatch.py@84ad500 - BA2 morphosyntax surface:
batchalign/pipelines/morphosyntax/ud.py@84ad500 - later released BA2 master-branch CLI/dispatch:
batchalign/cli/{cli,dispatch}.py@e8f8bfa - BA3 Rust CLI args/command tree:
crates/batchalign/src/cli/args/mod.rs
3.1) Data-structure shift: what changed and why it matters
The largest durable implementation change is the move from reconstructive pipelines to identity-preserving pipelines. This matters more than the language change from Python to Rust: BA2’s correctness failures came from losing structure and trying to recover it, not from Python being slow.
In BA2, major stages frequently crossed these boundaries:
- parse text into Python objects,
- flatten or normalize text for engine calls,
- run external NLP/ASR,
- rebuild higher-level structure from token strings afterward.
In BA3, the preferred pattern is:
- parse CHAT once into a typed structure,
- extract explicit payloads for inference,
- return typed or schema-constrained results,
- inject back into the original structure without losing provenance.
That change directly explains many correctness improvements in morphotag, retokenization, timing writeback, and validation.
In practical contributor terms:
- prefer stable identifiers over “find the same token again later”,
- prefer explicit index maps over positional guesswork,
- prefer AST iteration over flatten/split/reparse loops,
- prefer narrow deterministic fallback over broad DP recovery on flattened text.
This is not abstract guidance. Current morphotag/alignment code now enforces it in concrete ways:
%graconstruction validates root/head/chunk invariants before writeback,- special-form handling is explicit (
@c,@s,xbxxx) instead of being recovered indirectly from placeholder strings, - whole-utterance same-language all-
@sis now a validation concern (E255), while explicit undeclared@s:LANGis warn-only (E254) and still routes to the named language, - retokenization rebuilds AST content directly rather than patching flattened string output,
- UTR and FA use explicit IDs/indices where available before any fallback.
3.2) Command-by-command orchestration shift
The same principle shows up across the command surface:
transcribe:- Jan 9 / Feb 9 BA2: Python owned ASR output processing, retokenization, and CHAT construction in one pipeline
- current BA3:
Python worker: raw ASR only
Rust: post-process tokens, assemble CHAT, optionally run
utsegandmorphotag
translate:- Jan 9 / Feb 9 BA2: Python translated utterance text and wrote translation tiers through Python-side CHAT generation
- current BA3: Python worker: raw text translation only Rust: extract text payloads and inject translated results back into CHAT
utseg:- Jan 9 / Feb 9 BA2: Python owned constituency parsing, phrase extraction, DP reconciliation, and utterance rebuilding
- current BA3: Python worker: constituency trees Rust: assignment computation and CHAT mutation
coref:- Jan 9 / Feb 9 BA2: Python already ran document-level coref, detokenized the document, and DP-remapped chains back onto forms
- current BA3:
Python worker: structured chain data
Rust: document-level payload collection, sparse
%xcorefinjection, and validation
compare:- later
batchalign2-master: Python ownedmorphosyntax -> compare -> compare_analysis, projected through the PythonDocumentmodel, and relied on string/document regeneration as the projection mechanism - current BA3:
Rust: morphotag main only, keep gold raw, build a
ComparisonBundlewith local-window alignment plus structural word matches, and materialize either the released main output or an internal AST-first gold projection
- later
benchmark:- Jan 9 / Feb 9 BA2: Python command path around ASR + gold transcript + WER output files
- current BA3:
Rust: typed command options and per-file infer dispatch
Rust core: WER computation exposed through
batchalign_corePython package: optional convenience wrapper only, not worker infer logic
opensmile/avqi:- Jan 9 / Feb 9 BA2: Python feature-analysis commands with local library calls
- current BA3: Rust: typed command options and prepared-audio V2 dispatch Python worker: pure analysis tasks with structured request/response payloads
This is the durable architectural pattern to preserve:
- inference workers should do inference,
- orchestration and CHAT ownership should stay on the Rust side.
3.3) Utility-command control-plane shift
The utility command story also changed in code-meaningful ways:
setup:- Jan 9 / Feb 9 BA2: Python Click flow writing
~/.batchalign.ini - current BA3: Rust-owned prompt/validation/write path preserving the same compatibility file
- Jan 9 / Feb 9 BA2: Python Click flow writing
models:- Jan 9 / Feb 9 BA2: Python command tree directly exposed training runtime
- current BA3: Rust CLI still delegates to the Python training module; this is a control-plane wrapper change, not a training-stack rewrite
version:- Jan 9 / Feb 9 BA2: root-command version metadata
- current BA3: explicit subcommand plus build-hash reporting, useful for support and stale-binary diagnosis
cache:- Feb 9 BA2 introduced Python cache stats/clear/warm around Python-side cache state
- current BA3 redefines that command around the Rust runtime cache boundary: SQLite analysis cache plus media cache inspection/clearing
bench:- Feb 9 BA2 introduced a Python repeated-dispatch timing helper
- current BA3 keeps the same basic purpose but moves dispatch/control into Rust typed options and structured benchmark output
serve,jobs,logs,openapi:- These are the clearest BA3-only utility additions.
- They exist because the runtime model itself changed: once jobs, server health, logs, and API schema became first-class control-plane concerns, the CLI needed explicit ops commands instead of assuming one-shot local runs.
User-facing command/history detail belongs in user-migration.md. This page keeps the developer-facing architectural consequence: the control plane is now explicit, typed, and operationally observable.
4) Concurrency and orchestration differences
Batchalign3 makes concurrency explicit at architecture boundaries:
- command routing to local/remote execution backends,
- queueable jobs with durable status and logs,
- explicit server health, job status, and observable daemon/server boundaries.
Jan 9 BA2 had no concurrency: it processed files sequentially in the main process, loaded all models fresh each time, and had no mechanism to share state across runs. Feb 9 BA2 added concurrent dispatch (see Section 2), but workers were still job-scoped and models were reloaded from scratch per job. In BA3, contributors should model command execution as staged orchestration across explicit runtime boundaries.
This requires contributors to design for:
- idempotent work units,
- resumable/observable processing stages,
- strict input/output schema validation between boundaries.
4.1) Performance model shift
The durable performance improvement story is architectural:
- repeated one-file/one-process startup is no longer the only execution model;
- the daemon/server path keeps heavyweight engines warm across runs;
- cache misses can be batched across files instead of paying per-file setup overhead repeatedly;
- cache ownership and job state are explicit rather than incidental.
This is the kind of performance change that should remain in the migration book. Short-lived benchmark spikes or temporary regressions should not.
5) Data model and API boundary implications
For recent DP-migration work, no additional core CHAT AST augmentation was required because existing model identity/timing surfaces were sufficient.
Guideline for future work:
- do not enlarge the core AST for editor-only derived views,
- expose sidecar APIs for high-churn UI metadata,
- keep AST focused on durable linguistic source-of-truth structures.
Related rule for migration documentation: explain changes in algorithm choice, data structures, and public behavior; do not preserve branch-by-branch implementation churn.
6) Testing posture for contributors
Migration-era quality now depends on layered tests:
- golden tests for edge corpora (repeat/retrace/overlap/multilingual),
- no-DP-runtime allowlist tests
(
batchalign/tests/test_dp_allowlist.py: Rust PyO3 call sites, chat-ops call sites, Python inference zero-DP), - Stanza configuration parity checks
(
batchalign/tests/pipelines/morphosyntax/test_stanza_config_parity.py, MWT exclusion parity, Japanese processor parity, English gum package parity).
7) Python API migration
BA2 Python API: all removed
BA2 exposed a rich Python API: Document, CHATFile, BatchalignPipeline,
and 23+ individual engine classes (WhisperEngine, StanzaEngine, etc.).
All of these have been removed. BA3 is CLI-first. The Rust server owns all CHAT manipulation. There is no Python API for parsing, mutating, or serializing CHAT files.
External usage was minimal. A pre-release audit found that existing PyPI downloads were essentially all CLI usage; no downstream packages depended on the Python API, no academic papers referenced it. Removing the Python surface affects no observed external user.
BA3 equivalents for each BA2 pattern
Running pipeline operations:
# BA2
from batchalign import BatchalignPipeline
nlp = BatchalignPipeline.new("morphosyntax", lang="eng")
result = nlp("input.cha")
# BA3: CLI is the only entry point
# Note: morphotag has no `--lang`; per-file `@Languages:` headers drive routing.
import subprocess
subprocess.run(["batchalign3", "morphotag", "input/", "-o", "output/"])
Reading CHAT files: Use standard file I/O. BA3 does not provide a Python CHAT parser, use the CLI for processing.
Validation: Use batchalign3 validate input/ or the chatter TUI.
Removed surfaces
| Removed | Replacement |
|---|---|
batchalign.compat (CHATFile, Document, BatchalignPipeline) | CLI via subprocess |
batchalign.pipeline_api (run_pipeline, LocalProviderInvoker) | CLI via subprocess |
batchalign_core.ParsedChat (parse, serialize, add_*, callbacks) | Rust server handles directly |
batchalign.inference.benchmark (compute_wer) | batchalign3 compare CLI command |
Individual engine classes (WhisperEngine, StanzaEngine, etc.) | CLI commands (transcribe, morphotag, align) |
What’s genuinely lost
Engine composition (BatchalignPipeline.new("asr,morphosyntax,fa") with
specific engine instances): BA3 uses the CLI command surface. Multi-step
pipelines run as sequential CLI commands.
Direct AST mutation (doc[0][0].text = "modified"): The CHAT AST is
Rust-owned. Edit CHAT files as text or use the chatter TUI.
Neither limitation affects any known external user.
8) Onboarding plan for legacy contributors
- Start with the command/runtime crosswalk plus the current HK engine and extension-layer chapters.
- Pick one existing BA2 customization and re-implement it as either a built-in engine module or a CLI extension, not a long-lived source fork.
- Add/extend golden cases before behavior changes.
- For alignment/morphology changes, route through core AST/validator contracts and keep side effects deterministic and observable.
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
BA2 Compare Migration
Status: Current Last updated: 2026-05-01 22:47 EDT
This page is for contributors who know batchalign2-master compare and want to
judge the BA3 Rust reimplementation on its real semantics rather than on
superficial output shape.
The shortest summary is:
- BA3 keeps the important
batchalign2-mastercompare behavior: per-gold local window selection, local DP alignment,%xsrep,%xsmor, and.compare.csv. - BA3 intentionally changes how projection and serialization are implemented:
the reimplementation keeps CHAT in a Rust AST, carries typed alignment
metadata, materializes compare tiers through explicit typed content models,
and emits
.compare.csvfrom a structured table model instead of reconstructing structure from strings or a PythonDocumentshell.
Source map
Use these as the primary files when reviewing the rewrite:
- BA2 reference (the local
~/batchalign2-masterarchive is checked out at the Jan 9 baseline84ad500b..., wherecompare.pydoes not yet exist; use the post-redesign commit1f224df346c2ec590d45afa31136a3b878db622bto see the file referenced here, e.g.git -C ~/batchalign2-master show 1f224df:batchalign/pipelines/analysis/compare.py):batchalign/pipelines/analysis/compare.py(CompareEngine/_find_best_segment)~/batchalign2-master/batchalign/pipelines/analysis/eval.py~/batchalign2-master/batchalign/cli/dispatch.py
- BA3 reimplementation:
crates/batchalign/src/compare.rs(orchestration entry: the runner callscompare()andproject_gold_structurally()from talkbank-transform and writes the projected CHAT and.compare.csv)crates/batchalign-transform/src/compare/engine.rs(find_best_segment,compare(): the local-window search and DP-alignment core)crates/batchalign-transform/src/compare/materialize.rs(project_gold_structurally,inject_comparison,clear_comparison)crates/batchalign-transform/src/compare/metrics.rs(CompareMetricsCsvTable,format_metrics_csv)crates/batchalign/src/execution/(recipe-driven dispatch; replaces oldcompare_pipeline.rs)
BA2 compare shape vs BA3 compare shape
flowchart LR
subgraph BA2["batchalign2-master"]
ba2_main["main transcript"]
ba2_gold["gold transcript"]
ba2_mor["morphosyntax on compare inputs"]
ba2_cmp["window selection + local DP"]
ba2_doc["Document / string projection"]
ba2_csv["metrics output"]
ba2_main --> ba2_mor
ba2_gold --> ba2_mor
ba2_mor --> ba2_cmp --> ba2_doc
ba2_cmp --> ba2_csv
end
subgraph BA3["batchalign3 spike"]
ba3_main["main transcript"]
ba3_gold["gold transcript"]
ba3_mor["morphosyntax on main only"]
ba3_parse["parse main + raw gold into ChatFile ASTs"]
ba3_cmp["compare()\nwindow selection + local DP\nmain/gold views + structural matches"]
ba3_proj["project_gold_structurally()\nAST projection"]
ba3_tiers["typed %xsrep/%xsmor models\n-> UserDefinedDependentTier"]
ba3_csv["CompareMetricsCsvTable\n-> csv crate"]
ba3_gold_out["released reference output\nprojected CHAT + .compare.csv"]
ba3_main_out["internal benchmark output\nmain-annotated CHAT + .compare.csv"]
ba3_main --> ba3_mor --> ba3_parse
ba3_gold --> ba3_parse --> ba3_cmp
ba3_cmp --> ba3_proj --> ba3_tiers --> ba3_gold_out
ba3_cmp --> ba3_tiers --> ba3_main_out
ba3_cmp --> ba3_csv
ba3_csv --> ba3_gold_out
ba3_csv --> ba3_main_out
end
Semantics intentionally carried over
These points were treated as the batchalign2-master compare semantics worth
preserving:
- gold companions still use the
FILE.gold.chaconvention - each gold utterance still selects a best local main window before DP runs
- alignment is still local to that selected window, not one global flat pass
- compare still produces
%xsrep,%xsmor, and.compare.csv - skipped main tokens outside the selected window do not count as insertions
- deleted gold tokens stay untagged (
?) unless the reference side already has tags that can be reused structurally
Semantics intentionally changed
These are the deliberate architectural differences from the Python compare path:
-
Main is morphotagged, gold stays raw during artifact construction. BA3 no longer morphotags the gold transcript just to make compare work. This preserves reference-side deletion semantics and avoids inventing tags that were never present in the gold file.
-
Projection is AST-first and serializer-owned. BA3 compare carries explicit structural word-match metadata (
gold_word_matches) out of alignment and uses that to project onto the goldChatFile. It does not infer projection by reparsing%xsrepor by patching reconstructed strings.%xsrep/%xsmorare emitted from typed compare-tier models, and.compare.csvis emitted from a structured table model via the standard Rustcsvcrate. -
Tier projection is conservative by design. Exact structural matches may copy
%mor,%gra, and%worwholesale. Full gold-word coverage without exact structural identity may still project%mor. Partial%gra/%worprojection is intentionally withheld until there is a chunk-safe mapping, because “close enough” projection is exactly how BA2-style structural drift happens. -
Released output now follows the BA2 compare command shape. The public command writes the projected reference transcript at the main file’s output path, together with
%xsrep/%xsmorand.compare.csv. The internal main-annotated materializer remains available for benchmark-style flows, but it is no longer the compare command contract. -
One explicit bug-exception policy is in force.
batchalign2-mastercan emit structurally lossy partial%mor/%graprojection on gold output. BA3 does not reproduce that when it would make the CHAT AST inconsistent. Unsafe partial projection stays conservative.
BA2-to-BA3 code map
| Concern | BA2 | BA3 |
|---|---|---|
| Gold pairing | CLI / dispatch filename logic | compare planner + dispatch pairing |
| Best-window search | _find_best_segment() | find_best_segment() |
| Local alignment core | CompareEngine.process() | compare() |
| Metrics output | CompareAnalysisEngine | CompareMetricsCsvTable / format_metrics_csv() |
| Gold-side projection | Python Document / serializer path | project_gold_structurally() |
| Output selection | command path decides output form | materializer decides output form |
What parity work actually proved
The parity work on this branch proved BA3 drift fixes, not a new courtroom-grade BA2 bug report.
Specifically, live batchalign2-master oracles let us fix these BA3
mismatches:
- BA3 had been counting skipped main tokens outside the chosen local window as
insertions;
batchalign2-masterdid not - BA3 had been morphotagging raw gold during compare artifact construction,
causing deleted gold tokens to pick up invented POS tags instead of staying
? - BA3 gold-projected
%xsrep/%xsmorwas missing the gold utterance terminator asPUNCT
What the rewrite did not prove:
- a new, sharply isolated BA2 compare bug
The direct evidence here is “BA3 was wrong relative to batchalign2-master in
these places” plus “BA2’s projection architecture is structurally lossy.” That
is enough to justify the AST-first reimplementation, but not enough to claim a
fresh BA2 defect report.
Rules for future compare work
If compare keeps evolving in BA3, the safe rules are:
- extend
ComparisonBundleandproject_gold_structurally(), not the serialized%xsrep/%xsmortext - use AST walkers and dependent-tier helpers before inventing new string glue
- extend the typed compare-tier / CSV models before widening serializer output strings
- do not project partial
%gra/%worwithout explicit chunk-safe mapping - treat BA2 as a semantic reference, not a bug-for-bug target
Acceptance checklist for the rewrite
If the question is “should we accept this Rust reimplementation instead of starting over?”, the most useful review checklist is:
- does
compare()preserve thebatchalign2-masterlocal-window behavior? - does the workflow keep gold raw and main morphotagged on purpose?
- does projection stay on the CHAT AST instead of text reconstruction?
- are
%xsrep,%xsmor, and.compare.csvall driven from the same bundle and lowered through typed serializer models instead of raw string assembly? - are partial
%gra/%worprojections still blocked behind explicit safety rules?
Those are the design commitments that matter more than byte-for-byte loyalty to the Python implementation shell.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
BA2 Architecture Reference
Status: Frozen reference
Last updated: 2026-05-19 17:18 EDT
Baseline: batchalign2-jan9 (84ad500b09e52a82aca982c41a8ccd46b01f4f2c)
Supplement: later batchalign2-master compare redesign (1f224df346c2ec590d45afa31136a3b878db622b) as a forward-looking stress test
This page records the architecture of the frozen Jan 9 batchalign2 codebase so contributors have a stable comparison target while redesigning batchalign3.
It is not a nostalgia document. The point is to preserve the useful ideas from BA2 without re-importing the parts that made BA1 and BA2 hard to evolve.
Why This Matters
The Jan 9 BA2 baseline still expresses some architectural ideas more clearly than current BA3 implementation code:
- a central document abstraction
- a visible engine/pipeline split
- straightforward multi-step workflow composition
- a simple read -> transform -> write mental model
At the same time, BA2 also shows the limits of that model:
- sequential assumptions
- stringly dispatch
- implicit task ordering
- weak typing at workflow boundaries
- analyzers returning ad hoc dictionaries instead of typed artifacts
Those strengths and limits should both inform BA3.
Core Abstractions
Document
BA2 has one clear internal representation: Document.
Documentowns transcript structure, metadata, media references, and utterances.Utteranceowns orderedFormcontent plus dependent information such as timing, morphology, dependency, comparison tokens, and comments.- Most engines mutate or analyze
Documentdirectly.
flowchart TD
doc["Document"]
meta["Headers / Media / Metadata"]
utt["Utterance[]"]
form["Form[]"]
dep["Morphology / Dependency / Timing / Comparison"]
doc --> meta
doc --> utt
utt --> form
form --> dep
This is BA2’s strongest idea. The codebase has one obvious semantic center.
Engine and Pipeline Model
BA2 splits work between engines and pipelines.
BatchalignEngineis the unit of work.- Engines advertise
tasks. - Engines implement one of:
generateprocessanalyze
BatchalignPipelinesequences one generator, zero or more processors, and an optional analyzer.
flowchart LR
input["media path or Document"]
gen["Generator engine"]
proc1["Processor engine"]
proc2["Processor engine"]
analyzer["Analyzer engine"]
output["Document or analysis dict"]
input --> gen
gen --> proc1
proc1 --> proc2
proc2 --> analyzer
analyzer --> output
That model made it easy to reason about workflows like:
- transcribe = ASR generation
- morphotag = document processing
- compare = morphosyntax processing -> compare processing -> compare analysis
Transcribe / diarization shape worth preserving
The Jan 9 BA2 transcribe path matters because later help text drifted away from what the code actually did.
transcribewith Rev.AI: BA2 used Rev speaker labels directly and still ran BA-side utterance segmentation throughprocess_generation(..., utterance_engine=...).transcribe_s/--diarize: CLI dispatch mapped toasr,speaker, so the Pyannote speaker processor ran after ASR and relabeled already-built utterances from diarization segments.- That means BA2’s explicit diarization path was not ignored on Rev.AI; the stale help string was wrong, the pipeline wiring was not.
flowchart LR
audio["Audio input"]
rev["RevEngine.generate()\nRev speaker labels + BA utterance segmentation"]
doc["Document with utterances"]
plain["transcribe\nUse Rev labels directly"]
pyannote["PyannoteEngine.process()\nRelabel utterances from speaker segments"]
diarized["transcribe_s / --diarize\nRev labels first, then dedicated relabeling"]
audio --> rev --> doc
doc --> plain
doc --> pyannote --> diarized
Read / Write / Read-Write Model
BA2 effectively has three workflow shapes even though it never formalizes them as first-class architecture concepts:
- read-write: load a document, mutate it, write it back
- read-analyze-write-sidecar: load a document, analyze it, write CSV or metrics
- generate-then-read-write: create a document from media, then continue through document processing
The implementation lives mostly in CLI glue rather than inside a typed workflow layer:
- format adapters load or save
Document - CLI dispatch discovers files and companions
_dispatch(...)decides per-file handlingdispatch_pipeline()turns command names into engine lists
flowchart TD
cli["CLI command"]
discover["Discover files / companions"]
load["Load Document or media path"]
pipeline["BatchalignPipeline"]
write_doc["Write CHAT/TextGrid"]
write_sidecar["Write metrics / CSV / diff"]
cli --> discover
discover --> load
load --> pipeline
pipeline --> write_doc
pipeline --> write_sidecar
This model is simple and effective for sequential workflows, but it leaves too much policy in per-command closures and ad hoc dispatch code.
Strengths Worth Reviving
1. One semantic center
BA2’s best architectural property is that almost everything converges on one document model. Contributors can usually answer “what are we operating on?” without hunting across the codebase.
2. Visible multi-step composition
BA2 makes it obvious when one command is actually a sequence:
- morphosyntax first
- compare second
- metrics third
That clarity is still valuable even if the runtime implementation becomes more concurrent or more distributed.
3. Shared normalization seams
Some of BA2’s strongest code is not the pipeline shell itself but the shared normalization utilities around it. The architecture benefited whenever provider-specific weirdness was absorbed before the rest of the pipeline saw it.
4. Easy experimental evolution
Because the sequential document pipeline is easy to understand, major changes like the later compare redesign could land by rewriting one engine plus a small amount of CLI glue.
That developer ergonomics is exactly what batchalign3 needs to recover without throwing away its runtime strengths.
Limits That Should Not Return
1. Implicit task ordering
Processor order is not described by an explicit workflow graph. It is inferred through task ordering conventions. That is brittle and hard to extend.
2. Stringly dispatch
Command composition relies on string names and large if/elif registries. That
works at small scale and then becomes a change-management tax.
3. Weak output contracts
Processors return Document, analyzers return dictionaries, and callers need
out-of-band knowledge to know what shape they got back. That is a real
architectural limit for future multi-artifact workflows.
4. Sequential assumptions
BA2 does not model:
- cross-file batching as a first-class workflow concern (Note: BA3 originally implemented this for morphotag but later migrated to per-file parallel dispatch for better reliability and incremental feedback)
- persistent worker pools
- shared GPU inference hosts
- job lifecycles
- memory gating and runtime scheduling
Those concerns are not small add-ons in BA3. They shape the architecture.
5. Document/rendering coupling
The BA2 document model sometimes mixes transcript semantics with CHAT-specific serialization behavior. That makes it harder to reuse the model as a pure IR.
Later Compare Redesign As A Harbinger
The later compare redesign on batchalign2-master
(1f224df346c2ec590d45afa31136a3b878db622b) is valuable not because it is part
of the frozen Jan 9 baseline, but because it shows what future architectural
pressure looks like.
That change turns compare from:
- one flat hypothesis-vs-gold alignment
- output anchored to the hypothesis document
into:
- per-gold-utterance window selection
- local alignment inside each selected window
- projection of timing, morphology, and dependency from hypothesis onto gold
- output anchored to the gold document
In other words, it is not merely “better WER.” It is a new workflow kind:
- two input documents
- one comparison bundle
- one projected output document
- one metrics sidecar
That is the key lesson for BA3:
batchalign needs architecture for workflow families and typed intermediate artifacts, not just architecture for commands.
Bottom Line
The right BA3 inheritance from BA2 is:
- keep a strong document/artifact center
- keep explicit multi-step workflow composition
- keep provider normalization at stable seams
The wrong inheritance is:
- string registries
- implicit ordering
- sequential-only pipeline assumptions
- ambiguous return types
See Dispatch and Execution for the current BA3 architecture (command model, planning, recipe-driven execution kernel) that preserves BA2’s good ideas while keeping BA3’s concurrency, worker reuse, and typed runtime boundaries.
This page last changed: 2026-05-22 (commit 11b9b13a). The whole book last changed: 2026-09-16 (commit 34d249d8).
Algorithms, Language, and Alignment Migration
Status: Current Last updated: 2026-09-06 23:14 EDT
Comparison anchors:
batchalign2baseline84ad500b09e52a82aca982c41a8ccd46b01f4f2c- later released
batchalign2master-branch pointe8f8bfada6170aa0558a638e5b73bf2c3675fe6dwhere relevant - current
batchalign3
This page documents durable algorithmic and data-structure changes only. Temporary migration-branch experiments do not belong here.
For user-facing command and output consequences, start with User Workflow Migration. This page explains the mechanism behind those differences.
Comparison discipline
Algorithmic/output parity claims in this page are anchored to the correct Jan 9 baseline for the material under test:
-
core / non-HK claims: Jan 9
batchalign2-master -
HK / Cantonese claims: Jan 9
BatchalignHK -
Later Feb 9 BA2 behavior can still be useful secondary evidence when the question is specifically about the last released BA2 master branch.
-
later Python operational builds are not the migration baseline for the algorithmic claims documented here.
Use the repo comparison harnesses with the historically correct
84ad500...-pinned runner when validating these deltas in practice. For HK
material, that means batchalignhk, not stock batchalign.
1) CHAT parser/validator/AST/serialization as the central algorithmic change
The most important migration is architectural and algorithmic: parsing and
validating CHAT is now a typed, structured pipeline. This reduces failure modes
caused by line-oriented text surgery and makes downstream alignment and %mor/%gra
operations operate on stable structure.
Implication for contributors: if a change can be expressed as AST transform, do that first; avoid direct string hacking.
1.1) Why this changed correctness, not just implementation language
The main gain is not “Rust is faster.” The gain is that parsing, validation, injection, and serialization now operate on stable typed structure.
That directly changes user-visible correctness:
- fewer opportunities for
%mor/%gradrift from line-oriented text surgery, - fewer silent remap choices after tokenization/alignment divergence,
- stronger validation before invalid CHAT is written back out.
This is a fundamental redesign, not incremental cleanup: ad-hoc string manipulation and parallel-array patching are replaced by principled typed structure with explicit provenance, and that structural shift is what drives both the correctness and efficiency gains throughout the migration.
That pattern shows up repeatedly across the migration:
- UTR uses global Hirschberg DP alignment (the same proven approach as old batchalign, now in Rust),
- FA now carries explicit word identity and timing-mode metadata,
- retokenization uses deterministic range/index mapping and AST rebuilds,
%grageneration uses explicit chunk/head validation rather than positional guesswork.utsegnow treats constituency parsing and assignment computation as separate steps instead of flattening subtree leaves and DP-aligning them back to forms.corefnow carries typed sentence/chain structure instead of detokenized text plus DP remap back to utterance positions.
For migration purposes, separate:
| Stage | Algorithm/data-structure shift |
|---|---|
| Jan 9 BA2 -> Feb 9 BA2 | released BA2 already improved DP behavior, caching boundaries, and a number of robustness/performance details inside the Python architecture |
| Feb 9 BA2 -> current BA3 | current code goes further by moving key remap/injection/validation logic into Rust-owned chat-ops and typed orchestration |
2) Dynamic programming: what was removed, what remains
Narrowed or removed from runtime remap paths
- Retokenize char-level DP fallback mapping path was removed, replaced by deterministic interval/index mapping with length-aware monotonic fallback.
- FA response handling uses indexed word timings or deterministic token
stitching in
fa/alignment.rs: no DP.
For FA, the precise current claim is narrower:
- current Rust FA response handling uses indexed word timings or
deterministic token stitching in
fa/alignment.rs; - it does not use the Jan 9 / Feb 9 BA2 broad transcript-wide remap policy;
- a shared Hirschberg DP library still exists in-tree, so the accurate migration claim is that runtime remap policy was narrowed, not that every DP use disappeared.
UTR: global DP is the steady-state correctness boundary
UTR timing recovery (fa/utr.rs) uses a single global Hirschberg DP alignment
of all document words against all ASR tokens.
This is the correct steady-state algorithm for the hand-edited transcript
case (see the 407 trimmed fixture regression test in
crates/batchalign/src/chat_ops/fa/utr.rs): transcript words and ASR
tokens are two independent full-document sequences, so the matcher must
reason globally rather than utterance by utterance. The Rust Hirschberg
implementation in crates/batchalign-transform/src/dp_align/mod.rs is
O(mn) time and linear space (min(m,n) working memory). No published
benchmark currently anchors a specific speedup multiplier against the
old Python implementation; the practical effect is fast enough that UTR
on full transcripts is no longer a wall-clock concern.
This fixes token-starvation failures where local matching consumed tokens too
early. It does not solve every alignment pathology: dense &* overlap and
larger text/audio order divergence still remain a limitation of any monotonic
aligner.
Intentionally retained
- model-internal alignment/decoding internals (e.g., CTC/Whisper internals)
- evaluation/edit-distance style metrics (WER and similar analysis tooling)
Policy: runtime user-output remap should not silently reintroduce global DP tie ambiguity in paths where deterministic mapping is available.
This is a durable migration boundary. DP is legitimate when aligning two
genuinely independent sequences (UTR: transcript words vs ASR tokens; WER:
hypothesis vs reference). It is a regression when runtime output reconstruction
uses global DP to paper over mismatches that should be handled by deterministic
identity/index mapping (retokenization, FA injection, %mor/%gra attachment).
3) Realign-after-edit consequences
When transcript edits occur after initial alignment:
- deterministic ID/index matching preserves timing slots where provenance remains,
- bounded window policies prevent cross-utterance remap jumps,
- unresolved ambiguity yields explicit unassigned outcomes (not hidden remap).
This is the intended operational tradeoff: transparent uncertainty over unstable auto-corrections.
For migrators, this means some BA2-to-BA3 output differences are expected:
- Jan 9 / Feb 9 BA2 output may have looked “more complete” because ambiguous words were forced into a global remap anyway;
- current BA3 output may leave some timing/provenance unresolved explicitly;
- that is a correctness choice, not a missing feature.
3.1) align improvements
Feb 9 BA2 already improved cache use, DP edge cases, and FA failure handling.
BA3 goes further by moving FA grouping, timing injection, %wor,
monotonicity, and overlap cleanup into Rust orchestration with typed FA
payloads and deterministic transfer rules.
One especially important align sub-change is UTR:
- released BA2 recovered utterance timing via a single global Hirschberg DP alignment of all transcript words against all ASR tokens;
- BA3 now uses the same global Hirschberg approach (in Rust), preserving the correct full-document alignment model while moving the implementation onto the typed Rust chat-ops boundary.
- This fixes the 407-style token-starvation regression class, but it does not make UTR non-monotonic. Files with dense overlap and text/audio reordering can still remain only partially recoverable.
Fuzzy UTR matching (new in BA3)
BA3’s experimental two-pass strategy supports Jaro-Winkler matching
(threshold 0.85 by default, configurable via --utr-fuzzy). The default
auto and explicit global strategies use case-insensitive exact matching;
--utr-fuzzy applies only with --utr-strategy two-pass. Historical
coverage measurements do not establish the behavior or accuracy of a current
run; retain its strategy, configuration, source and timing evidence.
Two-pass overlap-aware UTR (BA3 mechanism, currently opt-in only)
BA2 had no overlap awareness in UTR, overlap markers (+<, &*)
were treated the same as regular words. BA3 ships a two-pass strategy
for conversation-analysis data:
-
Pass 1 (global): Align all non-overlap words against ASR tokens to establish the timing backbone. Overlap markers are excluded so they don’t consume ASR tokens that belong to the primary speaker.
-
Pass 2 (targeted): For each unresolved overlap utterance, search for its ASR timing using index-aware onset matching, multiple
⌊respondents match the correct⌈by overlap index with speaker-aware fallback.
The two-pass mechanism currently runs only when the user explicitly
passes --utr-strategy two-pass. The default (--utr-strategy auto)
unconditionally returns GlobalUtr since the auto-routing was disabled
on 2026-03-30, see resolve_strategy() in
crates/batchalign/src/runner/dispatch/utr.rs. The library function
select_strategy() in crates/batchalign/src/chat_ops/fa/utr.rs
(language-agnostic content inspection that picks TwoPassOverlapUtr
when +< or ⌊ markers are present) still exists, but is no longer
reached from the Auto path.
Density-aware overlap exclusion (inside the two-pass mechanism)
When the two-pass strategy is selected (currently only via explicit
override), the exclusion behavior is itself density-aware: when more
than 30% of utterances carry overlap markers (dense CA data like
Jefferson NB at 47% overlap), excluding all overlap words from pass-1
would starve the aligner of context, so two-pass falls back to global
alignment for that file. The threshold lives on TwoPassConfig as the
max_exclusion_density field (default 0.30), see
crates/batchalign/src/chat_ops/fa/utr/two_pass.rs.
Empirical result on Jefferson NB during the original tuning: pass-1 exclusion alone recovered 8.8% at 1491ms error; with density detection, recovered 10.7% at 691ms error.
Language-aware UTR strategy selection (disabled mechanism)
There was an earlier auto-routing layer that gated TwoPassOverlapUtr
on language: non-English files always used GlobalUtr (avoiding the
two-pass regression from noisier non-English ASR); English files fell
through to overlap-inspecting auto-selection. That gate was disabled
2026-03-30 (alignment regressions reported by an operator;
enforce_monotonicity() only checks start times) and is not currently
reachable. The previously-measured gains under the gate (English:
+4.3pp SBCSAE, +3.8pp Jefferson; non-English on Hakka, Welsh, German,
Serbian: GlobalUtr matched or beat TwoPassOverlapUtr) are retained
here as the historical benchmark that motivated the gate, not as a
description of current default behavior.
@Options: CA handling
BA3 detects @Options: CA at FA entry and suppresses %wor
generation for that file, see crates/batchalign/src/fa/mod.rs::run
where the option is consulted and info!("@Options: CA detected suppressing %wor generation") fires. The motivation is that CA
transcripts use prosodic notation (⌈⌉⌊⌋, arrows, lengthening marks)
that %wor cannot represent, so generating it adds noise that CA
researchers have to manually remove. CA terminator resolution itself
lives in the parser layer (the parser correctly promotes CA-style
terminators), not in an FA-side validation skip.
--media-dir flag (new in BA3)
batchalign3 align input/ -o output/ --media-dir /path/to/audio/ allows
specifying a separate directory for audio files. BA2 required audio files
to be alongside the .cha files. This supports workflows where media
is stored separately from transcripts (common in corpus archives).
Align improvement summary
| Feature | BA2 Jan 9 | BA3 |
|---|---|---|
| UTR matching | Exact only | Fuzzy (Jaro-Winkler 0.85) default inside TwoPassConfig |
| Overlap awareness | None | TwoPassOverlapUtr with index-aware onset matching, currently reachable only via explicit --utr-strategy two-pass |
| Dense overlap handling | None (all words treated equally) | Density detection inside two-pass (max_exclusion_density = 0.30, falls back to global when exceeded) |
| Language-aware UTR | None (same strategy for all langs) | Mechanism implemented but auto-routing disabled 2026-03-30; default --utr-strategy auto returns GlobalUtr for all languages |
| CA handling in FA | None | @Options: CA suppresses %wor generation in fa/mod.rs::run |
| Custom media directory | Not supported | --media-dir flag |
| FA orchestration | Python string surgery | Rust typed payloads + deterministic transfer |
4) Retokenization and Stanza multi-token outputs
Batchalign3 accounts for multi-word token expansion and tokenization divergence with deterministic interval/index mapping logic, preserving monotonic ordering.
Practical outcomes:
- one source token yielding multiple UD tokens is handled through explicit mapping,
- merged/split forms are attached by deterministic policy rather than global string-level DP reconciliation,
- divergence remains visible and testable in golden fixtures.
This directly addresses the “multiple tokens from Stanza” migration concern: token expansion is treated as structured provenance mapping, not as text that must later be globally realigned by DP.
4.1) Morphotag and %gra correctness consequences
Durable migration-relevant correctness changes include:
%graroot attachment now follows standard root-head semantics instead of self-referential root indices;- reflexive pronoun suffix handling was corrected;
- MWT chunk mapping avoids brittle positional assumptions;
- tokenizer-generated divergence is either deterministically mapped back or left explicit, rather than silently “fixed” by a global text remap.
Concrete currently tested consequences include:
- ROOT must attach to virtual root
0, not to itself; - invalid root/head/chunk-count combinations are rejected;
- MWT expansions produce per-component
%grarelations; @cand@sspecial forms are mapped explicitly rather than relying on placeholder leakage;xbxxxplaceholders are restored back to the original form in retokenized output;- reflexive pronouns explicitly emit
reflx; - retokenization can split contractions structurally, while
retokenize=falsepreserves original tokenization.
Important comparison nuance:
- reflexive
reflx, special-form handling, andxbxxxrestoration were not invented only in current BA3; older BA2 already had versions of those behaviors in Pythonud.py; - the more durable current shift is that ROOT/head/chunk semantics and retokenization behavior are now enforced through explicit mapping logic and tests rather than left to positional array repair.
For corpus maintainers, this means BA3 %mor/%gra diffs against BA2 should be
reviewed as likely corrections first.
4.2) From positional repair to principled indexing
The durable algorithmic shift is away from workflows like:
- flatten text,
- keep parallel arrays,
- patch indices after skips/merges,
- run broad DP when the arrays drift.
Toward workflows like:
- carry stable word identity,
- maintain explicit utterance/word/chunk indices,
- iterate AST content directly,
- use deterministic local fallback only where provenance is missing.
Concrete command-level examples:
transcribe:- BA2 Python built transcript structure while it was still normalizing token strings and punctuation;
- BA3 separates tagged raw ASR payloads from Rust normalization, Rust postprocess, and Rust CHAT assembly.
translate:- BA2 translated utterance strings and then relied on Python generation to materialize output tiers;
- BA3 extracts utterance payloads from the AST and injects
%xtraback by line index.
utseg:- BA2 flattened constituency subtrees to strings, aligned them back to form arrays, then rebuilt utterances;
- BA3 returns raw tree strings, computes assignment vectors, then splits AST utterances by index.
coref:- BA2 flattened the document to one detokenized string and DP-mapped chain
payloads back to
(utterance, form)slots; - BA3 uses sentence arrays and typed chain refs, then injects sparse
%xcorefby validated sentence/line mapping.
- BA2 flattened the document to one detokenized string and DP-mapped chain
payloads back to
This matters because it reduces accidental correctness:
- fewer outputs that “look plausible” only because a later repair pass guessed the intended alignment,
- more outputs whose correctness follows from preserved structure and validated index relationships.
4.3) Cantonese / HK ASR tokenization
HK / Cantonese parity has one additional algorithmic wrinkle that deserves to be called out explicitly.
- Cantonese material must be compared against the Jan 9
BatchalignHKbaseline. - The relevant preserved legacy command is
batchalignhk. - For
yue, semantically correct ASR text can still benchmark badly if the runtime keeps long Han-script chunks as one giant token instead of splitting them into character tokens before retokenization and scoring.
Current batchalign3 now handles this in the Rust-owned ASR post-process path:
- Cantonese text is normalized to HK traditional form once per monologue,
through
AlignedNormalization, before anything splits it, - Han-script
yueASR chunks are then split with the sharedcantonese_char_tokens()helper, which splits and strips punctuation and normalizes nothing, - ASCII/code-switched tokens are left intact,
- punctuation-based utterance retokenization then runs on those normalized tokens.
This matters because WER/compare behavior for Cantonese is sensitive to token granularity. A transcript can be visibly “close” while still scoring as a large regression if the main path presents only a few giant tokens to the scorer.
5) Japanese preprocessing/postprocessing
Japanese verb-form and POS overrides
crates/batchalign-transform/src/morphosyntax/lang_ja.rs (~440 lines,
ported from BA2’s Python ja/verbforms.py) applies 50+ ordered override
rules that run before UD→CHAT POS mapping. These correct Stanza outputs
for colloquial Japanese forms that the model frequently misclassifies:
- Subordinating conjunctions: contracted conditionals (ちゃ→ば, なきゃ, じゃ→ちゃ, たら, たっ, で) reclassified from VERB/AUX to SCONJ.
- Auxiliary verbs: colloquial endings (れる→られる, よう→おう, だら→たら, だ→た, 無い→ない, せる→させる, なさい→為さい) with corrected lemmas.
- Interjections: backchannels and fillers (はい, うん, おっ, ほら, ヤッホー, ただいま) reclassified from NOUN/VERB to INTJ.
- Verb lemma corrections: specific kanji verbs (撮る, 貼る, 混ぜる, 釣る, 降りる/降る, 載せる, 帰る, 舐める, etc.) that Stanza assigns wrong lemmas.
- Noun/pronoun fixes: colloquial forms (あたし→PRON, バツ, ブラシ, 引き出し, クシャミ) and onomatopoeia (ゴロンっ, モチーンっ).
Japanese Stanza processor configuration
Japanese requires the combined Stanza processor package (tokenize+pos+lemma+depparse
in one model), not separate processors. This is enforced in
test_stanza_config_parity.py to prevent misconfiguration that causes silent
accuracy degradation.
5.1) Performance consequences of the algorithm shift
The performance wins that belong in migration documentation are the durable ones:
- deterministic mapping avoids some expensive reconstruction work that used to happen after engine calls;
- better cache boundaries mean repeated morphosyntax/alignment work is skipped more often;
- batching and warm workers reduce per-file startup overhead.
Point-in-time benchmark spikes do not belong here unless they became a durable property of the released architecture.
6) Overlap and rapid interleaving speech
A known limit remains: rapidly overlapping/interleaving speaker turns are still hard for perfect automatic assignment. The migration improves this by local window constraints and deterministic fallback, but does not claim complete disambiguation in all overlap-heavy audio.
Mitigation strategy:
- preserve all candidate structure and timings,
- avoid global crossing remaps,
- expose unresolved slots for explicit review tools.
7) Regression governance
Algorithmic migrations are now defended by:
- golden test matrices (
batchalign/tests/golden/), - no-DP-runtime allowlist tests at
batchalign/tests/test_dp_allowlist.py::test_chat_ops_dp_calls_are_allowlisted, which fingerprints everydp_align::align(...)call site against an explicit allowlist so unintended DP-on-runtime regressions are caught in CI, - tracing instrumentation for mapping-mode divergence: the
warn!atcrates/batchalign-transform/src/retokenize.rs:75("retokenize text diverged; using length-aware monotonic fallback without DP") fires wheneverbuild_word_token_mapping()cannot use a deterministic word-token mapping and falls back to length-aware monotonic.
That governance change is itself part of the migration: the codebase is less willing to accept “looks plausible on a few files” as evidence that an algorithmic rewrite is safe.
Language handling: no silent fallbacks, early validation
Policy: hard-error on unknown languages, never silent fallback
batchalign3 deliberately rejects unknown or unsupported language codes at the earliest possible point with a clear, actionable diagnostic. This is an improvement over both BA2 and early BA3:
| Scenario | BA2 | BA3 (initial) | BA3 (current) |
|---|---|---|---|
| Unknown code → Rev.AI | pycountry crash (uncaught AttributeError) | Silent wrong code via &other[..2] truncation | Hard error at job submission with alternatives |
| Unknown code → Whisper | pycountry crash | Silent English fallback (return "english") | Hard ValueError with message |
| Unsupported Rev.AI language | HTTP 400 deep in pipeline | HTTP 400 deep in pipeline | Rejected at submission: “use --asr-engine whisper” |
BA2’s behavior was accidentally strict, it crashed because nobody added a None check, not because someone designed early validation. BA3’s initial migration introduced two regressions by trying to be “helpful”:
-
Rev.AI truncation fallback:
&other[..2]silently produced wrong codes (e.g.,hak→ha,pol→po). Replaced with a ~78-entry explicit mapping table (revai/preflight.rs::try_revai_language_hint)"auto"fallback withtracing::warn.
-
Whisper English fallback:
return "english"whenpycountryfound no match. This meant unknown languages were silently transcribed in English, the worst possible outcome, since the user gets output that looks plausible but is completely wrong. Replaced withraise ValueError(...).
Design principle: A clear error message is always better than silently
wrong results. Users can recover from “language not supported, try
--asr-engine whisper” but cannot recover from a transcript that looks
English but should have been Welsh.
Per-file @Languages: resolution (morphotag, translate, coref)
For text-NLP commands that operate on existing CHAT files,
morphotag, translate, coref: the processing language is
read per-file from each file’s own @Languages: header. None of
these commands accept a --lang flag at the CLI; the wire-level
LanguageSpec is PerFile, distinct from Auto.
| Scenario | BA2 | BA3 (current) |
|---|---|---|
Missing @Languages: header | Silent ["eng"] default applied to non-English files | Hard error: file is recorded as failed in the job’s file_statuses with a typed message asking the operator to fix the header and re-run. No silent eng fallback. |
Malformed @Languages: (non-ISO code) | First entry passed through to Stanza, which then crashed deep in inference | Hard error at parse with the offending value quoted in the diagnostic. |
Bilingual file (@Languages: spa, eng) | Primary language used | Primary language used (unchanged). Secondary languages routed per-utterance via [- xxx] precodes; @s words routed to L2 dispatch by default. |
Job-level --lang flag | Sentinel that silently overrode per-file headers, the 2026-05-03 morphotag incident: every Czech/Spanish/Polish/French file in a heterogeneous corpus was tagged with English Stanza and stamped with lang=eng provenance | Removed. The CLI surface rejects --lang for these commands. The job record carries lang=per-file and the dashboard displays it as such. |
BA2 source for the silent ["eng"] default:
# pipelines/morphosyntax/ud.py:1104
lang = doc.langs[0] if doc.langs else "eng"
# pipelines/utterance/ud_utterance.py:253
primary_lang = doc.langs[0] if doc.langs else "eng"
The pattern was repeated at ten-plus sites across BA2, every text-NLP
pipeline carried its own if doc.langs else "eng" clause. BA3 inherited
it as parity scaffolding (CommandProfile.lang = "eng" for the three
no---lang commands) until 2026-05-06, when the placeholder was killed
in favor of LanguageSpec::PerFile and resolve_per_file_lang was made
fallible.
Why BA2 used a silent default at all is unclear from the source. BA2
predates the project’s broader push toward strict CHAT validation; the eng
default is consistent with a “produce some output rather than fail”
stance that was reasonable when the corpus was overwhelmingly English
CHILDES data. With heterogeneous data, Cantonese, Polish, Czech,
Spanish, Hong Kong bilingual, the default is unsafe: it falsifies the
output’s @Languages: provenance and tags the wrong morphology onto
the wrong text. BA3 deliberately diverges.
UTR strategy selection (current: GlobalUtr always; two-pass gated)
The UTR (Utterance Timing Recovery) overlap strategy options are
auto, global, and two-pass, controlled by --utr-strategy.
Current behavior (runner/dispatch/utr.rs::resolve_strategy):
--utr-strategy auto(default), always usesGlobalUtr. The language-aware / overlap-inspecting branch was disabled 2026-03-30 after operator-reported alignment regressions on real files, uncorrected end-time overlap (enforce_monotonicityonly checks start times), and insufficient broad validation of the two-pass algorithm beyond the original four corpora.--utr-strategy global:GlobalUtr(explicit; same as auto today).--utr-strategy two-pass:TwoPassOverlapUtr(experimental; opt-in). The lower-levelselect_strategy()inchat_ops/fa/utr.rsinspects the file for+<linkers or⌊CA markers and returnsTwoPassOverlapUtrif either is present, but this function is currently unreachable from Auto.
Historical data (now-disabled auto-selection): an internal
benchmark on 7 non-English files across 4 languages plus the SBCSAE
and Jefferson English corpora measured +4.3pp on SBCSAE and +3.8pp
on Jefferson for the auto-selected two-pass strategy on English,
with regressions on non-English eliminated. These numbers describe
the disabled mechanism, not what auto delivers today.
The companion architecture page Command Flowcharts still shows a “Language == eng?” decision diamond for the older auto-selection behavior; that page needs the same correction.
Per-utterance language routing: improvement over BA2
Status: Implemented in BA3. An improvement over both BA2 and batchalign-next’s eager-loading approach.
BA3 implements full per-utterance language routing for morphosyntax:
utterances with [- fra] precodes are routed to the French Stanza
pipeline, [- spa] to Spanish, etc. The full chain:
- Rust (
morphosyntax/batch.rs): extracts per-utterancelanguage_codefrom[- lang]precodes (falls back to@Languagesheader, then to primary--lang), groups all cache-miss items by language - Rust (
morphosyntax/batch.rs): dispatches language groups viafutures::future::join_all, gated by atokio::sync::Semaphoreofmax_concurrent_groups = max_total_workers / max_workers_per_key. Each language group goes to a separate worker process; concurrency is bounded so the global cap can’t be exceeded. - Rust (
morphosyntax/worker.rs): within each language group, splits large batches into chunks across up tomax_workers_per_key(default 4) workers of the same language, also viajoin_all. - Python worker: receives one chunk, runs Stanza on it, returns raw UD annotations, Python has zero language-routing logic.
Language grouping and dispatch are entirely Rust-owned. Python workers are stateless single-language inference endpoints.
| Behavior | BA2 | BA3 |
|---|---|---|
[- lang] precode parsed | Yes | Yes |
| Per-utterance Stanza routing | No (always primary lang) | Yes (Rust groups by language) |
| Cross-language parallelism | No | Yes (concurrent language groups, semaphore-bounded) |
| Intra-language parallelism | No | Yes (chunked across multiple workers) |
@s:lang per-word routing | No | Yes (L2 dispatch, default-on; opt out via --no-l2-morphotag) |
BA2 parsed the [- lang] precode into override_lang but never used it
for routing: it always called nlp(line_cut) with the single primary
pipeline, and parse_sentence(..., lang[0]) always used the first
declared language. When skipmultilang=True, BA2 skipped non-primary
utterances entirely; when False (default), it processed them with the
wrong language model.
BA3’s @s:lang per-word routing (L2 dispatch) is implemented in
crates/batchalign-transform/src/morphosyntax/l2/: the L2 extractor
identifies @s-marked words, groups them into per-utterance
DispatchSpans by target language, dispatches each span to its
secondary-language Stanza model, and merges the secondary lexical
output back with the primary-language structural info before
splicing the merged Mor items in place of the primary pass’s
L2|xxx placeholders. Default-on; the --no-l2-morphotag flag opts
out and falls back to placeholder-only output. --skipmultilang
remains the utterance-level [- lang] skip control, not the per-word
L2 switch.
BA3’s two-level parallelism caps total active Stanza workers at
max_total_workers (computed from RAM, default ~28 on a fleet
machine). The cross-language semaphore allows up to
max_total_workers / max_workers_per_key language groups to dispatch
at once, and within each group up to max_workers_per_key (default 4)
chunks run in parallel. A 5-language batch with max_workers_per_key=4
on a 28-worker host can dispatch all 5 groups simultaneously
(5 ≤ 28/4 = 7) and use up to 20 concurrent Stanza workers; for
N > max_total_workers / max_workers_per_key the language groups
queue rather than all dispatching at once.
Unsupported languages
Not all ISO 639-3 codes have Stanza models. The TalkBank corpus includes
files with secondary languages like Quechua (que), Jamaican Creole
(jam), Min Nan Chinese (nan), Tamasheq (taq), and und
(undetermined). BA2 and BA3 handle these differently:
| Behavior | BA2 | BA3 |
|---|---|---|
| Unsupported secondary language | Silently processed with wrong model (MultilingualPipeline falls back to primary language) | Detected at preflight, skipped with warning: utterances get empty %mor/%gra |
| Worker crash on unsupported code | Never (MultilingualPipeline absorbs it) | Never (Rust filters before dispatch) |
| POS accuracy for unsupported langs | Wrong (primary-language model applied to foreign text) | Honest (empty rather than wrong) |
BA2’s stanza.MultilingualPipeline was convenient but dishonest: it
silently applied the wrong language model to unsupported languages,
producing POS tags and dependency parses that looked plausible but were
linguistically invalid. BA3’s approach is to fail honestly, an empty
%mor tier is better than a wrong one, because it signals to the user
that the language needs attention rather than hiding the problem behind
plausible-looking garbage.
The supported language set is maintained in
crates/batchalign-transform/src/morphosyntax/pos_hints.rs::is_stanza_supported
(Rust-side gate, called before dispatch in crates/batchalign/src/morphosyntax/batch.rs)
and batchalign/worker/_stanza_loading.py (Python-side mapping). Both
must stay in sync.
Processor capability discovery
BA2 and early BA3 both hardcoded assumptions about which Stanza processors are available per language. This caused runtime crashes when a language lacked an assumed processor.
| Behavior | BA2 | BA3 (before registry) | BA3 (with registry) |
|---|---|---|---|
| Capability source | Hardcoded in code | Hardcoded in 7 tables | Stanza resources.json (authoritative) |
| Dutch utseg | Crash (“constituency not known for nl”) | Crash (same bug) | Graceful (constituency omitted, sentence-boundary fallback) |
| MWT detection | BA2 exclusion-list inversion | Copy of BA2 exclusion list (MWT_LANGS) | Per-language from resources.json |
| Unsupported language | Wrong model (MultilingualPipeline fallback) | Reject/skip at dispatch | Reject at submission with clear error |
| iso3→alpha2 mapping | Hardcoded dict (56 codes) | Hardcoded dict (3 copies: Python + 2 Rust) | Derived from pycountry + resources.json |
| Constituency availability | Always requested (crashed for 180+ languages) | Always requested (same) | Per-language check (~11 languages have it) |
The capability table (batchalign/worker/_stanza_capabilities.py) reads
Stanza’s resources.json once at worker startup and reports per-language
processor availability. This eliminates the manual sync burden between
Python and Rust and prevents runtime crashes from missing processors.
See Language Routing for
the remaining gaps (per-word @s: routing, per-utterance ASR engine
selection).
Transcribe: language auto-detection and code-switching (new in BA3)
Status: Implemented in BA3. Not available in BA2.
BA3 adds full --lang auto support for transcribe with two-stage language
detection and automatic code-switching precode generation. This is entirely
new functionality, BA2 had no equivalent.
| Capability | BA2 Jan 9 | BA3 |
|---|---|---|
--lang auto CLI flag | Not explicitly supported; Whisper auto-detect worked implicitly by omitting the language kwarg | Fully supported with Rev.AI Language ID pre-pass |
| Primary language detection | None, always used user-specified language | Rev.AI Language ID API (audio-based, ~5-30s, high accuracy); whatlang trigram fallback for Whisper |
[- lang] code-switching precodes | Never generated during transcribe | Generated per-utterance via whatlang trigram detection |
Multi-language @Languages header | Single language only | Multiple languages, frequency-ordered, primary first |
| Rev.AI Language Identification API | Not used | Used as pre-pass to detect dominant language from audio features before transcription |
How it works
When --lang auto is passed with the Rev.AI ASR backend (production path):
-
Rev.AI Language ID API (~5-30s): submits audio to a dedicated language identification endpoint. Returns
top_language(e.g.,"es") with confidence score (e.g., 0.907). This uses audio/phonetic features, not text analysis, so it correctly identifies Spanish even in heavily code-switched English/Spanish audio. -
Transcription with concrete language: the detected language is used as the language hint for the transcription job, which enables
speakers_countandskip_postprocessingto be set correctly. -
Per-utterance language detection: after ASR post-processing, each utterance is analyzed by
whatlang(Rust trigram-based detector, ~1ms per utterance). Utterances confidently detected as a different language than the primary get[- lang]precodes in the CHAT output. -
Multi-language
@Languagesheader: the primary language is always listed first; secondary languages are added if they appear inMIN_UTTERANCES_FOR_SECONDARY(= 3) or more utterances, in descending-frequency order. The constant lives incrates/batchalign-transform/src/asr_postprocess/lang_detect.rs.
Accuracy on bilingual audio
On Spanish-primary heavily code-switched audio (e.g. Miami/Bangor spa-eng samples), the two backends behave differently:
- Rev.AI + Language ID typically identifies the primary language
correctly because the Language ID API is audio/phonetic-based, so
@Languagescomes outspa, engand the primary staysspa. - Whisper + whatlang frequently inverts the order to
eng, spawith the wrong primary, because whatlang’s trigram majority vote on ASR text is biased by English content embedded in Spanish utterances. - Per-utterance
[- eng]precode coverage is much lower than ground-truth in either backend, because trigram detection requiresMIN_CHARS_FOR_DETECTION(= 40 alphabetic chars) andUTTERANCE_CONFIDENCE_THRESHOLD(= 0.5) to fire, short or code-mixed utterances fall below those thresholds. Quantitative per-corpus numbers depend on input characteristics; see the language-routing limitations for the full design discussion.
Why Whisper gets the primary language wrong
Whisper auto-detects language internally per 30-second chunk, but the
HuggingFace pipeline does not expose which language was detected. The
pipeline returns text with lang="auto" echoed back, forcing fallback
to whatlang trigram detection on the ASR text output. On code-switched
bilingual text, whatlang frequently misclassifies the primary language
because Spanish utterances containing English words generate enough
English trigrams to tip the majority vote.
Rev.AI’s Language ID API avoids this by using audio-level phonetic features rather than text trigrams.
Transcribe: ASR post-processing improvements
BA3 ports all 8 BA2 ASR post-processing stages to Rust with provenance newtypes that prevent mixing text at different pipeline stages:
| Stage | BA2 Jan 9 | BA3 |
|---|---|---|
| Compound merging | Python, wordlist-driven merge (compounds list, O(n) in membership) | Rust, 3,584 pairs in LazyLock<HashSet<(&str, &str)>> (O(1) lookup) |
| Multi-word splitting | Python, timestamp interpolation | Rust, same algorithm |
| Number expansion | Python, 12 language tables (deu, ell, eng, eus, fra, hrv, ind, jpn, nld, por, spa, tha) | Rust, same 12 tables + Malayalam (mal) added 2026-04-26 for the Whisper-Hub digit-emission bug, 13 total |
| Cantonese normalization | Python (OpenCC dependency) | Rust (ferrous-opencc crate, no Python OpenCC needed) |
| Long-turn splitting | Python, 300-word threshold | Rust, same threshold |
| Retokenization | Python, punctuation-based | Rust, same algorithm |
| Disfluency replacement | Python, string matching | Rust, same wordlists |
| N-gram retrace detection | Python, ad-hoc grouping | Rust, WordKind::Retrace enum + CHAT AST annotation |
Key correctness improvement: BA3’s retrace detection marks words with
WordKind::Retrace in the token pipeline, then build_chat.rs wraps them
in proper CHAT <...> [/] AST nodes (AnnotatedWord / AnnotatedGroup).
Text Cleaning Divergences from BA2
| Operation | BA2 | BA3 | Rationale |
|---|---|---|---|
| Pre-Stanza paren stripping | line_cut.replace("(","") / replace(")","") strips parens from input before Stanza | No pre-Stanza stripping; Word::cleaned_text() handles CHAT notation at extraction time | BA2 silently dropped bare paren words, causing word count mismatches. BA3 still strips parens from MOR lemma output (mor_word.rs:119), same operation as BA2’s lemma post-processing, different stage. |
| MOR lemma hyphen normalization (post-Stanza) | Strip leading/trailing dash, collapse --→-, replace -→, (en-dash) on Stanza lemma output | Same operations, ported line-by-line in mor_word.rs:107-129 | Not a real BA2-vs-BA3 divergence, BA3 preserves the BA2 lemma-cleanup logic by design. The cleaned MOR is the migration contract. |
| Cantonese word segmentation | None | PyCantonese segment() via --retokenize | BA2 had no word segmentation for CJK per-character ASR output. |
| Cantonese POS accuracy on core vocabulary | Stanza zh (Mandarin model), internal benchmarks scored ~50% on core Cantonese; ~63% Mandarin baseline on the UD held-out test set | PyCantonese POS override scores ~95% on core Cantonese vocabulary; a trained Cantonese Stanza model on UD held-out reaches 93.5% (not yet deployed) | BA2 used the same Mandarin model. The exact baseline number depends on the test set used; see Cantonese / CJK Architecture: POS limitations for the cross-test-set picture. |
These divergences mean BA3 morphotag output for Cantonese will differ from BA2 output. The differences are improvements, BA2’s text stripping was lossy and its POS model was wrong for Cantonese. BA2 did string-level wrapping that could produce malformed CHAT when retraces crossed word boundaries or contained special characters.
Provenance newtypes: The token pipeline uses distinct types at each
stage (AsrRawText → AsrNormalizedText → ChatWordText) that prevent
accidentally passing text from one stage to another without the required
transformation. BA2 used bare str throughout.
Transcribe: Rev.AI language code mapping
BA2 used pycountry.languages.get(alpha_3=lang).alpha_2 for ISO 639-3
to Rev.AI code conversion. When pycountry returned None for an unknown
language code, BA2 crashed with an uncaught AttributeError (no fallback
at all). Early BA3 Rust code introduced a silent truncation fallback
(&other[..2]) that produced wrong codes for many languages:
pol→po(should bepl)hak→ha(not a valid ISO 639-1 code)ces→ce(should becs)
BA3 replaces this with an explicit ~78-entry ISO 639-3 → Rev.AI
mapping table in revai/preflight.rs::try_revai_language_hint, with
an "auto" fallback for unmapped languages (logged as a
tracing::warn recommending the operator add an explicit mapping for
the language). The reverse mapping (revai_code_to_iso639_3 in
revai/asr.rs) returns Option<LanguageCode3> rather than panicking
on unknown codes.
8) Forced alignment: compound filler handling (new in BA3)
Three algorithmic improvements to FA that did not exist in BA2 (verified
against batchalign2-jan9 baseline 84ad500b):
8.1) Compound filler splitting for FA extraction
CHAT fillers like &-you_know, &-sort_of, &-I_mean are single tokens
with underscores joining multi-word discourse markers. ASR engines (Whisper,
wav2vec) tokenize these as separate words (“you”, “know”). The Hirschberg
DP aligner could not match the compound token against the split ASR output.
BA3 splits compound fillers at underscores during FA word extraction, then merges the N timings back into one span during injection.
flowchart LR
subgraph "BA2 (broken)"
A2["&-you_know"] -->|"extract"| B2["'you_know'"]
B2 -->|"DP align"| C2["no match ✗"]
C2 --> D2["no timing on %wor"]
end
subgraph "BA3 (fixed)"
A3["&-you_know"] -->|"extract\n(split_compound_filler)"| B3["'you', 'know'"]
B3 -->|"DP align"| C3["match ✓"]
C3 -->|"inject\n(merge N timings)"| D3["you_know 382349_382989"]
end
Source: crates/batchalign/src/chat_ops/fa/extraction.rs::push_fa_word,
crates/batchalign/src/chat_ops/fa/injection.rs::inject_timing_on_word, shared
helper crates/batchalign/src/chat_ops/fa/mod.rs::split_compound_filler.
8.2) Edge filler bullet expansion
UTR-assigned utterance bullets are computed from ASR token matching. ASR
engines don’t transcribe fillers, so bullets exclude filler audio at
utterance boundaries. A trailing &-you_know whose audio lives in the
gap between utterances gets no FA coverage.
BA3 adds a pre-FA pass that expands utterance bullets into adjacent inter-utterance gaps when edge words are fillers.
flowchart TD
subgraph "BA2: bullet too narrow"
U1["*PAR: ... her &-you_know .\n bullet: 458385_459345"]
G1["gap: 459345 → 460325\n(filler audio here)"]
N1["*PAR: but they did try ...\n bullet: 460325_..."]
U1 --> G1 --> N1
style G1 fill:#fee,stroke:#c00
end
subgraph "BA3: bullet expanded"
U2["*PAR: ... her &-you_know .\n bullet: 458385_459835\n(expanded +490ms)"]
G2["gap: 459835 → 460325\n(remaining silence)"]
N2["*PAR: but they did try ...\n bullet: 460325_..."]
U2 --> G2 --> N2
style U2 fill:#efe,stroke:#0a0
end
Algorithm: For each utterance with a leading or trailing filler, expand
the bullet into the gap by min(gap/2, 1500ms). This ensures the FA audio
window includes the filler without stealing from the neighbor.
Source: crates/batchalign/src/chat_ops/fa/expand_for_fillers.rs::expand_bullets_for_edge_fillers.
8.3) FA group trailing gap extension
In addition to per-utterance bullet expansion, FA group audio windows are extended into the gap after the last utterance in a group. This catches trailing fillers at group boundaries where per-utterance expansion alone is insufficient.
Source: crates/batchalign/src/chat_ops/fa/grouping.rs::extend_into_trailing_gap.
Verification
Tested on a user’s 17-3.cha (<corpus>/<group>):
| Utterance | BA2 / BA3 pre-fix | BA3 post-fix |
|---|---|---|
... her &-you_know . (trailing) | you_know: no timing | you_know 459652_459835 ✓ |
&-you_know well , ... (leading) | you_know 454055_454130 (75ms) | you_know 453680_454127 (447ms) ✓ |
... about &-you_know having ... (mid) | you_know 382349_382989 ✓ | you_know 382349_382989 ✓ (unchanged) |
Mid-utterance fillers were already handled correctly, only edge fillers needed the fix.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Why Replace Python CHAT Handling with Rust
Status: Completed Last updated: 2026-05-19 13:34 EDT
The Problem
Batchalign’s morphosyntax pipeline (morphotag) is the most-used command in the
TalkBank processing workflow, run against hundreds of thousands of CHAT files. The
existing Python implementation has several areas where the Rust rewrite improves on the original:
-
Fragile text manipulation. The Python code converts CHAT transcripts to flat strings, sends them through Stanza for NLP analysis, then uses expensive string alignment (O(n*m) dynamic programming) to map Stanza’s output back to the original transcript. This “flatten → process → realign” pipeline is the source of most morphotag bugs, small tokenization differences between Stanza and CHAT cause misaligned %mor/%gra tiers.
-
60+ lines of
.replace()calls. Python’sannotation_clean()function strips CHAT annotations character-by-character to produce “clean” text for Stanza. This is destructive, it loses structural information, and every new annotation type requires adding more.replace()calls. -
Retokenization is a separate ~120-line code path that operates at the character level, building a “backplate” of alignable/non-alignable chunks and applying ~15 regex cleanups. It’s a complex subsystem with many language-specific cases.
-
No structural understanding of CHAT. Python treats CHAT as flat text. It can’t distinguish between a word inside a retrace group (
<I want> [/]) and a regular word without string-level pattern matching. Every edge case requires a new regex.
The Solution: Rust CHAT AST
We’ve built a Rust implementation that parses CHAT into a strongly-typed Abstract Syntax Tree (AST) and operates on it structurally. The key insight: when your data model matches the format’s structure, you don’t need string hacking.
How It Works
Rust Python (ML only)
┌────────────┐ ┌──────────────┐
CHAT → │ Parse AST │ → words JSON → │ Stanza NLP │
file │ Extract │ ← mor/gra JSON ← │ (unchanged) │
│ Inject │ └──────────────┘
│ Serialize │ → CHAT file
└────────────┘
Rust parses the CHAT file once into a proper AST, extracts the words that need NLP analysis by traversing the tree (no string manipulation), sends them to Python’s Stanza via a callback, receives %mor/%gra analysis, attaches it directly to the AST nodes, and serializes back to CHAT. The callback boundary means Python’s role is strictly limited to neural network inference.
Why It’s Safe
150 automated tests across two languages
| Suite | Tests | What’s Verified |
|---|---|---|
| Rust unit tests | 65 | Every module: parsing, alignment, injection, retokenization |
| Python integration tests | 27 | End-to-end pipeline with test doubles |
| Python-Rust equivalence tests | ~20 | Same inputs produce same word extractions |
| DP alignment equivalence tests | ~29 | Rust alignment matches Python alignment exactly |
| CHAT round-trip tests | ~9 | Parse → modify → serialize produces valid CHAT |
No mocks: real test doubles
Following the project’s strict no-mock policy, all tests use lightweight alternate
implementations (e.g., FakeStanzaNLP that returns predetermined analyses for known
words). This means tests exercise the actual code paths, not mocked interfaces.
Boundary-based architecture makes testing natural
The callback pattern creates a clean boundary: Rust sends words, Python returns analysis. Tests can verify each side independently:
- Rust side: Given this CHAT and this callback response, is the output correct?
- Python side: Given these words, does the callback produce the right %mor/%gra?
- End-to-end: Does CHAT → Rust → callback → CHAT produce valid output?
Round-trip verification
Every test that modifies a CHAT file also verifies the output re-parses correctly. The Rust parser and serializer are inverses, if the parser can’t read its own serializer’s output, the test fails.
Structural correctness by construction
The AST approach eliminates entire categories of bugs:
| Bug Class | Python (Text) | Rust (AST) |
|---|---|---|
| Retrace confusion | Regex to detect <...> [/] | UtteranceContent::Retrace: dedicated variant, structurally distinct |
| Special form handling | String search for @c, @s | Word.form_type field, parsed once, always available |
| Word boundary errors | Character-level offset tracking | Words are tree nodes; boundaries are implicit |
| Tier count mismatch | Count words in string, count mor items in string, hope they match | Traverse same tree for extraction and injection, count is always consistent |
| Annotation stripping | .replace() for each character class | AST traversal selects the right nodes; annotations are never destroyed |
Why It’s Efficient
Rust is dramatically faster for text processing
The Hirschberg DP alignment (used for character-level mapping) runs orders of magnitude faster in Rust:
| Input Size | Python | Rust |
|---|---|---|
| n=1,000 | ~500ms | <1ms |
| n=5,000 | minutes | <1s |
For morphotag, the bottleneck is Stanza’s neural network inference (unchanged). But the per-utterance Rust overhead (parsing, extraction, injection, serialization) is negligible compared to Python’s text processing. For large files with thousands of utterances, eliminating Python’s string manipulation overhead adds up.
Less code to maintain
| Component | Python | Rust |
|---|---|---|
| Word extraction | annotation_clean() + lexer filtering: ~100 lines of .replace() and regex | extract.rs: 263 lines of typed traversal |
| %mor/%gra construction | String concatenation in morphoanalyze(): scattered across ~200 lines | mor_parser.rs + inject.rs: 1,056 lines, self-contained |
| Retokenization | ~120 lines of character-level backplate + 15 regex fixups | retokenize.rs: 938 lines, operates on AST nodes |
The Rust code is longer in raw lines but each line does one thing. The Python code is shorter but relies on implicit knowledge about string formats, character positions, and annotation conventions that are never checked at compile time.
The AST is shared infrastructure
The talkbank-model and talkbank-parser crates aren’t just for morphotag.
They power the CHAT validation tooling and will be the foundation for migrating
forced alignment and utterance segmentation to Rust. Every pipeline that
currently uses annotation_clean() will benefit from the same AST.
What’s Been Migrated
Complete
- Standard morphosyntax (
retokenize=false): CHAT → extract words → Stanza callback → inject %mor/%gra → CHAT - Retokenized morphosyntax (
retokenize=true): Same, plus AST restructuring to match Stanza’s tokenization - Special forms (
@c,@s,@b): Extracted from AST, passed in callback, restored after analysis - Multi-language skip (
skipmultilang): Utterances with[- lang]override correctly skipped - Progress reporting: Per-utterance progress callbacks work through Rust
No Python Fallback
The Rust AST path is the only code path. The former Python morphoanalyze()
implementation and its _process_python() fallback have been removed. The
batchalign_core Rust extension is required.
Completed Follow-Ups
All follow-up steps from the original decision have been completed:
- Production testing against large corpus: Completed. Rust pipeline validated against existing annotations.
- Forced alignment migration: Completed. FA uses the same AST + callback
pattern via
inference/fa.py. - Utterance segmentation migration: Completed. Utseg uses the same pattern
via
inference/utseg.py. - Python fallback removed: The Python
morphoanalyze()code has been removed. All morphosyntax processing goes through the Rust AST path.
Summary
The Rust morphosyntax pipeline is safer (AST eliminates string-hacking bugs), faster (orders of magnitude for DP alignment), better tested (150 tests across Rust and Python), and architecturally cleaner (callback pattern isolates ML from text processing). The Python fallback has since been removed (see §“No Python Fallback” above), and the underlying AST infrastructure is reusable for every TalkBank processing pipeline.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Persistent State and Behavioral Changes
Status: Current Last updated: 2026-09-06 23:59 EDT
Batchalign3 introduces several stateful behaviors that did not exist in Batchalign2. This page documents every form of persistent state, where it lives, and how it differs from BA2’s stateless model.
Overview: BA2 vs BA3 execution model
BA2: Every invocation was a fresh Python process. Models loaded from scratch, results computed from scratch, nothing persisted between runs. This was simple but slow, re-processing the same file paid the full cost every time.
BA3: The Rust CLI manages persistent state across runs: a local daemon keeps models warm, a SQLite cache stores analysis results, and ML models are cached on disk. This makes repeated operations dramatically faster but introduces state that users need to understand.
Persistent state locations
| State | Location | What it stores |
|---|---|---|
| Analysis cache | platform-dependent OS cache dir (see below) | SQLite database of NLP results keyed by content hash |
| Model cache | platform-dependent (see below) | Downloaded ML model weights (~2 GB) |
| Config file | ~/.batchalign.ini | ASR engine selection, Rev.AI API key |
| Run logs | platform-dependent OS cache dir | Per-run structured logs |
| Daemon PID | platform-dependent | Background process state |
Analysis cache and run-log locations
Both live under the OS-conventional cache directory for the
batchalign3 application:
- macOS:
~/Library/Caches/batchalign3/(cache.db + logs/) - Linux:
~/.cache/batchalign3/(cache.db + logs/) - Windows:
%LocalAppData%\batchalign3\(cache.db + logs/)
Model cache locations
Models are stored by the ML libraries (Stanza, Whisper, etc.) in their default cache directories:
- macOS:
~/Library/Caches/(Stanza),~/.cache/whisper/(Whisper) - Linux:
~/.cache/stanza/,~/.cache/whisper/ - Windows:
%LOCALAPPDATA%\stanza\,%USERPROFILE%\.cache\whisper\
Analysis cache
The analysis cache is the largest behavioral difference from BA2. BA3
caches audio inference results only: UTR ASR (utr_asr) and
forced alignment (forced_alignment). Text-NLP commands (morphotag,
translate, utseg, coref) deliberately do not cache; each run
recomputes from scratch so that model or pipeline changes always take
effect immediately.
Audio cache keys combine media identity with the parameters owned by each cache layer. Media identity currently uses canonical path, modification time and size; it is not a content digest. The UTR normalized-response keys include the selected ASR provider, language and, for segments, window bounds. Provider-free legacy UTR entries are not automatically replayed because their producing backend is unknown.
- Repeated alignment can reuse matching retained UTR and FA entries, but still performs orchestration and may load workers. Reuse does not imply instant output.
- Transcript edits can reuse raw ASR while changing downstream FA groups.
- UTR matching tuning changes projection of retained ASR; it does not change the raw ASR request. Selecting another UTR provider changes its key.
- Symlinks and alternate spellings of the same path share media identity. Moving identical audio can miss the metadata-based identity. Changing only FA can also miss UTR’s current outer worker-version partition.
- Text-NLP commands re-run rather than returning cached analysis.
The forced-alignment cache reference describes the two key spaces and their current limitations.
Managing the cache:
batchalign3 cache stats # Show cache size and entry count
batchalign3 cache clear # Delete cached results (with confirmation)
batchalign3 cache clear --all # Also remove permanent UTR cache entries
BA2 had no cache. Every invocation computed results from scratch.
Local daemon
When you run a processing command, the CLI may start a background daemon process that keeps ML models loaded in memory. This eliminates model loading time on subsequent runs (5-20x speedup).
When this matters:
- The daemon uses memory even after your command finishes
- It persists across Python process exits (important for compat shim users)
- Multiple concurrent commands share the same daemon
Managing the daemon:
batchalign3 serve status # Check if daemon is running
batchalign3 serve stop # Stop the daemon (frees memory)
batchalign3 serve start # Start the daemon explicitly
BA2 had no daemon. Every invocation loaded models from scratch.
ML model downloads
The first time you run a processing command, ML models are downloaded automatically. This is a one-time cost of ~2 GB. Subsequent runs use cached models from disk.
When this matters:
- First run of
morphotagdownloads Stanza models (~500 MB) - First run of
aligndownloads Whisper/Wave2Vec models (~1-2 GB) - No network connection needed after first download
- The download itself surfaces through
progress_v2events on every UI channel; there is no separate pre-warm CLI command in BA3
BA2 also downloaded models on first use, but the behavior is the same.
Config file
~/.batchalign.ini stores the default ASR engine selection and API keys.
Created by batchalign3 setup. This is the same format as BA2.
Implications for subprocess integrations
There is no public Python API in BA3; the supported integration path
from Python is subprocess-into-batchalign3. The Python compat
shim (batchalign.compat.BatchalignPipeline, etc.) has been removed
along with the rest of the BA2 Python API, see
Developer Architecture Migration.
For scripts that drive batchalign3 via subprocess, the persistent-state
points still matter:
- The first call may be slow: models download and daemon starts.
- The daemon persists: after your driver process exits, the daemon
continues running. Stop it explicitly with
batchalign3 serve stopif you don’t want it. - Audio-task results are cached: re-aligning identical media is near-instant; text-NLP results recompute every run.
- Memory usage: the daemon holds ML models in memory (~2-4 GB) until you stop it.
To disable the audio cache for a specific run (BA2-like always-recompute for the cached tasks):
batchalign3 align ~/corpus/ -o ~/output/ --override-media-cache
The --override-media-cache-tasks <list> per-command flag offers
finer-grained control. Text-NLP commands never cache, so there is no
analogous flag for them.
Clearing all state
To reset to a clean state:
batchalign3 serve stop # Stop daemon
batchalign3 cache clear # Clear analysis cache
batchalign3 logs --clear # Clear run logs
# Model caches are managed by ML libraries; delete manually if needed
This page last changed: 2026-09-07 (commit 52b853df). The whole book last changed: 2026-09-16 (commit 34d249d8).
Batchalign2 CLI Reference (Baseline)
Status: Reference Last updated: 2026-09-07 07:04 EDT
This document captures the CLI surface of Batchalign2 across two baselines:
- Jan 9 baseline (
84ad500b): the primary migration anchor. - Feb 9 master (
e8f8bfad): the later released BA2 master branch, which added concurrency, caching,compare,cache,bench, and several global options not present in the Jan 9 baseline.
Features that exist only in the Feb 9 master (not in the Jan 9 baseline) are
marked with (Feb 9 only) throughout this document. The Jan 9 baseline CLI
was simpler: only -v/--verbose as a global option, and no compare, cache,
or bench commands.
Source: batchalign/cli/cli.py using rich_click (Click wrapper).
Global Options
These are defined on the top-level batchalign group and available to all
commands.
Jan 9 baseline: The only global option was -v/--verbose. All other global
options listed below were added in the Feb 9 master.
The Feb 9 globals BA3 carries forward (as wired flags) are
--verbose, --workers, --timeout, and --force-cpu. Every other
Feb 9 global was removed, passing it to BA3 produces a clap parse
error, not a silent no-op.
| Flag | Type | Default | Baseline | BA3 Status |
|---|---|---|---|---|
-v / --verbose | count | 0 | Jan 9 | Wired (global verbosity) |
--workers | int | os.cpu_count() | Feb 9 only | Wired (worker count) |
--memlog | flag | off | Feb 9 only | Removed |
--mem-guard / --no-mem-guard | flag | off | Feb 9 only | Removed |
--adaptive-workers / --no-adaptive-workers | bool | True | Feb 9 only | Removed |
--pool / --no-pool | bool | True | Feb 9 only | Removed |
--lazy-audio / --no-lazy-audio | bool | True | Feb 9 only | Removed |
--adaptive-safety-factor | float | 1.35 | Feb 9 only | Removed |
--adaptive-warmup | int | 2 | Feb 9 only | Removed |
--force-cpu | bool | False | Feb 9 only | Wired (no --no-force-cpu companion in BA3) |
--shared-models / --no-shared-models | bool | False | Feb 9 only | Removed |
All commands except avqi, setup, and version use the common_options
decorator which adds positional IN_DIR and OUT_DIR arguments (both
click.Path(exists=True, file_okay=False)).
Processing Commands
align
Forced alignment: adds word-level timing bullets to existing CHAT transcripts.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--whisper / --rev | exclusive pair | --rev | UTR engine selection | Hidden compat alias → --utr-engine |
--wav2vec / --whisper_fa | exclusive pair | --wav2vec | FA engine selection | Hidden compat alias → --fa-engine |
--pauses | flag | off | Keep each word’s own end time instead of healing small gaps into it. Applies to every FA engine. | Wired |
--wor / --nowor | bool | True | Write %wor tier | Wired (default_value_t = true) |
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations | Wired |
Pipeline task: "fa" (forced alignment).
transcribe
Create transcripts from audio files via ASR.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--whisper_oai / --rev | exclusive pair | --rev | ASR engine (OAI variant) | Hidden compat alias → --asr-engine; the engine is not implemented, so the job is refused |
--whisper / --rev | exclusive pair | --rev | ASR engine (HF variant) | Hidden compat alias → --asr-engine |
--whisperx / --rev | exclusive pair | --rev | ASR engine (WhisperX variant) | Hidden compat alias → --asr-engine; the engine is not implemented, so the job is refused |
--diarize / --nodiarize | bool | False | Speaker diarization | Hidden compat alias → --diarization |
--wor / --nowor | bool | False | Write %wor tier | Wired |
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations (Feb 9 only) | Wired |
--lang | str | "eng" | Language code | Wired |
-n / --num_speakers | int | 2 | Expected speaker count | Not as spelled. BA3 takes --num-speakers (kebab). It never accepted BA2’s --num_speakers underscore, and it stopped accepting -n on 2026-08-19. |
Pipeline task: "asr" (without diarization) or transcribe_s dispatch →
"asr,speaker" (with --diarize).
Jan 9 behavior note: the preserved BA2 CLI help text said --diarize was
“ignored with Rev.AI”, but the implementation did not do that. CLI dispatch
still routed --diarize to transcribe_s, and transcribe_s still ran the
post-ASR speaker pipeline after Rev transcription.
Note on --wor: BA2 default was False (no %wor). Current BA3 preserves
that policy and wires --wor / --nowor through the Rust transcribe path.
Earlier migration-stage notes flagged this as a regression, but current code and
tests now cover the %wor toggle explicitly.
morphotag
Morphosyntactic analysis (POS, lemma, dependency parse).
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--retokenize / --keeptokens | bool | False | Retokenize main line for UD | Wired |
--skipmultilang / --multilang | bool | False | Skip multilingual files | Wired |
--lexicon | path | None | Manual lexicon override | Wired |
--override-media-cache / --use-cache | bool | False | Bypass analysis cache (Feb 9 only) | Wired |
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations (Feb 9 only) | Wired |
--no-l2-morphotag | flag | off | Opt out of BA3’s default-on per-word @s secondary dispatch and keep legacy L2|xxx placeholders | BA3-only |
Pipeline task: "morphosyntax".
Migration note: --skipmultilang and --no-l2-morphotag are not
equivalent. --skipmultilang is the utterance-level [- lang] skip control;
--no-l2-morphotag is the BA3-only opt-out for per-word @s routing. BA3
also validates whole-utterance same-language all-@s patterns as E255 and
warns on explicit @s:LANG missing from @Languages as E254; chatter debug fix-s repairs both transcript-side issues.
translate
Translation to English.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations (Feb 9 only) | Wired |
--translate-engine google|seamless|nllb|tencent|aliyun | enum | google | Pick translation engine | BA3-only |
Pipeline task: "translate".
BA2 engine selection. BA2 had no --translate-engine flag.
Operators picked Seamless by editing ~/.batchalign.ini:
[translate]
engine = seamless_translate
The [translate] engine entry was read by
pipelines/dispatch.py:resolve_engine_specs and silently became the
engine for every subsequent BA2 invocation on that host until the
file was edited again, exactly the per-host hidden-state pattern
that the BA3 design rejects.
BA3 replacement. BA3 surfaces the same capability as an explicit
CLI flag (--translate-engine google|seamless|nllb|tencent|aliyun)
plus the shared --engine-overrides '{"translate":"<engine>"}'
global flag. Default remains Google for fleet-wide behavior parity.
Hosts where Google is unreachable (mainland-China sites behind the
GFW) pass --translate-engine tencent (best Mandarin),
--translate-engine aliyun (Cantonese-capable cloud), or
--translate-engine nllb (self-hosted local model) per invocation.
BA3 deliberately does not honor the BA2 [translate] engine config
key, engine choice is a policy decision that lives at the command
line.
coref (hidden)
Coreference resolution. Hidden from --help in BA2.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations (Feb 9 only) | Wired |
Pipeline task: "coref".
utseg
Utterance segmentation.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--lang | str | "eng" | Language code | Wired |
-n / --num_speakers | int | 2 | Expected speaker count | Not as spelled. BA3 takes --num-speakers (kebab). It never accepted BA2’s --num_speakers underscore, and it stopped accepting -n on 2026-08-19. |
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations (Feb 9 only) | Wired |
Pipeline task: "utseg".
benchmark
ASR word error rate benchmarking against gold transcripts.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--whisper / --rev | exclusive pair | --rev | ASR engine (HF variant) | Hidden compat alias → --asr-engine |
--whisper_oai / --rev | exclusive pair | --rev | ASR engine (OAI variant) | Hidden compat alias → --asr-engine; the engine is not implemented, so the job is refused |
--lang | str | "eng" | Language code | Wired |
-n / --num_speakers | int | 2 | Expected speaker count | Not as spelled. BA3 takes --num-speakers (kebab). It never accepted BA2’s --num_speakers underscore, and it stopped accepting -n on 2026-08-19. |
--wor / --nowor | bool | False | Write %wor tier | Wired |
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations (Feb 9 only) | Wired |
Pipeline task: "asr" (transcribe) + "morphosyntax" (compare).
compare (Feb 9 only)
Transcript comparison against gold-standard references.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--lang | str | "eng" | Language code | Wired |
--merge-abbrev / --no-merge-abbrev | bool | False | Merge abbreviations (Feb 9 only) | Wired |
Pipeline task: "morphosyntax" (compare uses morphosyntax to tag both transcripts before WER computation).
Note on --lang: BA2 passed --lang through the compare pipeline for
morphosyntax. Current BA3 also exposes --lang on CompareArgs and carries it
through compare dispatch. Earlier migration-stage notes flagged this as missing,
but the current CLI surface and tests cover it.
opensmile
OpenSMILE acoustic feature extraction.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--feature-set | choice | "eGeMAPSv02" | eGeMAPSv02 / eGeMAPSv01b / GeMAPSv01b / ComParE_2016 | Wired |
--lang | str | "eng" | Language code | Wired |
Note: Uses its own input_dir/output_dir positional arguments instead of
common_options.
Pipeline task: "opensmile".
avqi
Acoustic Voice Quality Index from paired .cs/.sv audio files.
| Flag | Type | Default | Help | BA3 Status |
|---|---|---|---|---|
--lang | str | "eng" | Language code | Wired |
Note: Uses its own input_dir/output_dir positional arguments instead of
common_options.
Pipeline task: "avqi".
Admin Commands
setup
Interactive configuration wizard. Creates/updates ~/.batchalign.ini with
default ASR engine and Rev.AI API key.
No command-specific flags in BA2. BA3 adds --engine, --rev-key, and
--non-interactive for scripted setup.
version
Prints version and credits via pyfiglet.
No flags. BA3 equivalent: batchalign3 version.
Utility Commands
cache (Feb 9 only)
Cache management. Not present in the Jan 9 baseline. Registered as an external
Click subcommand from the BA2 cache CLI module in the Feb 9 master.
BA2 subcommands: stats, clear, warm. BA3 supports stats and clear
(with --all and --yes options).
bench (Feb 9 only)
Repeated benchmark execution for performance measurement. Not present in the
Jan 9 baseline. Registered as an external Click subcommand from the BA2 bench
CLI module in the Feb 9 master.
BA3 equivalent: batchalign3 bench <command> <in_dir> <out_dir> --runs N.
models
Model training utilities. Registered via add_command from
batchalign.models.training.run.
BA2 subcommand: train. BA3 adds prep (Rust-native training text extraction)
alongside train (Python runtime).
batchalignHK Plugin (Archived)
The HK plugin was a separate PyPI package (batchalign-hk-plugin) that
registered additional ASR/FA engines via Python entry points. It was folded
into batchalign3 as built-in engines in March 2026; there is no separate HK
install tier now.
Plugin Discovery
BA2 used importlib.metadata.entry_points(group="batchalign.inference") to
discover plugin-provided InferenceProvider implementations at startup. Each
provider registered PluginDescriptor objects declaring engine name, task type,
and factory function.
Engines
| Engine | Task | Module | Credentials |
|---|---|---|---|
tencent | ASR | batchalign_hk.tencent_asr | Tencent Cloud API key |
aliyun | ASR | batchalign_hk.aliyun_asr | Aliyun NLS API key |
funaudio | ASR | batchalign_hk.funaudio_asr | None (local model) |
wav2vec_canto | FA | batchalign_hk.cantonese_fa | None (local model) |
Selection
Engines were selected via --engine-overrides '{"asr": "tencent"}' on the
CLI. The JSON payload was parsed into a BTreeMap<String, String> and
forwarded to worker dispatch, which matched the engine name against plugin
registrations.
BA3 Status
All four engines are now built-in modules under batchalign/inference/languages/cantonese/.
Engine dispatch uses AsrEngine/FaEngine enums in worker/_types.py.
The plugin discovery mechanism (PluginDescriptor, InferenceProvider,
entry points) has been completely removed. See
Plugin Removal Notes for the full migration record.
Pipeline Task Mapping
| Command | Pipeline Task String | Notes |
|---|---|---|
align | "fa" | Forced alignment |
transcribe | "asr" | Without diarization |
transcribe (diarized) | "asr" + speaker | With --diarize |
morphotag | "morphosyntax" | POS + lemma + depparse |
translate | "translate" | Google Translate / Seamless M4T |
coref | "coref" | English only, document-level |
utseg | "utseg" | Constituency parse → boundaries |
benchmark | "asr" + "morphosyntax" | Transcribe then compare |
compare | "morphosyntax" | Tags both sides before WER |
opensmile | "opensmile" | Feature extraction |
avqi | "avqi" | Voice quality index |
Regression Summary
No current CLI-surface regressions are recorded in this baseline table for
transcribe, benchmark, or compare.
Historical note: earlier BA3 migration notes temporarily flagged
transcribe --wor, benchmark --wor, and compare --lang as regressions
during the rewrite. Current code and tests now wire those surfaces.
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Debugging and Tracing Migration
Status: Current Last updated: 2026-05-19 13:34 EDT
BA2 Baseline: No Principled Debugging Story
Batchalign2 (baseline commit 84ad500b) had no structured debugging infrastructure:
- Tiered
-vconsole logging (~30 scatteredL.info()/baL.info()calls, ~15 rawprint()statements) via Python’sloggingmodule and Rich console formatting - Ephemeral console-only output: no filesystem dumps, no structured traces, no per-stage instrumentation, no debug env vars, no timing breakdowns, no metrics
- Debugging meant: run with
-vvv, read console, manually inspect I/O files
There were no debug artifacts, no offline replay capability, and no way to reproduce a pipeline failure without re-running the ML models.
BA3: Three-Tier Debugging Architecture
BA3 introduces a principled three-tier approach:
Tier 1: Structured Logging (tracing crate)
Already shipped. See Tracing and Debugging
for the -v/-vv/-vvv verbosity system, engine boundary tracing, and
per-component instrumentation.
Tier 2: --debug-dir for Reproducible Filesystem Dumps
The --debug-dir PATH CLI flag (or BATCHALIGN_DEBUG_DIR env var) enables
structured CHAT/JSON artifact dumps at each pipeline stage. This enables:
- Offline TDD: load fixture data, call pipeline functions, assert on output without running ML models
- Test fixture generation: debug artifacts from real pipeline runs become regression test inputs
- Stage decomposition: inspect intermediate state between every pipeline stage
Coverage: debug artifact dumps are wired into both the align (FA/UTR) and transcribe (ASR → build CHAT → utseg → morphosyntax) pipelines.
Full directory layout for all artifact types:
debug-dir/
# ── Transcribe pipeline artifacts ──
sample_asr_response.json # Raw ASR tokens + timestamps
sample_post_asr.cha # CHAT after assembly (before utseg)
sample_pre_utseg.cha # CHAT entering utterance segmentation
sample_post_utseg.cha # CHAT after utterance segmentation
sample_pre_morphosyntax.cha # CHAT entering morphosyntax
# ── Align pipeline artifacts ──
sample_utr_input.cha # CHAT before UTR injection
sample_utr_tokens.json # ASR timing tokens fed to UTR
sample_utr_output.cha # CHAT after UTR injection
sample_utr_result.json # UTR injection statistics
sample_fa_input.cha # CHAT before FA (after UTR)
sample_fa_grouping.json # FA group plan (time windows, words)
sample_fa_group_0.json # Per-group words + timings
sample_fa_group_1.json
sample_fa_output.cha # Final aligned CHAT
Tier 2b: Always-On Error Logging
Even without --debug-dir, certain failure modes automatically log diagnostic
data at WARN level, zero cost in the happy path:
| Failure | What is logged |
|---|---|
| Utseg pre-validation fails | Full CHAT text + parse error details |
| Whisper inverted timestamps | Warning with start/end values |
| MOR item count mismatch | Word count + MOR count + utterance text |
| Stanza sentence count mismatch | Expected vs actual counts |
Tier 3: Dashboard Traces (debug_traces)
When --debug-dir is specified, debug_traces is automatically enabled on job
submissions. The server collects FaTimelineTrace data for each file and
exposes it via GET /jobs/{id}/traces for dashboard visualization.
Example Workflow: Reproduce a Transcribe-to-Utseg Failure
# 1. Run transcription with debug artifacts
batchalign3 transcribe audio/ output/ --lang eng --debug-dir /tmp/ba3-debug
# 2. If utseg fails, inspect the CHAT that was produced
cat /tmp/ba3-debug/sample_post_asr.cha
# 3. Validate it offline to find the exact parse error
cargo run -p talkbank-cli -- validate /tmp/ba3-debug/sample_post_asr.cha
# 4. Without --debug-dir, check server logs for the automatic warn! dump
Example Workflow: Reproduce a UTR Failure
# 1. Run alignment with debug artifacts
batchalign3 align input/ output/ --lang eng --debug-dir /tmp/ba3-debug
# 2. Inspect the UTR input and tokens
cat /tmp/ba3-debug/sample_utr_input.cha
jq . /tmp/ba3-debug/sample_utr_tokens.json
# 3. Write a test that loads the fixtures and calls inject_utr_timing directly
# (no ML model needed, the tokens are already captured)
Fine-Grained Cache Overrides
BA3 also introduces --override-media-cache-tasks for per-task cache control:
# Skip only UTR ASR cache (keep morphosyntax, FA caches)
batchalign3 align input/ output/ --override-media-cache-tasks utr_asr
# Skip UTR + FA caches
batchalign3 align input/ output/ --override-media-cache-tasks utr_asr,forced_alignment
The flag is honored only for audio tasks that have a cache:
utr_asr and forced_alignment. Text-NLP tasks
(morphosyntax, utterance_segmentation, translation) are
accepted on the CLI for forward compatibility but are no-ops
because BA3 does not cache text-NLP results, each run recomputes
from scratch.
The existing --override-media-cache flag continues to skip every
honored cache in one shot.
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
Introduction
Status: Current Last updated: 2026-08-30 21:00 EDT
Batchalign is a toolkit for language sample analysis (LSA) from the TalkBank project. It processes conversation audio files and their transcripts in CHAT format, providing automatic speech recognition, forced alignment, morphosyntactic analysis, translation, utterance segmentation, and audio feature extraction.
The standalone batchalign3 binary (written in Rust) provides the CLI and
an HTTP server for offloading work to a central machine. Python ML workers
(Stanza, Whisper, etc.) handle inference and are managed automatically by
the server.
batchalign3 is the supported public Batchalign surface today. It is a
public preview product line with wheels for Windows, macOS, and Linux. The
separate Batchalign Desktop shell in apps/dashboard-desktop/ is still
experimental and should not be described as the supported first-time-user entry
point. For current platform details, see Platform Support
and the Release Contract.
batchalign3 is installed from GitHub releases (there is no PyPI package): run
the installer one-liner from the
Installation guide, which bootstraps uv and
installs the CLI with a uv-managed Python. Repo-hosted .command / .bat
double-click wrappers run the same installer.
Who is Batchalign for?
Batchalign is designed for researchers and clinicians who work with conversation transcripts – particularly those stored in TalkBank’s CHAT format. Typical workflows include:
- Transcribing recorded conversations into CHAT files via ASR (Rev.AI or OpenAI Whisper).
- Aligning existing transcripts against audio to produce word-level and utterance-level timestamps.
- Tagging transcripts with morphological and dependency analyses (
%morand%gratiers) using Stanford Stanza. - Translating non-English transcripts to English.
- Segmenting unsegmented text into utterances.
- Extracting acoustic features (OpenSMILE, AVQI) from speech recordings.
Key features
- Rust-backed CHAT parsing. All CHAT reading and writing goes through a
Rust AST (
batchalign_core), ensuring correct handling of CHAT’s complex encoding, escaping, and continuation rules. - Per-utterance caching. Morphosyntax, forced alignment, and utterance segmentation results are cached in a local SQLite database so that reprocessing the same corpus is nearly instant.
- Server mode. A built-in HTTP server lets you offload processing to a central lab machine. Clients send small CHAT files (~2 KB each); the server resolves media from configured volume mounts and does all the heavy computation.
- Automatic concurrency tuning. The CLI auto-tunes worker counts based on available RAM and GPU resources, and manages a persistent local daemon so model loads are amortized across successive commands.
How to use this book
This book is organized into six sections:
- Migration Book – the authoritative public crosswalk from the previous
release to the current version, anchored to the January 9, 2026 baseline
84ad500...and, where needed, the February 9, 2026 released BA2 master pointe8f8bfa.... - User Guide – Installation, quick start, CLI reference, Python API, server setup, and troubleshooting.
- Architecture – How the pipeline, engine, dispatch, caching, and validation systems work internally.
- Technical Reference – Detailed documentation on CHAT format, morphosyntax, forced alignment, multilingual support, and more.
- Developer Guide – Building from source, testing conventions, adding new engines, and working with the Rust core.
- Design Decisions – ADRs and accepted design notes on the implemented Rust control plane, correctness work, and server orchestration.
If you are a new user, start with Installation
and Quick Start. If you are migrating from
a previous version, start with Migration Guide.
There is no public Python API, the supported integration path from
Python is subprocess-into-batchalign3. See
No Python API for the full statement.
Acknowledgments
The TalkBank Project, of which Batchalign is a part, is supported by NIH grant HD082736.
If you have questions or encounter issues, please open an issue in the repository’s issue tracker.
This page last changed: 2026-08-30 (commit 0964e762). The whole book last changed: 2026-09-16 (commit 34d249d8).
Installation
Status: Current Last updated: 2026-08-31 22:01 EDT
batchalign3 is distributed via GitHub releases (there is no PyPI package).
The installer bootstraps uv if needed, installs
batchalign3 into an isolated environment using a uv-managed Python (3.13 by
default), and re-running it upgrades to the latest release.
# macOS / Linux
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/FranklinChen/talkbank-tools/releases/latest/download/install-batchalign3.sh | sh
# Windows (PowerShell)
irm https://github.com/FranklinChen/talkbank-tools/releases/latest/download/install-batchalign3.ps1 | iex
After installing, open a new terminal so the batchalign3 command is on
your PATH, then:
batchalign3 --help
Pre-built wheels are published for all five platforms (macOS Apple Silicon +
Intel, Linux x86_64 + aarch64, Windows x86_64). One abi3 wheel per platform
covers Python 3.13 and newer. batchalign3’s own dependencies still resolve
from PyPI, so the first install downloads large ML dependencies.
System requirements
| Requirement | Details |
|---|---|
| Python | 3.13 or 3.14 (a uv-managed 3.13 is used by default) |
| Disk space | Several GB for ML models (downloaded on first use) |
| RAM | 8 GB minimum, 16 GB recommended |
| FFmpeg | Only needed for some media formats |
| Platforms | macOS Apple Silicon + Intel, Linux x86_64 + aarch64, Windows x86_64 |
Choosing the Python version
The installer uses a uv-managed Python 3.13 by default. To install against a
different supported version, set BATCHALIGN3_PYTHON before running it:
BATCHALIGN3_PYTHON=3.13 curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/FranklinChen/talkbank-tools/releases/latest/download/install-batchalign3.sh | sh
Double-click helpers
If you prefer not to use a terminal, the repo ships double-click wrappers that run the same installer:
- macOS: install-batchalign3.command
- Windows: install-batchalign3.bat
The downloaded helpers are not code-signed, so macOS Gatekeeper / Windows
SmartScreen may warn on first run; see the
installers README
for the click-through. They install uv if needed and then run the canonical
installer.
Updating
Re-run the installer one-liner; it reinstalls the latest release in place:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/FranklinChen/talkbank-tools/releases/latest/download/install-batchalign3.sh | sh
Offline / manual install from a wheel
Every release attaches per-platform wheels plus a sha256.sum. To install
without the script (for example on an air-gapped machine), download the wheel
for your platform from the
latest release
and install it with uv:
uv tool install --python 3.13 ./batchalign3-0.4.1-cp313-abi3-macosx_11_0_arm64.whl
First run
The first time you run a processing command (for example morphotag), ML
models are downloaded automatically. This is a one-time cost of several GB and
may take a few minutes depending on your connection; subsequent runs use cached
models.
Evaluating the experimental GUI shell? See
Batchalign Desktop (Experimental). The supported first-time
user path is the batchalign3 CLI above.
Worker Python resolution
The CLI finds a Python 3.13 runtime automatically, via BATCHALIGN_PYTHON, the
active virtualenv, a sibling/project .venv, or python3.13 on PATH. Override
explicitly:
# macOS / Linux
export BATCHALIGN_PYTHON=/path/to/venv/bin/python
# Windows (PowerShell)
$env:BATCHALIGN_PYTHON = "C:\path\to\venv\Scripts\python.exe"
The visible batchalign3 command is a thin Python launcher that immediately
execs the packaged Rust CLI binary. The launcher also preserves the chosen
Python runtime for worker subprocesses, so batchalign3 serve ... and
background/daemon flows run through the same Rust CLI/server codepath as direct
invocation of the packaged binary.
Verify the installation
batchalign3 --help
Confirm the chosen Python runtime can import the worker package:
$BATCHALIGN_PYTHON -c "import batchalign.worker"
If you are relying on VIRTUAL_ENV or python3 instead of BATCHALIGN_PYTHON,
run the same import check with that interpreter.
Rev.AI setup
If you plan to use the default Rev.AI-backed transcription path, initialize
~/.batchalign.ini:
batchalign3 setup
See Rev.AI Integration for details.
Development install
For contributors working from a source checkout:
git clone https://github.com/FranklinChen/talkbank-tools.git
cd talkbank-tools
make batchalign-python-prepare # build wheel + sync uv env + install
make build # cargo build --workspace --release
make batchalign-python-prepare rebuilds the wheel via the maturin backend
declared in pyproject.toml, runs uv sync --group dev --no-install-project,
and installs the freshly built wheel into the dev environment.
make build runs cargo build --workspace --release. It does not rebuild the
embedded dashboard; if you also need the React dashboard rebuilt, run
make batchalign-dashboard-build (which requires Node.js + npm in addition to
Rust and uv).
In a source checkout, uv run batchalign3 is the normal way to invoke the
console script; the maturin backend’s profile = "dev" setting means each
uv run ... triggers an incremental rebuild of the PyO3 extension on demand.
Reserve uv run for Python tools such as pytest, mypy, and maturin when
you are not invoking the CLI itself.
For the fastest contributor loop:
uv run batchalign3 --help # incremental PyO3 rebuild via maturin/uv
cargo build -p batchalign # native batchalign3 binary (debug)
./target/debug/batchalign3 --help
For the fuller contributor workflow and rebuild matrix, see Building & Development.
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Model Downloads and Caching
Status: Current Last updated: 2026-09-16 03:36 EDT
The contract
batchalign3 downloads every ML model it needs automatically, the first time a command needs it. You never have to seed models, run setup scripts, or remember which language pack lives where. The only error you should ever see related to model downloads is one of these:
- “Failed to download …: network unreachable”, your machine can’t reach the internet (or the upstream is down). Try again when you have network.
- “Failed to download …: disk full”, free some space and retry.
- “Failed to download …: HTTP 401/403”, a configured custom model requires
authentication. BA3’s three PINNED released local Pyannote artifacts
(
talkbank/dia-forkand its segmentation/embedding dependencies) are public and ungated, but that engine also fetches one UNPINNED PLDA calibration artifact whose default is currently the GATEDpyannote/speaker-diarization-community-1repository; the model-access error names the repository and the fix (a Hugging Face token). See diarize.
If you see anything else, anything along the lines of “capability table is unavailable”, “resources.json could not be read”, “model not found locally” , that is a batchalign3 bug. file an issue on GitHub. You should not have to think about model storage.
What downloads, when, and roughly how big
Every download is one-time. After the first run, the model lives in your local cache and the same command runs without any download.
| When you run | What downloads | Approximate size | Approximate first-run wait |
|---|---|---|---|
morphotag (any language) | Stanza resource catalog (resources.json) | ~1 MB | 1-2 seconds |
morphotag (first time for a language) | Stanza language pack for that language | 250-500 MB | 30 s to 2 min |
morphotag --retokenize on a Cantonese file (@Languages: yue) | Nothing extra, PyCantonese is bundled | Not applicable | Instant |
morphotag --retokenize on a Mandarin file (@Languages: cmn/zho) | Stanza Chinese tokenizer | ~200 MB | 30-60 s |
transcribe (Whisper engine) | Whisper ASR model from HuggingFace | 0.5-3 GB depending on model size | 1-10 min |
align (Whisper engine) | Whisper FA model from HuggingFace | ~3 GB | 3-10 min |
align (Wave2Vec engine, default) | Wave2Vec MMS_FA bundle from torchaudio | ~1.2 GB | 1-5 min |
align --lang yue (Cantonese FA) | Wave2Vec Cantonese model | ~1 GB | 1-5 min |
transcribe --diarization enabled --speaker-engine pyannote or standalone diarize (local default) | Pyannote talkbank/dia-fork and its pinned dependencies (public, ungated) PLUS an unpinned PLDA artifact currently gated behind accepted terms + a Hugging Face token | ~500 MB | 1-3 min once authorized |
transcribe --diarization enabled (default speaker backend) or diarize --speaker-engine pyannote-ai | No local speaker model; uses paid pyannoteAI Precision-2 | N/A | Provider latency only |
translate (Seamless engine) | SeamlessM4T from HuggingFace | ~2.4 GB | 2-8 min |
transcribe (utterance segmentation, certain languages) | BERT utterance model from HuggingFace | ~400 MB | 1-3 min |
These sizes are ballpark. Real numbers depend on the upstream artifact and your network speed.
Which revision you get
The models BA3 loads by default are PINNED: each resolves to an exact revision
rather than to whatever the upstream repository happens to hold today. Hugging
Face models are pinned to a commit (openai/whisper-large-v3 at
06f233fe06e710322aca913c1bc4249a0d71fce1, for instance), and the ModelScope
models the Paraformer engine loads, the checkpoint together with its
voice-activity and punctuation models, are pinned to the tag v2.0.4, because
ModelScope publishes no commit behind a tag. Cloud engines have no weights to
pin; their request names the provider’s own model instead.
That covers the utterance-segmentation boundary models too, so a transcribe
or utseg run in English, Mandarin or Cantonese loads a known revision
(talkbank/CHATUtterance-en at 764ec3f762c2e24df2def8df98b5fe34940085c6, for
instance). Because the revision is now known before the model loads rather than
read back afterwards, every file those runs write records it: the engine=
field of the [fc-ba3 utseg ...] comment is always <model id>@<revision>.
What that buys you: the same command on the same media loads the same weights next month as it does today, and a repository that moves upstream cannot silently change your output.
An override still loads. Passing your own model (for example
--engine-overrides '{"funaudio_model":"..."}') leaves that model unpinned
rather than refusing the job, and BA3 records it at the revision actually
observed when it loaded, so the run still says which weights produced it.
What an override cannot do is name its revision BEFORE the model loads, which
is why a run with an unpinned model does not use the UTR ASR result cache
(see below): no stored row could promise it came from the same weights.
What you’ll see on first run
Every download surfaces in your console, the TUI, the desktop app, and the
web dashboard at http://<host>:8001/dashboard/jobs/<id>: whichever UI
you’re using. This is a deliberate UX commitment (see the time
transparency principle): you should
always know what batchalign3 is doing and roughly how long it will take.
Example: a brand-new install running batchalign3 morphotag input/ output/
will show a sequence like:
Downloading Stanza resource catalog (one-time, ~1 MB; future runs will be instant)…
Stanza resource catalog ready.
Downloading Stanza language pack for eng (en) (one-time, ~250-500 MB; future runs will use the local cache)…
Loading Stanza English…
Processing input/file1.cha
Processing input/file2.cha
…
A fresh transcribe run:
Downloading openai/whisper-large-v3 for ASR (one-time, ~3 GB; future runs will use the local cache)…
Loading Whisper-large-v3 onto GPU…
Calling Rev.AI for utterance-timing recovery…
Processing recordings/r001.wav
…
If you see “Downloading X…” and the run sits there for a while, that is expected, the download is running in the background. The libraries also print their own progress bars to your terminal stderr.
After the first successful run, the same command on the same language runs without any download, typically in seconds (for text NLP) to a few minutes (for audio passes, dominated by inference, not loading).
Where models are stored
batchalign3 uses each library’s own cache. Locations vary by OS because each library follows its own platform conventions:
| Library | macOS | Linux | Windows |
|---|---|---|---|
| Stanza (1.11+) | ~/Library/Caches/stanza/<resver>/resources/ | ~/.cache/stanza/<resver>/resources/ | %LocalAppData%\stanza\<resver>\resources\ |
| HuggingFace (Whisper, Wave2Vec, SeamlessM4T, pyannote, BERT) | ~/.cache/huggingface/hub/ | ~/.cache/huggingface/hub/ | %LocalAppData%\huggingface\hub\ |
| torchaudio (Wave2Vec MMS_FA bundle) | ~/.cache/torch/hub/torchaudio/ | ~/.cache/torch/hub/torchaudio/ | %LocalAppData%\torch\hub\torchaudio\ |
| PyCantonese | Bundled in the package, no separate cache | (same) | (same) |
<resver> in the Stanza path is the resource-catalog version (e.g.
1.11.0), which Stanza bumps independently of the package version.
Combined cache size for a multi-language workflow can reach 10-30 GB.
Customizing cache locations
Each library honors a standard environment variable for cache override. Useful when you want to put caches on an external drive, a shared mount, or a smaller SSD:
| Library | Environment variable |
|---|---|
| Stanza | STANZA_RESOURCES_DIR |
| HuggingFace | HF_HOME (controls all subpaths) or HF_HUB_CACHE (just the model hub) |
| torchaudio | TORCH_HOME (controls all torch hub caches) |
Example: offload everything to an external drive:
export STANZA_RESOURCES_DIR="/Volumes/External/caches/stanza"
export HF_HOME="/Volumes/External/caches/huggingface"
export TORCH_HOME="/Volumes/External/caches/torch"
batchalign3 daemon start
If you set these before the first model download, batchalign3 downloads straight to the new location. If you set them after you’ve already downloaded somewhere else, models will re-download, copy the existing cache directories first to avoid that.
Working offline
After the first successful download, local-model commands work offline for the same languages and engines. Cloud engines such as Rev.AI and pyannoteAI still require their services. To enforce strictly offline Hugging Face behavior (and surface a clear error if a local model is missing rather than attempting a download), set:
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
Stanza always tries the local cache first; no equivalent flag is needed.
In strict offline mode, the user-facing error for a missing model is along the lines of “model X not in local cache; offline mode is enabled”, actionable, distinct from the download-failed errors above.
Pre-seeding for offline / air-gapped deployments
For deployments where the worker will run without internet (CI runners, air-gapped fleets, conference demos), do the first download on a machine with internet, then copy the cache directories to the offline target.
Stanza catalog + English pack:
python -c "import stanza; stanza.download('en')"
HuggingFace models (download to a known local path you can rsync):
python -c "
from huggingface_hub import snapshot_download
for repo in [
'openai/whisper-large-v3',
'talkbank/dia-fork',
'facebook/hf-seamless-m4t-medium',
]:
snapshot_download(repo)
"
Then copy the cache directories listed above to the offline machine, set
HF_HUB_OFFLINE=1 / TRANSFORMERS_OFFLINE=1, and run.
Disk-space management
To free disk space by removing cached models (they will re-download next use):
# macOS
rm -rf ~/Library/Caches/stanza/
rm -rf ~/.cache/huggingface/hub/
rm -rf ~/.cache/torch/hub/
# Linux
rm -rf ~/.cache/stanza/
rm -rf ~/.cache/huggingface/hub/
rm -rf ~/.cache/torch/hub/
Removing caches mid-job is safe: any in-flight download will continue, and future jobs will re-download what they need. Removing caches while a daemon is running will not crash the daemon, the next job that needs the missing model will simply re-download it (and surface a download notification to you, as documented above).
Result caching (separate from model caching)
batchalign3 caches audio-bound intermediate evidence so repeated runs of
align, selected transcribe stages, or standalone diarize on the same
media with the same settings do not redo expensive service calls or model
inference. The cache distinguishes final
forced-alignment projection, raw forced-alignment worker evidence, UTR ASR,
raw Rev transcript evidence, raw speaker evidence, and normalized speaker
segments.
The authoritative list of cached task kinds is in
crates/batchalign/src/chat_ops/cache_key.rs::CacheTaskName. Every kind is
scoped to an identity of its own, alongside language, evidence/projection
revision, and relevant inputs, so changing any of those produces a fresh
entry. Which identity depends on the kind:
- Forced alignment is scoped to the engine version reported by the exact selected worker, because its rows are namespaced before inference runs.
- UTR ASR is scoped to the pinned plan instead: the engine together with the exact models it was pinned to, which is known before dispatch without loading anything. A run whose ASR model is unpinned has no such identity, so it infers without reading or writing UTR ASR rows rather than pooling them with another plan’s.
In server mode, worker models remain loaded between jobs as well; result caching and warm workers remove different costs.
Text NLP tasks are not cached. Running morphotag, utseg,
translate, or coref twice on the same file runs the model twice. The
CLI accepts --override-media-cache-tasks morphosyntax for backward-
compatible scripting but emits a warning (“batchalign3 does not cache text NLP”) and ignores it.
To force re-computation of cached audio tasks (e.g., after a model update):
batchalign3 align --override-media-cache corpus/ -o output/ --lang eng
batchalign3 transcribe --override-media-cache recordings/ -o transcripts/ --lang eng
--override-media-cache clears all audio-task caches for the run. For
finer control, pass --override-media-cache-tasks with one or more of
forced_alignment, utr_asr, rev_asr_evidence, or
speaker_diarization_raw_evidence. The last two are useful for controlled
transcription experiments: refresh Rev without buying another speaker job, or
refresh speaker evidence while replaying the same Rev response.
The result cache lives at:
| OS | Path |
|---|---|
| macOS | ~/Library/Caches/batchalign3/cache.db |
| Linux | ~/.cache/batchalign3/cache.db |
| Windows | %LocalAppData%\batchalign3\cache.db |
You can rm it at any time; new runs will start a fresh cache.
When something goes wrong
Three classes of error you might legitimately see, and what to do:
Network unreachable. “Failed to download Stanza catalog: network
unreachable” or “Failed to download model X: connection timeout”. Check
your internet connection or proxy; retry. If you’re on a corporate
network with a firewall, you may need to allow https://huggingface.co
and https://raw.githubusercontent.com.
Disk full. “Failed to write model file: no space left on device”. Free up space (see “Disk-space management” above) or move caches to a larger drive (see “Customizing cache locations”). Then retry.
Authentication. “Failed to download X: HTTP 401/403”. The model
requires a HuggingFace auth token. The standard batchalign3 install does
not need any, if you’ve configured a custom model that requires auth,
set HF_TOKEN in the daemon environment.
What you should never see. Errors mentioning “capability table”, “resources.json”, “model not installed”, or any internal-implementation language. If you see one of these, batchalign3 is failing to download something it should have downloaded automatically. File a bug on GitHub and include the error message verbatim plus the full command you ran. The team will fix the on-demand path; you will not be asked to seed models manually.
Related references
- Time transparency UX principle, why downloads (and other slow operations) always surface to the UI.
- Developer-facing model downloads doc, internals: load paths, cache invalidation, test strategy.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Batchalign Desktop (Experimental)
Status: Current Last updated: 2026-08-30 21:00 EDT
Batchalign Desktop is the native Batchalign GUI shell in
apps/dashboard-desktop/. It is experimental and is not currently a
supported public release surface.
For supported end-user workflows today, install and run batchalign3 via
the canonical CLI path in Installation. Treat this chapter as
orientation for the in-repo desktop shell, not as the primary installation path
for first-time users.
Current status
- Release contract: Experimental (see the Release Contract)
- Distribution: no supported public desktop bundle line today
- Desktop shell location:
apps/dashboard-desktop/ - Supported product surface today:
batchalign3CLI, local server, and dashboard UI
Evaluating from source
Getting started
-
Install Batchalign: follow the Installation guide. The desktop app needs
batchalign3on your PATH. -
Launch the shell from source: run
npm run devfromapps/dashboard-desktop/. -
First-time setup: on first launch, a setup wizard asks you to choose your default speech-to-text engine:
- Rev.AI: fast cloud service, requires a paid API key from rev.ai/auth/signup
- Whisper: free local model, slower, downloads ~2 GB on first use
This creates
~/.batchalign.ini, the same config file thatbatchalign3 setupwrites from the terminal.
What the shell is for
When run from source, the Batchalign desktop shell is meant to expose the same
high-level processing flows as the batchalign3 CLI and web dashboard:
- pick a processing command
- choose files or folders
- launch work against a local Batchalign server
- monitor progress in a native window
The home screen
After setup, you see the home screen with two zones:
Command cards: six tasks you can perform:
| Card | Command | What it does |
|---|---|---|
| Transcribe Audio | transcribe | Turn audio or video recordings into written transcripts in CHAT format |
| Add Grammar | morphotag | Add part-of-speech tags and grammatical structure, needed for CLAN commands like MLU and DSS |
| Align to Audio | align | Link each word in a transcript to its exact moment in the audio, so you can click to play |
| Translate | translate | Add an English translation line under each utterance in a non-English transcript |
| Segment Utterances | utseg | Automatically break a long block of text into separate speaker turns |
| Score Accuracy | benchmark | Measure how closely a machine transcript matches a human-verified one |
Recent tasks: a compact list of your most recent processing jobs, with status and file count.
Processing a folder
-
Pick a command: click one of the six cards.
-
Choose input files: click the dashed area to open a native folder picker. The app scans the folder for relevant files (
.chafor most commands, audio files for Transcribe). You’ll see the file count and folder path. -
Choose output location: by default, output goes to a separate folder (click to choose one). You can switch to “Modify in place” if you want to overwrite the originals, make backups first.
-
Select language: shown for commands that need it (Transcribe, Align, Segment Utterances, Score Accuracy). Defaults to English.
-
Start processing: click the full-width Start button. The app submits the job to the local server and switches to the progress view.
Watching progress
The progress screen shows:
- Summary bar: command name, file count (“12 of 45 files”)
- Progress bar: animated blue stripe while running, turns green on completion or red on failure
- File list: live-updating via server-sent events. Currently processing files appear at the top with a pulsing blue dot, followed by queued, completed, and errored files. Each row shows the filename, current stage (e.g., “Aligning”, “Transcribing”), and duration.
- Cancel: click Cancel in the top-right to stop the job. Files that already finished processing are kept.
When processing finishes:
- Success: the bar turns green. Click Open Output Folder to view your results in Finder/Explorer. Click Process More Files to start another job.
- Errors: an error panel appears below the progress bar, grouping failed files by error type with plain-language explanations and suggested fixes. Common causes include invalid CHAT format, missing audio files, or low memory.
Server status
The app automatically starts a local batchalign3 server when it launches (on port 18000) and stops it when you quit. A status bar at the top of the screen shows the connection state:
| Indicator | Meaning |
|---|---|
| Green dot, “Server running” | Ready to accept jobs |
| Yellow pulsing dot, “Server starting…” | Server is booting (usually 1-3 seconds) |
| Red dot, “Server stopped” | Server crashed or was stopped, click Start Server to restart |
| Red dot, “batchalign3 not found” | The batchalign3 binary isn’t installed, follow the install instructions shown |
You can manually stop and restart the server from the status bar.
Help
Click the ? button in the top-right corner of the header to open a help panel with descriptions of all six commands and answers to common questions.
Dashboard (power users)
Click Dashboard in the header to switch to the fleet monitoring view.
This shows all jobs across servers with detailed file-level status, error
grouping, and algorithm trace visualizations. It’s the same dashboard
available in a web browser at http://localhost:18000/dashboard.
Settings and configuration
Click the gear icon in the top-right corner of the header to open Settings. From there you can:
- Switch your default ASR engine between Rev.AI and Whisper
- Add or update your Rev.AI API key
Changes are saved to ~/.batchalign.ini, the same config file that the CLI
uses. You can also edit this file directly if you prefer:
[asr]
engine = rev
engine.rev.key = YOUR_KEY_HERE
Valid engine values: rev (Rev.AI cloud) or whisper (local).
Keyboard shortcuts
The app does not currently define custom keyboard shortcuts. Standard platform shortcuts (Cmd+Q / Alt+F4 to quit, Cmd+W to close window) work as expected.
Troubleshooting
“batchalign3 not found”: the app can’t find the CLI binary. Make sure you’ve installed Batchalign (see the Installation guide) and that your terminal’s PATH is available to GUI apps. On macOS, you may need to restart after installing.
Server won’t start: check that nothing else is using port 18000.
Try running batchalign3 serve start --port 18000 in a terminal to see
the error output.
Files not showing up: the folder picker filters by file extension.
For most commands, only .cha files are shown. For Transcribe, only audio
files (.wav, .mp3, .mp4, .m4a, .flac) are shown.
Processing is slow: the first run downloads ML models (~2 GB) and may take several minutes. Subsequent runs are much faster because models stay cached and the server keeps them in memory. See Performance for tuning tips.
For other issues, see Troubleshooting.
This page last changed: 2026-08-30 (commit 0964e762). The whole book last changed: 2026-09-16 (commit 34d249d8).
Web Dashboard
Status: Current Last updated: 2026-07-30 18:21 EDT
The batchalign3 web dashboard is a real-time monitoring interface for watching
job progress, inspecting worker state, and tracking system resource usage.
It opens automatically in your browser when you submit a job (unless you pass
--no-open-dashboard).
Accessing the Dashboard
When the batchalign3 server is running, the dashboard is available at:
http://localhost:8000/dashboard
Replace localhost:8000 with the server’s address if running remotely (e.g.,
http://your-server:8001/dashboard).
The CLI opens the dashboard automatically when you submit a job:
batchalign3 transcribe corpus/ -o output/ --lang eng
# ↑ browser opens to /dashboard/jobs/<job-id>
To suppress the browser auto-open:
batchalign3 --no-open-dashboard transcribe corpus/ -o output/ --lang eng
Dashboard Layout
The main dashboard page (/dashboard) uses a two-column layout:
┌──────────────────────────────────────────────────────────────┐
│ batchalign Dashboard Visualizations 2 active ● │
├─────────────────────────────────────┬────────────────────────┤
│ │ │
│ Job List │ Workers Panel │
│ ┌─────────────────────────────┐ │ GPU: 1 process │
│ │ TRANSCRIBE ● running │ │ Stanza: 2 processes │
│ │ …/corpus/ 3/50 files (6%) │ │ IO: idle │
│ │ ▓▓▓░░░░░░░░░░░░░░░░░░░░░░ │ │ │
│ └─────────────────────────────┘ ├────────────────────────┤
│ ┌─────────────────────────────┐ │ │
│ │ MORPHOTAG ● completed │ │ Memory Panel │
│ │ 74/74 files 2m 15s │ │ ▓▓▓▓▓▓▓▓▓▓░░░░░ │ │
│ └─────────────────────────────┘ │ 148 GB used │
│ ┌─────────────────────────────┐ │ 108 GB available │
│ │ ALIGN ✗ failed │ │ Gate: 2 GB ● safe │
│ │ 12/15 files 3 failed │ │ │
│ └─────────────────────────────┘ ├────────────────────────┤
│ │ │
│ │ Vitals │
│ │ 42 attempts 1 retry │
│ │ │
└─────────────────────────────────────┴────────────────────────┘
On mobile or narrow screens, the right column stacks below the job list.
Job List
Each job card shows:
- Command badge: color-coded by command type (green for transcribe, indigo for align, violet for morphotag, etc.)
- Status: queued (amber), running (blue, pulsing), completed (green), failed (red), cancelled (gray)
- Source directory: abbreviated path to the input files
- File progress:
completed/total files (percent) - Workers: how many concurrent file workers are assigned
- Duration or age: elapsed time (running) or “3m ago” (completed)
- Error count: if any files failed, shown in red
Click a job card to open the job detail page.
Job Detail Page
The detail page at /dashboard/jobs/<id> shows:
Header
- Command badge, status dot, job ID
- Action buttons: cancel (if running), restart, delete
Metadata Grid
- Files:
completed / total (percent) - Submitted: relative time (“3m ago”)
- Duration: elapsed wall clock
- Workers: concurrent file count
Progress Bar
For active jobs, an animated progress bar with striped fill. Shows an indeterminate shimmer when the job is queued but no files have started.
Command Args
The original submission options as formatted JSON, useful for debugging which engine, language, and flags produced this output.
File Table
Every file in the job, grouped by directory. Each row shows:
- Filename: just the basename (directory shown as a collapsible group header)
- Status: dot + label + optional error category badge
- Pipeline phase indicator: for processing files, a 5-segment bar showing which pipeline phase is active (Read → Transcribe → Align → Analyze → Finalize)
- Sub-file progress: when available, a mini progress bar with counter (e.g., “Aligning 3/7”)
- Stage label: the current processing stage in italic text
- Duration: how long this file took (done files)
- Error detail: click to expand full error text (error files)
Error Panel
If files have failed, errors are grouped by error code with counts. Each group shows the error category (Parse, Media, System, Engine, Pipeline Bug) and affected filenames.
Filter Tabs
Filter the file table by status: All, Processing, Done, Error, Queued. A search box lets you filter by filename.
Workers Panel
Shows the three worker profiles and their current state:
GPU Profile (amber)
- Shared ASR + Forced Alignment + Speaker models in one process
- When active: “1 process” with language tags (e.g.,
eng shared) - Key callout: “Models shared, align + transcribe reuse one process”
- Commands served: align, transcribe, transcribe_s, benchmark
Stanza Profile (indigo)
- NLP processors (POS tagging, dependency parse, coreference)
- Multiple processes for CPU parallelism (e.g., “2 processes”)
- Shows idle/total per language:
eng 1/2 idle - Commands served: morphotag, utseg, coref, compare
IO Profile (emerald)
- Lightweight API/library calls (translation, audio analysis)
- Usually 1 process per language
- Commands served: translate, opensmile, avqi
Memory Panel
Real-time system RAM usage:
- Gauge bar: colored segment showing used vs available memory. Changes
color based on proximity to the memory gate threshold:
- Green: plenty of headroom (available > 4× threshold)
- Amber: getting close (available between 2× and 4× threshold)
- Red: danger zone (available < 2× threshold)
- Numbers: “148 GB used” / “108 GB available”
- Gate threshold: shown as a vertical marker on the gauge and a status badge (e.g., “Gate: 2 GB threshold”)
- Gate rejections: if any jobs have been rejected due to memory pressure, shown as a red count badge
The memory gate prevents new jobs from starting when available RAM drops below the configured threshold (default: 2 GB). This protects against OOM crashes when running large ML models.
Vitals Panel
Compact operational counters since server start:
| Counter | Color | Meaning |
|---|---|---|
| crashes | Red | Worker processes that crashed unexpectedly |
| forced kills | Red | Files force-terminated (OOM, stuck) |
| gate rejects | Amber | Jobs rejected by the memory gate |
| attempts | Gray | Total file processing attempts started |
| retries | Amber | Attempts that were retried after transient failures |
| deferred | Gray | Work units deferred for later execution |
Only nonzero counters are shown. If everything is healthy, the vitals panel shows only the attempt count.
Pipeline Stages
When a file is actively processing, the dashboard shows which pipeline phase it’s in using a 5-segment indicator:
| Phase | What’s happening | Typical duration |
|---|---|---|
| Read | Loading CHAT, resolving audio, checking cache | Seconds |
| Transcribe | ASR inference, timing recovery | Minutes (proportional to audio length) |
| Align | Forced alignment on utterance groups | Minutes |
| Analyze | Morphosyntax, segmentation, translation, coreference | Seconds to minutes |
| Finalize | Post-processing, building CHAT, writing output | Seconds |
Not all files go through all phases, an align job skips Transcribe and
Analyze; a morphotag job skips Transcribe and Align.
Connection Status
The header shows a connection indicator:
- Green dot + “Connected”: live WebSocket connection to the server. Updates stream in real time.
- Red dot + “Reconnecting…”: connection lost. The dashboard will automatically reconnect with exponential backoff.
When connected, job and file status updates arrive via WebSocket push, no manual refresh needed. The dashboard also polls the health endpoint every few seconds to keep the memory and worker panels current.
Algorithm Visualizations
The dashboard includes interactive algorithm visualizations at
/dashboard/visualizations:
- DP Alignment Explorer: step through the dynamic programming cost matrix used for word alignment, with fill and traceback animation
- Retokenization Mapper: see how Stanza word splits/merges are resolved back to CHAT words
- ASR Pipeline Waterfall: (planned) stage-by-stage ASR post-processing
- FA Timeline: (planned) DAW-style forced alignment timing visualization
Each visualization has a static mode (editable sample data, no server needed)
and a live mode (actual trace data from a completed job with debug_traces
enabled).
Keyboard Shortcuts
The dashboard is mouse-driven. The job detail page supports:
- Tab: cycle through filter tabs
- Arrow keys: navigate file list pagination
Tips
- Frozen progress during batch commands (morphotag, utseg, translate, coref): this is normal. The model processes all files at once, so individual files don’t advance until the batch completes. The elapsed timer keeps ticking.
- Multiple concurrent jobs: the dashboard shows all jobs. Use the status filter tabs to focus on active or failed jobs.
- Error investigation: click an error row to expand the full error message. Error codes link to specific failure categories that help diagnose whether the issue is in your input, media files, or the processing engine.
- Large corpora: for jobs with hundreds of files, use the search box in the file table to find specific files by name.
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
Progress and Feedback
Status: Current Last updated: 2026-07-30 18:21 EDT
Batchalign reports real-time progress during processing. This page explains what to expect for each command, what the progress indicators mean, and when to worry versus when to wait.
How Progress Works
Every processing job tracks progress at the file level. In server mode, the server reports stage transitions and optional sub-file counters (e.g., “Aligning 3/7 groups”) to all connected clients, the CLI, TUI, and React dashboard all consume the same stream. In direct local mode, the CLI now projects the same file-status snapshots from the in-memory direct host, so local runs still show live terminal progress without requiring a dashboard.
For direct local runs, the CLI also prints a stable debug handle at startup:
- the direct job ID
- the local artifact directory for that job
On failures, the CLI prints any persisted bug-report IDs and direct debug artifact paths so you can inspect the failed run later without keeping the process alive.
There are two progress tiers:
- Stage labels: every file shows a stage name (“Reading”, “Aligning”, “Writing”) that changes as processing advances.
- Sub-file counters: some stages include a current/total counter for fine-grained progress within a single file.
Per-Command Expectations
align (forced alignment)
Align processes files individually and concurrently. Each file goes through:
- Reading: loading the CHAT file from disk
- Resolving audio: finding and preparing the media file
- Recovering utterance timing (if needed), re-transcribing to recover word timing for untimed utterances. Shows sub-progress for partial-window UTR (e.g., “2/5” windows). This step takes roughly as long as the recording itself.
- Aligning: forced alignment on utterance groups. Shows sub-progress (e.g., “3/7” groups).
- Writing: saving the aligned output
Timing: Most of the time is spent in steps 3-4. A 10-minute recording typically takes 5-15 minutes depending on the engine and number of utterances.
transcribe
Transcribe processes files individually. Each file goes through:
- Resolving audio → Transcribing → Post-processing → Building CHAT → optional Segmenting / Morphosyntax → Finalizing → Writing
Shows a pipeline stage counter (e.g., “2/5”) as each stage completes.
Timing: Rev.AI runs roughly in real-time. Whisper may take 2-5x the audio length.
morphotag, utseg, translate, coref (batched commands)
These commands batch all files together into a single inference call for GPU efficiency. Progress stages:
- Reading: files are loaded one at a time; each transitions from the initial stage to “Reading” during I/O.
- Analyzing/Segmenting/Translating (0/N), the batch total is published before inference starts. During inference, the progress bar shows the batch size but individual files don’t advance.
- Writing (1/N, 2/N, …), as each file’s result is written to disk, the counter ticks up.
For large in-place reruns driven by --file-list, the batched text commands
may still stage the rewritten CHAT files until the current invocation
finishes. In other words: you can see healthy progress without seeing the
input .cha files mutate on disk yet. If you want visible on-disk updates
during a long repair pass, split the rerun into smaller invocations.
What “frozen” means: During step 2, the progress bar won’t advance because all files are processed as a single batch. This is normal, the model is working on your entire corpus at once. The elapsed timer keeps ticking to confirm the app is alive.
Timing: Depends on corpus size. 50 files typically takes 1-5 minutes for morphotag, faster for translate and utseg.
When to Worry vs. When to Wait
Normal: Progress frozen during batch processing, or during UTR/transcription (these are genuinely long-running). The elapsed timer should always be ticking.
Investigate if:
- The elapsed timer stops advancing (app may have frozen, try refreshing)
- A file stays in “Reading” for more than 30 seconds (possible I/O issue)
- “Resolving audio” persists for minutes (media file may be missing)
How to Cancel
- Desktop app: Click the red “Cancel” button in the progress view
- CLI: Press
Ctrl+C(graceful shutdown) - API:
POST /jobs/{id}/cancel
Cancellation is cooperative, the current file finishes its in-progress work before the job stops.
Pipeline Phase Indicator
For processing files, the dashboard and desktop app show a compact 5-segment phase bar that maps the 23 internal progress stages into visual phases:
| Segment | Pipeline Phase | Stages Included |
|---|---|---|
| 1 | Read | Reading, Resolving audio, Checking cache, Parsing |
| 2 | Transcribe | Transcribing, Recovering utterance timing, Recovering timing (fallback) |
| 3 | Align | Aligning, Applying results |
| 4 | Analyze | Morphosyntax, Segmentation, Translation, Coreference, Comparing, Benchmarking |
| 5 | Finalize | Post-processing, Building CHAT, Finalizing, Writing |
The 23 FileProgressStage variants map to 5 visual phases via phase_index() in crates/batchalign/src/cli/tui/ui.rs; two variants (Processing generic fallback and RetryScheduled) deliberately do not map to a phase.
The active phase pulses; completed phases are filled; future phases are gray.
Not every command uses all phases, morphotag skips Transcribe and Align;
align skips Transcribe and Analyze.
Progress Displays
| Client | What you see |
|---|---|
| Web dashboard | Two-column layout: job list with pipeline phase bars, system panels (workers, memory, vitals). See Web Dashboard for details. |
| Desktop app | Processing progress view with SSE-driven file list and stage labels |
| CLI | indicatif progress bar with file count, elapsed time, and per-file terminal logs for both server-backed and direct local runs |
| TUI | Per-file spinners with pipeline phase dots, elapsed timers, status breakdown, ETA, worker status, memory gauge, scroll indicators (server-backed jobs only) |
TUI Details (--tui)
The TUI shows the same information as the web dashboard in a terminal-friendly format:
- Header with status breakdown (
3✓ 2⠋ 1✗ 44·), elapsed time, and ETA. Shows “Done!” or “Done, N failed” on completion. - Pipeline phase dots next to each processing file:
●●●○○maps the 5 phases (Read/Transcribe/Align/Analyze/Finalize) using the same grouping as the web dashboard. Green = completed, cyan = active, gray = future. - Per-file elapsed timer (
M:SS) on processing files, computed fromstarted_at. Helps spot stuck files during long align/transcribe jobs. - Worker status line between the header and file list: shows active worker keys.
- Memory gauge below the worker line: 20-char bar with used/total GB and gate proximity indicator (safe/warn/danger). Warns explicitly when memory is near or below the gate threshold.
- Scroll indicators (
▲ N more above/▼ N more below) when groups have more files than fit on screen. - Auto-collapse for completed groups: non-focused groups where all files are done or errored show a condensed title.
- Error codes in the error panel, extracted from the server’s structured
error codes (e.g.
[E362] Bullet timestamps must be monotonic).
Keybinds: q quit · c cancel · ↑↓ scroll · tab group · e errors · m metrics
Press m to toggle the worker/memory rows on small terminals.
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
Quick Start
Status: Current Last updated: 2026-09-07 07:04 EDT
This chapter covers the most common batchalign3 workflows from the terminal.
The examples assume the batchalign3 binary is installed and that local
processing commands can reach a Python runtime with batchalign.worker
available.
Evaluating the experimental GUI shell? See
Batchalign Desktop (Experimental). The supported first-time
user path today is still the batchalign3 CLI.
For the full command surface, see the CLI Reference.
Before you start
Model downloads: The first time you run a processing command, Batchalign downloads ML models (~2 GB). This is a one-time cost, subsequent runs use cached models from disk.
Caching: Batchalign caches audio-bound intermediate results
(forced-alignment word timings and the UTR ASR pass) in a local SQLite
database, so re-running align or transcribe on the same audio
returns those steps from cache. Text-NLP commands (morphotag,
utseg, translate, coref) are not cached and always recompute.
See Caching for details.
Performance: Back-to-back runs are still much faster than first-run model
downloads because models and caches stay on disk. If you need hot in-memory
workers across repeated runs, start an explicit server with batchalign3 serve.
See Performance for tuning tips.
Basic command shape
batchalign3 [GLOBAL OPTIONS] COMMAND [COMMAND OPTIONS] [PATHS...]
- Global options go before the command.
- Most processing commands use
-o/--outputfor a destination directory. - Omitting
-o/--outputmeans in-place processing when the command supports it.
Transcribe audio to CHAT
batchalign3 transcribe ~/recordings/ -o ~/transcripts/ --lang eng
To use a local Whisper model:
batchalign3 transcribe ~/recordings/ -o ~/transcripts/ \
--asr-engine whisper --lang eng
Important routing note: explicit --server now submits shared-filesystem
paths_mode jobs for transcribe. The target server must be able to read the
same input paths and write the requested output paths.
Align transcripts against audio
batchalign3 align ~/corpus/ -o ~/aligned/
Common useful flags:
batchalign3 align ~/corpus/ -o ~/aligned/ --wor
batchalign3 align ~/corpus/ -o ~/aligned/ --fa-engine whisper
batchalign3 align ~/corpus/ -o ~/aligned/ --utr-engine whisper
Add morphosyntactic analysis
batchalign3 morphotag ~/corpus/ -o ~/tagged/
Useful variants:
batchalign3 morphotag ~/corpus/ -o ~/tagged/ --retokenize
batchalign3 morphotag ~/corpus/ -o ~/tagged/ --skipmultilang
morphotag is not cached, so repeated runs run the full Stanza pipeline
again. The wall-clock win for repeated runs comes from keeping workers
warm in memory rather than from disk caching. For interactive sessions
where you want workers to stay loaded across commands, use explicit
server mode (batchalign3 serve start plus --server).
Verbosity
batchalign3 align ~/corpus/ -o ~/aligned/
batchalign3 -v align ~/corpus/ -o ~/aligned/
batchalign3 -vv align ~/corpus/ -o ~/aligned/
batchalign3 -vvv align ~/corpus/ -o ~/aligned/
Run logs
batchalign3 logs
batchalign3 logs --last
batchalign3 logs --export
batchalign3 logs --clear
Remote server mode
For commands that support explicit remote dispatch:
batchalign3 --server http://yourserver:8000 morphotag ~/corpus/ -o ~/tagged/
batchalign3 --server http://yourserver:8000 align ~/corpus/ -o ~/aligned/
transcribe, transcribe_s, benchmark, and avqi always prefer
the local daemon and ignore --server (see command_prefers_local_daemon
in crates/batchalign/src/cli/dispatch/mod.rs). The remaining text and
analysis commands (morphotag, align, compare, etc.) honor explicit
--server routing.
Next steps
- Batchalign Desktop (Experimental), in-repo GUI shell status and scope
- CLI Reference
- Performance
- Server Mode
- Rev.AI Integration
- Python API
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
CLI Reference
Status: Current Last updated: 2026-09-16 09:47 EDT
This page documents the current public batchalign3 CLI surface. For anything
you are scripting against, confirm with batchalign3 <command> --help.
For detailed input/output patterns and mutation behavior per command, see Command I/O Parity.
Command shape
batchalign3 [GLOBAL OPTIONS] COMMAND [COMMAND OPTIONS] [PATHS...]
Global options go before the command name.
Global options
| Option | Meaning |
|---|---|
-v, -vv, -vvv | Increase verbosity |
--workers N | Maximum concurrent files per job (default: auto-tune; GPU commands default to 1). Auto-tune is (ram_total_mb / 16 GB).clamp(1, 8) for GPU-bound work. |
--force-cpu | Disable MPS/CUDA and force CPU-only models |
--server URL | Remote server URL. Env fallback: BATCHALIGN_SERVER |
--override-media-cache | Bypass the media analysis cache (audio tasks only; text NLP tasks are not cached at all) |
--require-media-cache | Require reusable evidence at cache-backed media stages; a miss fails instead of authorizing inference. Conflicts with both cache-override forms. |
--override-media-cache-tasks TASKS | Bypass only named audio-evidence caches (comma-separated: forced_alignment, utr_asr, rev_asr_evidence, speaker_diarization_raw_evidence) |
--debug-dir PATH | Directory for pipeline debug artifacts (CHAT/JSON fixtures for offline replay). Env fallback: BATCHALIGN_DEBUG_DIR |
--memory-tier {small,medium,large,fleet} | Override the auto-detected memory tier (forces worker bootstrap and memory budgets for that tier regardless of actual system RAM) |
--timeout SECONDS | Operator override for the audio-task transport timeout. For ASR, the default is DERIVED per request from the audio’s own duration (DecodeBudgetSeconds, crates/batchalign-types/src/worker_v2/requests.rs) plus a fixed margin, not a flat number; --timeout can only RAISE that derived ceiling, never lower it below what the request’s own decode budget needs. Forced alignment and speaker diarization still use a flat default (1800 = 30 min) unless overridden. |
--tui / --no-tui | Toggle full-screen TUI for server-backed jobs (DirectHost local runs stay on terminal progress bars) |
--open-dashboard / --no-open-dashboard | Toggle browser auto-open for submitted server job pages (macOS only, interactive TTY only) |
--engine-overrides JSON | Per-engine PARAMETERS, as a {string:string} JSON object, e.g. {"qwen_model":"Qwen/Qwen3-ASR-0.6B-hf","qwen_device":"cpu"}. Forwarded to the worker as opaque knobs. The asr / fa / utr / translate keys additionally select an engine and beat the per-command flags; see “Engine selection” below. The payload is parsed once, while the command line is parsed, so an invalid one is rejected before anything runs. A bare engine name (--engine-overrides whisper) is reported as the wrong-flag mistake it is, naming --asr-engine and its siblings, rather than as malformed JSON. |
--sequential | Process files one at a time with a single worker. No memory gate, no server. Ideal for small jobs on laptops |
--no-server | Skip auto-detection of a local server; force direct in-process execution |
BA2 compatibility flags (--memlog, --mem-guard, --adaptive-workers,
--pool, --shared-models, etc.) have been removed. If your scripts use them,
remove them.
Engine selection
These are per-command flags, not global ones: they go AFTER the command
name, like batchalign3 transcribe in/ --asr-engine paraformer.
| Option | Commands | Meaning |
|---|---|---|
--asr-engine NAME | transcribe, benchmark | ASR engine. |
--fa-engine NAME | align | Forced-alignment engine. |
--utr-engine NAME | align | Utterance-timing-recovery engine. Only consulted with --utr. |
--existing-wor-boundaries {preserve,rebuild-from-evidence} | align | v0.4.0 option for prior %wor/main boundaries. Default preserve; rebuild mode is experimental and does not change raw FA cache identity. |
--end-overlap-policy {clamp-all-adjacent,preserve-cross-speaker} | align | Same-speaker (default, preserve-cross-speaker) or every adjacent pair (clamp-all-adjacent) end overlap resolved from measured word hulls. Cross-speaker overlap is left alone under the default, since it is ordinary conversation. No raw FA cache-key change. |
--translate-engine NAME | translate | Translation engine. |
Each flag’s --help lists every value it accepts, derived from the engine
enum itself, so the advertised set and the accepted set are the same list. An
unrecognized name is rejected while parsing, naming what you typed and
suggesting the nearest valid value.
The older --asr-engine-custom / --fa-engine-custom / --utr-engine-custom
flags still work and are hidden from help. They exist because each visible flag
used to advertise only some of its engines, which left the rest reachable only
through a second, differently-named flag: that is how the Cantonese engines
stayed hidden from the people who needed them. Prefer the flags above.
The asr / fa / utr / translate keys of --engine-overrides also select
an engine, and take precedence over the flags. Use the flags for ordinary work;
the keys exist so one shared option can pin engines across a batch.
utr was missing from that set until 2026-08-06: it parsed, was forwarded to
the Python worker as an opaque extra, and was ignored by everything, so a user
who wrote it saw their choice silently dropped.
Sequential mode
--sequential gives you the simplest possible execution path, similar to
batchalign2’s direct mode. One worker per task type, files processed one at a
time, no concurrency infrastructure:
batchalign3 morphotag corpus/ -o output/ --sequential
What it does:
- Forces
--workers 1and--no-server - Disables the memory gate (no cross-process coordination)
- Keeps the worker alive for the entire run (no idle timeout kills)
- Preserves the utterance cache (repeated runs benefit from cached results)
When to use it:
- Processing a handful of files on a laptop
- Debugging pipeline issues (predictable, single-threaded execution)
- Environments where memory auto-tuning is unwanted
When NOT to use it:
- Large corpus runs (50+ files), the default parallel mode is 3-5× faster
- Fleet machines with warm workers, use the server instead
--sequential is incompatible with --server (mutually exclusive).
Dashboard browser auto-open
On macOS, when you run a processing command interactively (e.g.,
batchalign3 transcribe corpus/ output/), the CLI automatically opens the
job’s dashboard page in your default browser. This lets you monitor progress
in real time.
Direct local execution does not submit an HTTP job, so there is no dashboard
page to open. In that mode, --open-dashboard is a no-op and the CLI shows
local terminal progress inline instead.
The dashboard auto-open is only triggered when:
- Running on macOS (no-op on Linux/Windows)
- stderr is connected to an interactive terminal (TTY)
--no-open-dashboardwas not passed- The
BATCHALIGN_NO_BROWSERenvironment variable is not set
It will not fire in non-interactive contexts: cron jobs, CI pipelines,
SSH sessions without a display, piped output, or scripts. To suppress it
explicitly in interactive sessions, pass --no-open-dashboard.
Common path-processing options
The core processing commands documented below all accept:
| Option | Meaning |
|---|---|
PATHS... | Input files or directories |
-o, --output DIR | Output directory |
--file-list FILE | Read input paths from a text file (see below) |
--in-place | Modify inputs in place |
When exactly two positional paths are provided, the CLI still accepts the
legacy input/output directory form. For new scripts, prefer -o/--output.
--file-list format
--file-list FILE reads input paths from a plain-text UTF-8 file, one path
per line:
- Blank lines and lines beginning with
#are ignored; whitespace around each path is trimmed. - A relative path resolves against the directory containing the list file, not the directory you run the command from. Absolute paths are used as written.
- A path naming a directory is expanded exactly like a positional directory argument: matching files are discovered recursively beneath it.
- The input set is de-duplicated, keeping the first occurrence. A file
listed twice, spelled two ways (
a.cha,./a.cha, an absolute path), or reached both directly and through a listed directory is processed once. - Every entry must exist when the command runs. A missing entry is a usage
error (exit code 2) naming the list file and line, for example
lists/rerun.txt:3: input path does not exist: lists/corpus/missing.cha.
# lists/rerun.txt: entries are relative to lists/
corpus/session-01.cha
corpus/session-02.cha
# a whole directory, and an absolute path
corpus/follow-up/
/data/project/extra/session-09.cha
# Run align on every listed input (in place, against a remote server)
batchalign3 --server http://your-server:8001 align --file-list lists/rerun.txt
To process a large list in smaller batches, split it into chunk files in
the same directory as the original list (for example, run
split -l 10 rerun.txt batch- inside that directory) so relative entries
keep resolving against the same place, then run
batchalign3 align --file-list <chunk> on each chunk sequentially.
--file-list cannot be combined with positional PATHS arguments; the CLI
rejects the combination. Without -o/--output, every listed input is
processed in place (output overwrites input); with -o DIR, results are
written under DIR exactly as for positional inputs.
For batched text-NLP commands (morphotag, utseg, translate, coref),
large --file-list runs may not show file-by-file on-disk rewrites while the
invocation is still running. The command can batch/stage work internally and
then commit the in-place writes when the current invocation finishes. If you
need visible write-through during a long rerun, split the list into smaller
chunks and run those chunks sequentially.
Processing commands
Each processing command has a dedicated page with full options, a pipeline diagram, examples, and gotchas. Click the command name for complete documentation.
CHAT-mutation commands (input .cha → output .cha)
| Command | What it does |
|---|---|
| align | Add word-level and utterance-level timestamps via forced alignment |
| morphotag | Add %mor POS/lemma and %gra dependency tiers |
| utseg | Re-segment utterance boundaries using Stanza constituency parsing |
| translate | Add %xtra English translation tiers |
| coref | Add sparse %xcoref coreference annotation tiers (English only) |
| compare | Compare against gold .cha references; write %xsrep/%xsmor + .compare.csv |
Audio-input commands (input audio → new files)
| Command | What it does |
|---|---|
| transcribe | Create .cha transcripts from audio via ASR |
| benchmark | Transcribe and evaluate WER against gold .cha references |
| opensmile | Extract acoustic features → .opensmile.csv (positional I/O) |
| avqi | Calculate Acoustic Voice Quality Index from paired .cs/.sv audio (positional I/O) |
Operational commands
setup
Initialize ~/.batchalign.ini:
batchalign3 setup
batchalign3 setup --non-interactive --engine whisper
batchalign3 setup --non-interactive --engine rev --rev-key <KEY>
Options:
| Option | Meaning |
|---|---|
--engine {rev,whisper} | Persist default ASR engine |
--rev-key KEY | Rev.AI key for non-interactive setup |
--non-interactive | Disable prompts |
logs
batchalign3 logs
batchalign3 logs --last
batchalign3 logs --export
batchalign3 logs --clear
Key options:
| Option | Meaning |
|---|---|
--last | Show the most recent run log |
--raw | Raw JSONL output with --last |
--export | Zip recent logs |
--clear | Delete log files |
--follow | Tail the newest log file |
-n, --count N | Number of recent runs to list |
serve
batchalign3 serve start --foreground
batchalign3 serve status
batchalign3 serve stop
serve start key options:
| Option | Meaning |
|---|---|
--port PORT | Listen port |
--host HOST | Bind address |
--config PATH | Alternate server.yaml path |
--python PATH | Worker Python executable |
--foreground | Do not daemonize |
--test-echo | Start test-echo workers |
jobs
batchalign3 jobs --server http://myserver:8000
batchalign3 jobs --server http://myserver:8000 <JOB_ID>
batchalign3 jobs <JOB_ID>
batchalign3 jobs --json <JOB_ID>
batchalign3 jobs cancellations <JOB_ID>
With --server, lists or inspects remote jobs. Without --server,
inspects the local job artifact directory for post-failure debugging.
Pass --json for machine-readable output.
The cancellations subcommand prints the cancellation audit history
for a single job, every cancel attempt is recorded with source
(tui / api / dashboard / staging / signal), host, pid, reason,
and in_flight_filename. Use this when a user reports “I didn’t
cancel that job.”
cache
batchalign3 cache stats
batchalign3 cache clear --yes
batchalign3 cache clear --all --yes
BATCHALIGN_ANALYSIS_CACHE_DIR and BATCHALIGN_MEDIA_CACHE_DIR relocate
the underlying caches for isolated runs. BA2-compatible flag forms
cache --stats and cache --clear are still accepted.
openapi
batchalign3 openapi -o openapi.json
batchalign3 openapi --check --output openapi.json
--check exits non-zero when the target file does not match the generated
schema.
models
Two subcommands:
| Subcommand | Purpose |
|---|---|
models prep | Extract training text from CHAT files (Rust-native, no CLAN needed) |
models train | Forward to the Python training runtime (python -m batchalign.models.training.run) |
See Models Training Runtime ADR.
ipc-schema
batchalign3 ipc-schema -o schemas/
batchalign3 ipc-schema --check --output schemas/
Emits JSON Schema for Rust→Python IPC types. Without -o, schemas are
written to stdout as a single JSON object. With --check, exits non-zero
on schema drift against the target directory.
bench
batchalign3 bench <COMMAND> <IN_DIR> <OUT_DIR> [--runs N]
batchalign3 bench align corpus/ out/ --runs 3 --dataset eng-childes-v1
batchalign3 bench align corpus/ out/ --use-cache
Benchmark command execution time across repeated runs. <COMMAND> is
one of: align, transcribe, transcribe_s (with diarization),
morphotag, translate, utseg, benchmark, opensmile, coref,
compare. Distinct from the benchmark top-level command, which
measures ASR word accuracy.
| Option | Meaning |
|---|---|
--runs N | Number of repeat runs (default: 1) |
--dataset LABEL | Dataset label included in structured output (useful for cross-run comparison) |
--use-cache | Use the analysis cache for benchmark runs (default: bypass cache so each run hits cold paths) |
doctor
batchalign3 doctor
batchalign3 doctor --lang yue --format json
batchalign3 doctor --explain memory_gate_mb
batchalign3 doctor --warnings-as-errors
Pre-flight diagnostic that spawns a test worker, sends known inputs through the morphosyntax pipeline, and validates the output structure. Catches machine-specific issues (stale models, missing processors, MWT quirks) before they become production failures.
| Option | Meaning |
|---|---|
--lang LANG | Language to test (default: eng) |
--format {human,json} | Output format (default: human) |
--python PATH | Custom Python path (overrides BATCHALIGN_PYTHON) |
--explain KNOB | Trace why one resolved knob has its current value (gpu_thread_pool_size, force_cpu, max_total_workers, max_concurrent_jobs, max_workers_per_key, memory_gate_mb). Prints resolved value, source (operator override vs. host-facts recommendation), the rule that produced the recommendation, and the relevant detected facts. Implies --check. |
--warnings-as-errors | Treat host-facts validation warnings as fatal: exit non-zero when any warning fires, not only on error. Intended for CI gates that want zero-warning deployments. Has no effect outside --check / --explain. |
replay
batchalign3 replay <DUMP_FILE>
batchalign3 replay --lang yue path/to/failed_ipc_*.json
Replay a captured failed IPC request against a fresh worker. Takes a
dump file from ~/.batchalign3/debug/ and sends the exact request to
a new worker, reporting the response. Useful for reproducing field
failures locally.
eval
batchalign3 eval l2-morphotag <ARGS>
batchalign3 eval utr-alignment --chat <CHAT> --tokens <JSON> --output <JSON>
batchalign3 eval utseg-replay post-chat --input-chat <CHAT> --evidence <JSON> --output-chat <CHAT>
batchalign3 eval utseg-replay pre-asr --asr-response <JSON> --evidence <JSON> --output-chat <CHAT> [--media-name <NAME>] [--wor]
Evaluation subcommands. Currently:
| Subcommand | Purpose |
|---|---|
eval l2-morphotag | L2 morphotag evaluation: pair @s words with %mor / %gra items via typed AST walk (supersedes scripts/l2-eval/analyze.py) |
eval utr-alignment | Offline global UTR word-to-token replay with fingerprinted typed evidence and no inference or CHAT mutation |
eval utseg-replay | Reapply retained utterance-boundary evidence and report whether it still reproduces the document the run wrote; exits 1 on a difference, 2 on a refused input |
version
batchalign3 version
Prints version and build information.
Exit codes
batchalign3 uses stable non-zero exit code categories:
| Code | Meaning |
|---|---|
2 | Usage/input error |
3 | Configuration error |
4 | Network/connectivity error |
5 | Server/job lifecycle error |
6 | Local runtime error |
Exit code 1 is reserved for unexpected failures outside the typed categories.
A server that reports another build than the CLI’s, or no build, is refused
before anything is submitted, with exit code 5; see
Server Mode: build identity check.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
align
Status: Current Last updated: 2026-09-15 09:28 EDT
Add word-level and utterance-level timestamps to an existing CHAT transcript by running forced alignment against the corresponding audio file.
Requires: a .cha file whose @Media header names an audio file visible
to the server (or to the local daemon). See
Media Resolution.
Quick start
# Align one file in place
batchalign3 align file.cha
# Cantonese / other non-RevAI languages: choose a compatible UTR backend explicitly
batchalign3 align yue_file.cha --utr-engine whisper
# Align a corpus directory, writing results to a separate output directory
batchalign3 align corpus/ -o aligned/
# Audio lives in a different directory from the .cha files
batchalign3 align transcripts/ -o out/ --media-dir /path/to/audio/
# Align a curated list of files against a remote server
batchalign3 --server http://your-server:8001 align --file-list rerun.txt
Pipeline
align is FA-first. It does not always need utterance timing recovery
(UTR), and the selected UTR backend only matters when the parsed CHAT file
actually contains untimed utterances.
In practice that means:
- fully timed files can skip UTR entirely and go straight to FA
- partially timed or untimed files may require UTR before FA
- backend/language errors should be read as “this file needs UTR, and the selected UTR backend cannot support it”, not as “forced alignment itself is unavailable for this language”
The diagram below shows how CLI flags control the alignment pipeline at
runtime. Source: crates/batchalign/src/runner/dispatch/.
flowchart TD
start([align invoked]) --> read[Read CHAT file]
read --> resolve_audio[Resolve audio file]
resolve_audio --> ensure_wav[ensure_wav: convert mp4→wav if needed]
ensure_wav --> parse[parse_lenient → ChatFile]
parse --> reuse_check{Complete reusable\n%wor timing?}
reuse_check -->|Yes| reuse[Refresh main-tier bullets from %wor\nmechanically, then resolve overlap\n(--end-overlap-policy) and only THEN\noptionally regenerate %wor from the\nresolved state]
reuse_check -->|No| count[count_utterance_timing → timed, untimed]
reuse --> done([Output .cha file])
count --> utr_check{untimed > 0?}
utr_check -->|No| skip_utr[Skip UTR, all timed]
utr_check -->|Yes| utr_engine_check{UTR enabled\nand selected backend\nsupports this file?}
utr_engine_check -->|Yes| run_utr_pass["run_utr_pass()"]
utr_engine_check -->|No: --no-utr| warn_interp[Log warning\nFall back to interpolation]
run_utr_pass --> utr_done[Re-serialize CHAT\nwith recovered timing]
utr_done --> group
warn_interp --> group
skip_utr --> group
group[group_utterances → time windows]
group --> before_check{--before path\nprovided?}
before_check -->|Yes| incremental[process_fa_incremental\nDiff old vs new, copy stable %wor,\nreuse preserved groups]
before_check -->|No| full[process_fa\nProcess all groups]
incremental --> engine_select
full --> engine_select
engine_select{--fa-engine?}
engine_select -->|whisper| whisper_fa[Whisper engine\nonset times only\nmax_group_ms from the engine = 20000]
engine_select -->|wav2vec / cantonese| wav2vec_fa[Wave2Vec engines\nword start+end\nmax_group_ms from the engine = 15000]
engine_select -->|qwen3_fa| qwen3_fa[Qwen3 aligner\nword start+end\nyue/zho/cmn/eng only\nmax_group_ms from the engine = 15000]
whisper_fa --> pause_check
wav2vec_fa --> pause_check
qwen3_fa --> pause_check
pause_check{--pauses?}
pause_check -->|Yes| preserve[WordGapHealing::PreserveMeasured\nkeep each word's own end]
pause_check -->|No| heal[WordGapHealing::Heal\nbridge small plausible gaps]
preserve --> cache_check
heal --> cache_check
cache_check[Cache lookup: BLAKE3 keys]
cache_check --> worker_infer[execute_v2(task="fa") misses → Python FA worker\nprepared audio + prepared text]
worker_infer --> response_shape{Worker response shape?}
response_shape -->|Wave2Vec / Cantonese indexed intervals| indexed_fa[Validate one timing per requested word]
response_shape -->|Whisper token onsets| dp_align_fa[DP-align returned tokens → transcript words]
indexed_fa --> inject_fa[Inject word-level timings into AST]
dp_align_fa --> inject_fa
inject_fa --> retry_check{FA\nsucceeded?}
retry_check -->|Yes| prior_boundary_check{"--existing-wor-boundaries?"}
prior_boundary_check -->|preserve| preserve_prior[Clamp against prior authoritative bounds<br/>and preserve compatible edge coverage]
prior_boundary_check -->|rebuild-from-evidence| rebuild_prior[Keep fresh word extents<br/>rebuild main bullet from word hull]
preserve_prior --> overlap_policy
rebuild_prior --> overlap_policy
retry_check -->|No + retryable| fallback_check{Untimed utts\nnot recovered?}
fallback_check -->|Yes + not tried| fallback_utr["Fallback: run_utr_pass()\n(at most once)"]
fallback_utr --> retry_loop[Retry FA with\nrecovered timing]
retry_loop --> cache_check
fallback_check -->|No or already tried| backoff[Backoff + retry]
backoff --> cache_check
overlap_policy{"--end-overlap-policy?\n(default: preserve-cross-speaker)"}
overlap_policy -->|preserve-cross-speaker default, ALWAYS| resolve_same[Resolve same-speaker overlap BY SPEAKER STREAM\nnot file adjacency: skips an intervening\nother-speaker line. CoverageOnly / BoundaryFromWords /\nInterleavedWords, from measured word hulls.\ncross-speaker overlap untouched]
resolve_same -->|clamp-all-adjacent ADDITIONALLY| resolve_all[Also resolve every PHYSICALLY adjacent overlap\nsame rule, any speaker pair\ncannot undo resolve_same: only shrinks]
resolve_same -->|preserve-cross-speaker| wor_check
resolve_all --> wor_check
wor_check{--wor / --nowor?}
wor_check -->|--wor| gen_wor["Generate %wor tier\n(from the RESOLVED state)"]
wor_check -->|--nowor| skip_wor[Omit %wor tier]
gen_wor --> merge_check
skip_wor --> merge_check
merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations transform]
merge_check -->|No| validate
merge --> validate[Post-validate → serialize CHAT output]
validate --> done([Output .cha file])
Validation model
align uses staged validation:
- request-shape validation Invalid path-mode shapes, malformed flags, and incompatible option payloads fail immediately.
- file-state inspection After parsing the CHAT file, Batchalign inspects whether the file is already timed well enough to skip UTR.
- stage-specific backend validation If the file needs UTR, Batchalign validates the selected UTR backend against the file’s language before running timing recovery.
This is why --utr-engine matters for some files but is irrelevant for others.
UTR strategy selection (Auto disabled)
When --utr-strategy auto (the default), the strategy is currently
always GlobalUtr regardless of file content or language. The
previous content/language-aware auto-routing (which auto-picked
TwoPassOverlapUtr for English files containing +< or ⌊ markers)
was disabled 2026-03-30. ResolvedUtrStrategy in
crates/batchalign/src/runner/dispatch/options.rs resolves this policy once. Two-pass overlap-aware
recovery is reachable only via the explicit --utr-strategy two-pass
override.
flowchart TD
auto(["--utr-strategy auto\n(default)"]) --> always_global["GlobalUtr\n(monotonic single-pass)"]
explicit_global(["--utr-strategy global"]) --> force_global["GlobalUtr\n(explicit override)"]
explicit_two(["--utr-strategy two-pass"]) --> force_two["TwoPassOverlapUtr\n(explicit override)"]
Why Auto was disabled: an operator reported alignment regressions on
real files; investigation found that enforce_monotonicity() only
checks start times, not end times, so overlapping utterance bullets
go uncorrected. The two-pass tuning was also based on only four
corpora and not broadly validated. The previously-measured gains
under that mechanism (English: +4.3pp SBCSAE, +3.8pp Jefferson;
non-English on Hakka/Welsh/German/Serbian: GlobalUtr matched or beat
TwoPassOverlapUtr) are retained here as historical context for the
benchmark numbers that motivated the original gate, not as a
description of current default behavior.
UTR internals: partial vs full-file ASR
When fewer than 50% of utterances are untimed and audio is longer than 60 s,
run_utr_pass() uses partial-window ASR (running ASR only over untimed
regions) rather than a full-file pass.
flowchart TD
entry(["run_utr_pass()"]) --> parse[Parse CHAT\ncount timed vs untimed]
parse --> zero{untimed == 0?}
zero -->|Yes| noop([Return: nothing to do])
zero -->|No| ratio{untimed < 50%\nAND audio > 60s?}
ratio -->|Yes| partial_mode
subgraph partial_mode [Partial-Window ASR]
direction TB
pw_find[find_untimed_windows\nPadding: 500ms, merge overlaps]
pw_find --> pw_loop["For each window (start, end):"]
pw_loop --> pw_seg_cache{Segment\ncache hit?}
pw_seg_cache -->|Hit| pw_use[Use cached segment ASR]
pw_seg_cache -->|Miss| pw_extract["extract_audio_segment()\nffmpeg -ss/-to → cached WAV"]
pw_extract --> pw_infer[infer_asr on segment]
pw_infer --> pw_store[Cache segment result]
pw_store --> pw_use
pw_use --> pw_offset[Offset token times\nby window start_ms]
pw_offset --> pw_loop
end
ratio -->|No| full_mode
subgraph full_mode [Full-File ASR]
direction TB
ff_cache{Full-file\ncache hit?}
ff_cache -->|Hit| ff_use[Use cached ASR]
ff_cache -->|Miss| ff_infer[infer_asr on full audio]
ff_infer --> ff_store[Cache full result]
ff_store --> ff_use
end
partial_mode --> inject
full_mode --> inject
inject["inject_utr_timing()\nExact-subsequence fast path,\nelse global DP"]
inject --> result([Return updated CHAT + UtrResult])
Rerun hardening rules
align does not blindly trust existing %wor timing on reruns. Several
regression-driven safeguards now keep stale timing shapes from being refreshed
back into the output:
-
Cheap
%worreuse is health-checked first. Existing%wortiming is reused only when the word distribution already looks plausible. Rerun falls back to fresh FA instead of reuse when:- any
%worword is near-zero (< 40 ms) - a 3+-word utterance has one
%worword consuming more than 40% of the utterance span - the last
%worword already overruns the utterance boundary or other reuse-shape invariants fail
- any
-
Gap healing only bridges small gaps by default. Under
WordGapHealing::Heal, the default, Batchalign may extend a word to the next word’s start to remove tiny pauses; pass--pausesto keep each word’s own end instead. Ordinary smoothing only applies to plausibly small internal gaps (currently<= 1000 ms). Larger gaps are treated as real pauses or mistracks and are left visible unless a more specific rerun-healing rule applies. -
Gap healing treats boundary-sensitive seams specially. Several traced rerun bugs showed that some words already have the right FA timing before postprocess, then become dominant only after smoothing, while others need a targeted heal:
- merged compound fillers like
&-you_knoware not stretched forward a second time after injection merges their split FA parts - ordinary lexical words are not stretched into a following timed filler
span such as
&-umwhen that bridge would make the lexical word dominate the utterance - timed fillers are likewise not stretched across a following pause when that smoothing would make the filler itself dominate the utterance
- a near-zero lexical word may still bridge to the following filler start when that heal stays below the same 40% utterance-share plausibility cap
- if a collapsed lexical word already touches an adjacent word boundary, continuous mode may rebalance that shared boundary so the lexical word reaches the 40 ms floor without collapsing the neighboring span in turn; this now applies when borrowing from either the following word or the preceding word, and for both fillers and ordinary lexical words
- merged compound fillers like
-
Rerun clamping is selective. Fresh FA timings are not clamped to narrow provisional UTR hints, and small final-word overruns can heal instead of being chopped back to a near-zero tail. This prevents reruns from preserving stale narrow bullet windows that were only ever estimates.
-
Prior-boundary rebuilding is an explicit v0.4.0 research projection. The default
--existing-wor-boundaries preserveretains the compatibility behavior above.rebuild-from-evidenceinstead treats earlier%worand main-tier boundaries as revisable output: it keeps admitted word extents and rebuilds the main bullet from their minimum/maximum hull. This flag does not change FA raw-evidence cache keys and cannot authorize inference. Use it with--require-media-cacheand--debug-dirfor controlled replay. It can reveal real conflicts between adjacent utterance boundaries; a structurally wider word hull is not by itself proof that its acoustic boundaries are correct.This option does not force new evidence resolution. If every utterance qualifies for the reusable-
%worfast path, rebuild reconstructs main-tier bullets from those existing admitted word timings and does not replay raw FA cache entries. A controlled raw-evidence comparison must use an input whose intended groups actually reach evidence resolution, then confirm each sidecar’s evidence source. -
The default is
preserve-cross-speaker: cross-speaker overlap is ordinary conversation and is left alone.--end-overlap-policygoverns same-speaker (and, underclamp-all-adjacent, additionally cross-speaker) end overlap. Same-speaker overlap is resolved BY SPEAKER STREAM, not by file adjacency (2026-09-01 review, item 15): each speaker’s own bulleted utterances, in file order, are paired and resolved consecutively WITHIN THAT SPEAKER’S OWN STREAM, skipping any intervening other-speaker line rather than letting it break the pairing. This runs UNCONDITIONALLY, under either policy value: E704 (CLAN 133, a speaker may not overlap themself) is defined on the speaker’s own sequence, not on physical line adjacency, so an ordinary A-B-A dialogue must not hide a same-speaker overlap from resolution. A pair is resolved from MEASURED word timings, never guessed, into one of three cases:- The earlier utterance’s last measured word already ends before the next utterance starts: only the bullet’s inherited coverage overshot, so the bullet end moves back to the word; no word moves, no review is needed.
- The two utterances’ words do not themselves overlap: both bullets take their measured word-hull edges instead of an arbitrary clamp; no word moves, no review is needed.
- The two utterances’ words genuinely overlap in time (or the next
utterance has no measured word): the bullet is clamped to the next
utterance’s start AND every word past that bound is clamped with it,
and the decision is flagged for review, since this is a real conflict
between segmentation and FA evidence that only a person can adjudicate.
clamp-all-adjacentADDITIONALLY applies the same three-way resolution to every PHYSICALLY adjacent pair, cross-speaker included, instead of leaving cross-speaker overlap alone; it cannot undo the speaker-stream resolution above, since every resolution only SHRINKS the pair it touches, so a pair already resolved there satisfies this pass’s own overlap check and is silently skipped. Neither value relaxes start-order enforcement or changes raw FA cache identity.
-
Execution shape does not change the declared projection. The same prior-boundary, optional-repair, and end-overlap policies now apply whether a run performs fresh injection, reuses every
%wor, or resolves no FA groups, and%wor(when requested) is always written AFTER this resolution runs, never before, on every path (fresh alignment, the all-reusable fast path, and per-utterance partial reuse folded into the same write). Optional repair always runs before final monotonicity enforcement, and repair’s own boundary-averaging step (--bullet-repair) uses the SAME three-way resolution on measured hulls, so a small overlap it splits can only move into a bullet’s own inherited coverage, never into a real word; where the hulls themselves overlap, it clamps bullet and words together exactly as the main resolution does. This is a consistency guarantee, not a recommendation to enable the experimental repair flag.
The practical effect is that reruns now prefer fresh FA over stale reuse whenever the old timing distribution already looks suspicious, and postprocess is more conservative about turning real pauses/fillers into dominant words.
Options
Path options (shared with all processing commands)
| Option | Meaning |
|---|---|
PATHS... | Input .cha files or directories |
-o, --output DIR | Output directory (omit to overwrite inputs in place) |
--file-list FILE | Read input paths from a text file (one path per line; # comments and blank lines ignored; relative paths resolve against the list file’s directory; directories expand like positional directories; duplicates are processed once). Cannot be combined with positional PATHS. Full rules: CLI reference |
--in-place | Explicit in-place flag |
Alignment options
| Option | Default | Meaning |
|---|---|---|
--media-dir PATH | alongside .cha | Directory to search for audio files matching the @Media header stem |
--utr-engine {rev,whisper,tencent} | rev | UTR backend; --help lists the accepted values. rev needs Rev.AI credentials and has no Cantonese support; whisper is local; tencent covers Chinese variants. A rejection names the engines that would work for the file’s language. |
--utr-engine-custom NAME | : | Deprecated alias for --utr-engine, still honoured, hidden from --help. |
--utr / --no-utr | enabled | Enable or skip the UTR pre-pass entirely |
--utr-strategy {auto,global,two-pass} | auto | Overlap strategy: auto currently always returns GlobalUtr (the language/content-aware gate was disabled 2026-03-30; see §“UTR strategy selection” above). two-pass is the only way to reach TwoPassOverlapUtr today. |
--utr-fuzzy THRESHOLD | 0.85 | Two-pass only: Jaro-Winkler similarity threshold. Global/auto remain case-insensitive exact; 1.0 = exact only |
--utr-ca-markers {enabled,disabled} | enabled | Use CA overlap markers (⌈⌉⌊⌋) to set alignment windows |
--utr-density-threshold N | 0.30 | Max overlap fraction before skipping pass-1 exclusion (0.0-1.0) |
--utr-tight-buffer MS | 500 | Pass-2 tight window buffer in milliseconds |
--fa-engine {wav2vec,whisper,cantonese,qwen3_fa} | wav2vec (reports word start and end; see §“Forced alignment reference”) | Forced-alignment model. cantonese is the jyutping-preprocessing engine, formerly reachable only as wav2vec_fa_canto through the flag below. qwen3_fa (also spelled qwen3-fa or qwen3) is Qwen/Qwen3-ForcedAligner-0.6B-hf, the SAME aligner the Qwen3-ASR engine uses for its own word timestamps, run here against a transcript you already have; it reports word start and end, and it supports only yue, zho, cmn and eng, refusing any file that DECLARES another language in @Languages:, primary or secondary, by name at admission rather than falling back to another engine. |
--fa-engine-custom NAME | : | Deprecated alias for --fa-engine, still honoured, hidden from --help. |
--wor / --nowor | --wor | Include or suppress the %wor word-timing tier |
--pauses | off | Preserve each engine-reported word end instead of healing small plausible gaps. For Whisper, it also selects the historical character-spaced text mode. |
--existing-wor-boundaries {preserve,rebuild-from-evidence} | preserve | v0.4.0 option controlling how a rerun projects fresh FA evidence when the input already has %wor. preserve keeps compatibility; the experimental rebuild mode keeps fresh word extents and reconstructs the main bullet from their hull. It is a local projection only and does not change raw-evidence cache identity. |
--end-overlap-policy {clamp-all-adjacent,preserve-cross-speaker} | preserve-cross-speaker | Controls the same-speaker/cross-speaker resolution described above. The default leaves cross-speaker overlap alone; clamp-all-adjacent resolves it the same way as a same-speaker pair. It does not change raw-evidence cache identity. |
--merge-abbrev | off | Merge abbreviations in the output CHAT |
--before PATH | : | Previous version of the file for incremental alignment (skip unchanged utterances) |
Both UTR fraction options are validated before a command is constructed:
non-finite values and values outside the closed interval 0.0 through 1.0
are rejected by the CLI and by configuration deserialization.
| --bullet-repair | off | Post-FA bullet repair for timing violations (experimental) |
| --review-level {none,low-confidence,all} | none | Legacy compatibility option. All values now leave CHAT free of %xalign/%xrev; omit it in new scripts. |
What changes in the .cha file
%wortier added or replaced with word-level timestamps (word ·start_end·)- Utterance-level bullet times (
·start_end·) updated (see below for how) - Existing
%morand%gratiers are preserved and untouched - The audio file is read but never modified
Research evidence without CHAT clutter
%wor deliberately stores only display words and timing bullets. It cannot
represent model score, cache/reuse source, or the provenance chain of a timing
that was clamped, derived, or repaired. For an alignment experiment, pass
--debug-dir DIR; a successful run then writes
DIR/<stem>_fa_evidence.json alongside the ordinary debug artifacts.
For a bare input filename, <stem> is the familiar file stem. If the submitted
filename contains directories, Batchalign appends a short digest of that full
identity so equal basenames from different corpus branches cannot overwrite
one another.
Version 0.3.0 writes evidence schema version 2, version 0.4.0 schema version 3,
and the release after 0.4.4 schema version 4, which adds a flat dropped_word_timings
section listing every word timing the run measured and then discarded (with the
speaker, utterance, tier, word position, measured span, and the bound it
exceeded). That section is always present, and empty when nothing was
discarded. All of them record the selected
engine and build/model version,
the cache key and evidence source for every group (wor_reuse, cache, or
inference), stable word identifiers, pre-injection timings, Wave2Vec-family
model scores when available, complete start/end provenance chains, and every
typed decision that later clamped or removed timing. Those decisions remain in
the JSON while CHAT output contains no review-tier projection. A model
score is evidence emitted by the aligner, not a calibrated probability
that a boundary is correct. Schema 3 additionally records stable utterance
ordinals beside the input-AST line indices for numeric monotonicity effects;
this lets a consumer survive an inserted provenance header without attaching
the decision to the preceding utterance. Schema version 3 does not yet record the final
per-word timings after post-processing; use the resulting CHAT for those final
bullets and do not infer a complete repair history from the sidecar.
An enabled evidence dump is fail-closed: if Batchalign cannot create or write the requested sidecar, the job reports a persistence error instead of silently finishing without the research artifact.
When untimed utterance recovery uses Rev, the same debug directory also gets
one or more *_rev_evidence.json sidecars. They record the exact prepared
media, provider presentation, raw cache key/outcome, request identity, and
named UTR projection revision. A partial-window run keys each filename by its
raw evidence identity so several windows cannot overwrite one another.
How utterance bullet times are set
Utterance bullet timing goes through two stages and the result depends on whether this is the file’s first alignment or a re-alignment.
First alignment (UTR → FA):
- UTR runs first, setting a provisional timing hint on each untimed utterance. These hints are rough estimates, they come from a global DP alignment of ASR tokens against transcript words and serve as grouping windows for the FA step. They are not written to the output.
- FA runs within those windows and aligns each word individually.
- After injection, each utterance bullet is replaced with the span derived from the FA word timings (first aligned word start → last aligned word end). This is the self-healing property: valid FA word timings produce a valid utterance bullet by construction, regardless of how accurate the UTR hint was.
Re-alignment, default preserve policy (file already has FA word timings):
If the file already has utterance bullets set by a previous FA run (or hand-linked by an annotator), the bullet is expanded but never shrunk:
- The new bullet covers
min(word_start, existing_start)→max(word_end, existing_end). - This preserves timing coverage around fillers (
&-uh), pauses, gestures (&=laughs), and other elements that FA cannot align but whose timing was already captured in the original bullet.
Re-alignment, experimental v0.4.0 rebuild-from-evidence policy:
- Admitted word extents are not clamped to the prior authoritative bullet.
- The main bullet is replaced by the exact minimum-start/maximum-end hull of the admitted word evidence.
- The raw FA cache key remains unchanged, so a cache-required comparison can vary this projection without rerunning the model.
- A later monotonicity pass applies the selected
--end-overlap-policy, resolving each same-speaker pair – by that speaker’s OWN stream, not file order, so an intervening other-speaker line never hides the pair – (and, underclamp-all-adjacent, additionally each physically adjacent pair, cross-speaker included) from measured word hulls into one of three cases (see above); the default,preserve-cross-speaker, leaves a cross-speaker pair alone entirely. A genuine word conflict remains explicit evidence for adjudication; do not equate CHAT validity or word containment counts with boundary accuracy.
FA failure fallback:
If FA produces no aligned words for an utterance (audio quality too poor, utterance skipped by the engine), the UTR hint is kept unchanged and written as the utterance bullet. This is the safe fallback: approximate timing is better than no timing.
The following diagram shows the decision logic inside
update_utterance_bullet() (fa/mod.rs):
flowchart TD
start(["After FA word injection"]) --> any_words{"Any FA words\naligned?"}
any_words -->|No| keep_existing["Keep existing bullet\n(UTR hint or authoritative)"]
any_words -->|Yes| source_check{"Existing bullet\nsource?"}
source_check -->|"No bullet"| set_word_span["Set bullet =\nword span\n(first start → last end)"]
source_check -->|"UTR hint\n(provisional)"| overwrite["Overwrite with word span\n(UTR estimate → FA precision)"]
source_check -->|"Authoritative\n(hand-linked or prior FA)"| boundary_policy{"Prior-boundary policy?"}
boundary_policy -->|preserve| union["Union compatible coverage\nmin(word_start, existing_start)\n→ max(word_end, existing_end)"]
boundary_policy -->|rebuild-from-evidence| exact_hull["Replace with exact\nfresh word hull"]
keep_existing --> result(["Utterance bullet written"])
set_word_span --> result
overwrite --> result
union --> result
exact_hull --> result
Gotchas
@Media: unlinked is not an error. unlinked means the transcript
exists but utterances have not yet been aligned to timestamps, it is the
normal pre-alignment state. align is precisely the command that creates
those links. The audio file is still resolved and used normally. When an
alignment run produces timing evidence, its output consumes the unlinked
status before writing the file. A pass-through file, or a run that produces no
timing evidence, preserves the status. Timed output is refused if @Media is
missing, ambiguous, unusable, or carries a contradictory status.
Re-aligning an already-aligned file does not shrink utterance bullets under
the default preserve policy.
If an utterance already has a bullet from a previous FA run or from
hand-linking, the new bullet will cover at least as wide a span as the
original. This is intentional: the original bullet may cover fillers,
pauses, and gestures at the edges of the utterance that FA itself cannot
align (because they produce no acoustic signal the aligner recognises).
The union policy ensures that re-running align on the same file is safe
and idempotent. The experimental rebuild-from-evidence policy deliberately
does not make that guarantee; use it only when testing whether prior boundaries
are stale, with evidence retention and output comparison.
Audio must be visible to the execution host. With --server, the server
resolves @Media against its own filesystem. Paths that are valid on your
machine may not be valid on the server. Use --media-dir to point to a
server-visible path, or use the server’s media_mappings configuration.
--utr-strategy global is the default behavior anyway. Since the
auto routing currently always returns GlobalUtr (see §“UTR strategy
selection”), passing global explicitly produces identical behavior.
If you see timing regressions and want to try the two-pass overlap-aware
mechanism, use --utr-strategy two-pass: that’s the only way to
reach it today.
For large re-run lists, split the list into batches and submit
each batch as a separate batchalign3 align --file-list <batch>
invocation rather than passing thousands of files at once. Each
invocation gets a fresh worker pool, which keeps memory pressure
predictable.
Related documentation
- Forced Alignment, algorithm details, prerequisites, media resolution
- %wor Tier, %wor tier semantics and format
- Overlapping Speech, CA overlap markers and
+<encoding - Command I/O: align, I/O patterns, server vs local, mutation behavior
- Command Flowcharts: align, full architecture flowchart
- Dynamic Programming, Hirschberg alignment algorithm
- Incremental Processing,
--beforeflag mechanics
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
transcribe
Status: Current Last updated: 2026-09-16 09:47 EDT
Create a new CHAT transcript from audio files using automatic speech
recognition (ASR). Produces .cha files alongside or in a separate output
directory. Never modifies the input audio.
Quick start
# Transcribe a single recording, output alongside input
batchalign3 transcribe interview.wav
# Transcribe all audio files in a directory
batchalign3 transcribe recordings/ -o transcripts/ --lang eng
# Auto-detect language (useful for bilingual/code-switched audio)
batchalign3 transcribe bilingual.wav -o out/ --lang auto
# Transcribe with paid pyannoteAI Precision-2 diarization, the default
batchalign3 transcribe interview.wav -o out/ --asr-engine whisper --diarization enabled
# Keep all audio local and use the TalkBank-pinned Pyannote model
batchalign3 transcribe interview.wav -o out/ --diarization enabled --speaker-engine pyannote
# Use the remote server
batchalign3 --server http://your-server:8001 transcribe corpus/ -o out/ --lang eng
Dedicated diarization defaults to the pyannoteAI Precision-2 cloud model. Set its API key in the environment:
export BATCHALIGN_PYANNOTE_API_KEY="your-key"
PYANNOTE_API_KEY and BATCHALIGN_PYANNOTE_KEY are also accepted. For
compatibility with existing installations, BA3 also reads
engine.pyannote.key from the [diarize] section of ~/.batchalign.ini.
The environment variables take precedence.
pyannoteAI receives the recording through its temporary-media API. Its use can incur account charges, so confirm the account plan and the recording’s data-use or IRB rules before running it. Job output is not written into the API key configuration.
The local --speaker-engine pyannote alternative uses the public, ungated
TalkBank-pinned talkbank/dia-fork pipeline plus its pinned segmentation and
embedding dependencies, downloaded anonymously on first use. It runs
inference locally and does not use the pyannoteAI API key. --speaker-engine nemo is a second local alternative that avoids the paragraph below entirely.
--speaker-engine pyannote also fetches one UNPINNED, currently GATED
artifact: a PLDA calibration model. pyannote.audio’s pipeline class loads
it unconditionally during construction, and the released config does not
override it, so the class’s own default applies: the gated
pyannote/speaker-diarization-community-1 repository. A machine with no
accepted terms and no Hugging Face token fails on first use naming that
repository. The fix is a token, checked in this order: ~/.batchalign.ini
[auth] hf_token, then Hugging Face’s own resolution (HF_TOKEN, or the
token saved by hf auth login); accepting the repository’s terms at
https://huggingface.co/pyannote/speaker-diarization-community-1 is required
either way. Full detail: diarize.
A Hugging Face token is NOT relevant to the default command shown above,
which uses --speaker-engine pyannote-ai and never reaches this local path.
The standalone diarize command is intended for producing
anonymous .turns.json evidence for an existing transcript. It defaults to
local Pyannote but can explicitly select paid pyannoteAI Precision-2; both
surfaces share the same raw/derived speaker-evidence cache. Integrated
diarized transcription projects speaker evidence onto
timed ASR words before utterance segmentation and CHAT construction; it does
not require a later chatter rediarize pass.
Rev and speaker evidence caching
Rev.AI transcription now caches the raw provider-shaped transcript before BA3 token conversion and post-processing. A normal warm run checks and validates that evidence before submitting anything to Rev, so it avoids another Rev call. Keeping raw monologues, elements, punctuation, timings, confidence, and resolved language also lets BA3 post-processing experiments replay the same service output locally.
With --diarization enabled, BA3 durably caches both the backend-shaped
speaker evidence and the normalized turns used by the transcribe pipeline.
For pyannoteAI, retained raw evidence includes the completed job ID, full
output object, and warning. Repeating the same recording with the same speaker
backend, expected speaker count, preparation recipe, and model revision
replays validated turns without calling the diarization backend again. Copies
and renames of byte-identical recordings share the entry.
Raw evidence and derived turns have separate revision identities. A later BA3 release can change how provider output is converted to speaker intervals and recompute those intervals locally from the retained response. That kind of algorithm experiment does not upload the audio or incur another pyannoteAI inference charge.
This is particularly important for the paid pyannote-ai default. A corrupt
entry fails visibly and does not fall through to another paid call. Concurrent
identical files in one server are coalesced so only the first miss runs
inference.
For a replay experiment that must not turn a missing Rev or speaker entry into a service call, require the cache explicitly:
batchalign3 --require-media-cache transcribe interview.wav -o out/ \
--asr-engine rev --diarization enabled
A raw-evidence miss then fails visibly before service inference is authorized. If raw speaker evidence exists but normalized turns do not, BA3 may still recompute those turns locally. The flag applies to cache-backed stages; it does not cache or suppress ordinary local ASR engines.
To run a deliberate fresh experiment, use the global override:
batchalign3 --override-media-cache transcribe interview.wav -o out/ \
--diarization enabled
The fresh results replace the matching cache entries. The override can incur
new Rev and pyannoteAI charges. Other ASR engines are not yet cached in
ordinary transcribe runs. See Caching for exact invalidation
rules and limitations.
Pipeline
flowchart TD
start([transcribe invoked]) --> resolve[Resolve audio file]
resolve --> ensure_wav[ensure_wav: convert if needed]
ensure_wav --> diarize_check{--diarization?}
diarize_check -->|"enabled"| transcribe_s["Command: transcribe_s\nASR + dedicated speaker relabeling\nRev or Whisper"]
diarize_check -->|"auto/disabled\n(default)"| transcribe_m["Command: transcribe\nDefault path\nRev labels used directly when present"]
transcribe_s --> engine_check
transcribe_m --> engine_check
engine_check{--asr-engine?}
engine_check -->|whisper| whisper[Whisper local ASR]
engine_check -->|whisper_hub| whisper_hub["HF Whisper fine-tune\n(per-language model_id)"]
engine_check -->|rev| rev_key["Hash provider media + Rev request semantics"]
engine_check -->|"whisperx, whisper_oai"| refused["Refused: engine not implemented"]
engine_check -->|"whisper_rs, tencent, aliyun, funaudio or paraformer, qwen"| other_asr["Other engines\nprovider adapter pairs each unit\nwith its own timestamp"]
rev_key --> rev_cache{"Validated raw Rev-evidence cache"}
rev_cache -->|hit| rev_convert["Convert retained raw Rev transcript"]
rev_cache -->|miss/forced refresh| rev_call["Authorized Rev language-ID/submit/poll"]
rev_call --> rev_store["Validate + required durable commit"]
rev_store --> rev_convert
rev_convert --> asr_tokens
whisper --> asr_tokens
whisper_hub --> asr_tokens
other_asr --> asr_tokens
asr_tokens["Raw ASR tokens\nword + start_s + end_s + optional speaker + confidence"]
asr_tokens --> convert["convert_asr_response()\nGroups tokens by speaker label"]
convert --> dedicated_check{"--diarization enabled?"}
dedicated_check -->|No| postprocess
dedicated_check -->|Yes| speaker_key["Hash source bytes + semantic request"]
speaker_key --> speaker_cache{"Validated derived-segment cache"}
speaker_cache -->|hit| retain_check
speaker_cache -->|derived miss| speaker_raw{"Validated raw-evidence cache"}
speaker_raw -->|hit| speaker_normalize["Versioned local normalization"]
speaker_normalize --> speaker_store_derived["Commit derived segments"]
speaker_store_derived --> retain_check
speaker_raw -->|raw miss/forced refresh| speaker_v2["execute_v2(task=speaker)\nprepared audio → backend evidence\npyannoteAI Precision-2 by default"]
speaker_v2 --> speaker_store["Validate + commit raw evidence\nthen derived segments"]
speaker_store --> retain_check{"--debug-dir?"}
retain_check -->|Yes| retain_turns["Write same-job canonical speaker turns\nwith typed backend provenance"]
retain_check -->|No| postprocess
retain_turns --> postprocess
subgraph postprocess ["Rust post-processing: process_raw_asr()"]
direction TB
p1[1. Compound merging] --> p1check{lang=yue?}
p1check -->|Yes| p2["2. Cantonese normalization\nonce per monologue\nOpenCC + domain replacements"]
p1check -->|No| p3
p2 --> p3
p3[3. Multi-word splitting\nsplit tokens with spaces, interpolate timestamps]
p3 --> p4[4. Number expansion\ndigits → word form]
p4 --> p5[5. Long-turn splitting\nchunk at >300 words]
p5 --> p6[6. Retokenization\npunctuation-based utterance splitting]
p6 --> p7[7. Disfluency replacement\nfilled pauses + orthographic from per-language wordlists]
p7 --> p8[8. N-gram retrace detection\nwrap repeated n-grams in `<...> [/]`]
end
postprocess --> speaker_apply{Dedicated speaker\nsegments present?}
speaker_apply -->|Yes| project["Project segments onto timed ASR words\nby greatest summed overlap\nSplit chunks at speaker changes"]
speaker_apply -->|No| utseg_check{"with_utseg?\ndefault: true"}
project --> utseg_check
utseg_check -->|Yes| run_utseg[process_utseg_with_evidence\nBERT-based re-segmentation]
utseg_check -->|No| mor_check{"with_morphosyntax?\ndefault: false"}
run_utseg --> build_chat["build_chat → ChatFile AST\nHeaders, participants, %wor tiers"]
utseg_check -->|No| build_chat
build_chat --> mor_check
mor_check -->|Yes| run_mor[process_morphosyntax\nPOS + lemma + depparse]
mor_check -->|No| merge_check
run_mor --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| output
merge --> output[Serialize → .cha output]
output --> done([Output .cha file])
Utterance boundary detection
transcribe always does utterance splitting before CHAT output is written.
There are two paths:
eng,cmn,zho,yue: dedicated pre-CHAT utterance models- all other languages, punctuation-based splitting in Rust
Utterance segmentation also runs as a second pass over the built CHAT, and that
pass needs a segmenter. A language in the first list has one. A language in the
second does not, unless you pass --utseg-fallback-stanza, and a run that
asks for utterance segmentation in such a language is refused when the job is
planned, before any ASR runs. That matters because ASR is the expensive part:
the refusal used to happen at the very end of the pipeline, after the whole
transcription had been produced and paid for, and it named the worker’s wire
format (invalid utseg V2 result) rather than the missing model. So a Spanish,
French, Japanese or Catalan transcription either authorizes the fallback:
batchalign3 transcribe corpus-spa/ -o out/ --lang spa --utseg-fallback-stanza
or is told immediately that it has no segmenter.
With --lang auto the language is not known until ASR has returned, so for
that case alone the same refusal necessarily happens after ASR rather than
before it.
For the FunASR engines (funaudio, paraformer), Chinese ASR tokens are
single characters (Latin words stay whole), each with its own timestamp, and
the provider’s punctuation is not used as a boundary. Builds before 2026-09-15
paired Paraformer clauses with per-character timestamps, which mistimed whole
transcripts; re-run Paraformer transcripts made with them. Details:
ASR token pipeline.
For a supported language, normal transcription applies the utterance model
again after CHAT construction. This second pass can refine boundaries using
the completed main-tier context. Standalone utseg remains available for
re-segmenting an existing CHAT transcript without transcribing media again.
With --wor, a post-CHAT split that has complete timing for all partitioned
word tiers receives a main-tier timing bullet on every child, derived from
that child’s own word span. If complete child timing is unavailable, no child
receives a main-tier bullet at all. The parent’s bullet measures the whole
parent, so its start is where the first child began and its end is where the
last one finished, and writing it onto any one child would present a time
nobody measured as a measured one. The single exception is a split that kept
one child, where the parent’s span is still exactly that child’s. This is a
timing-preservation rule, not a claim that the model’s segmentation is the only
acceptable CHAT segmentation.
Options
Path options
| Option | Meaning |
|---|---|
PATHS... | Audio files (.mp3, .mp4, .wav) or directories |
-o, --output DIR | Output directory for new .cha files |
--file-list FILE | Read input paths from a text file |
--in-place | Write .cha files alongside the audio inputs |
ASR and language options
| Option | Default | Meaning |
|---|---|---|
--lang CODE | eng | 3-letter ISO language code, or auto for language auto-detection |
--asr-engine NAME | rev | ASR engine; see the table below. --help prints the same list, generated from the engines that exist, so neither can go stale. |
--asr-engine-custom NAME | : | Deprecated alias for --asr-engine, still honoured so existing scripts keep working. Hidden from --help. |
--num-speakers N | 2 | Speaker count passed to Rev.AI and to the dedicated diarizer. With --diarization enabled it must be 2 or more: a count of 1 is refused at submission, not obeyed. NOT a worker count; see --workers. No short flag, deliberately: see below. |
--auto-speakers | off | Let the provider infer the speaker count instead. Rev.AI only: any other --asr-engine is refused before the job runs. Conflicts with --num-speakers. |
--diarization {auto,enabled,disabled} | auto | Dedicated speaker diarization stage (auto = disabled) |
--speaker-engine {pyannote-ai,pyannote,nemo} | pyannote-ai when enabled | Paid pyannoteAI Precision-2 cloud diarization, or an explicit local engine |
--wor / --nowor | --nowor | Include or suppress the %wor word-timing tier |
--merge-abbrev | off | Merge abbreviations in the output |
--utseg-fallback-stanza | off | Opt in to the legacy Stanza constituency-parser fallback for utterance segmentation when no TalkBank BERT model is configured for --lang. Default refuses substitution. See utseg → Language support. |
Why --num-speakers has no short flag
-n was a short form for it until 2026-08-19. It was removed because -n
reads as a job or worker count nearly everywhere it appears, pytest -n most
of all, and this CLI has its own --workers beside the make -j and
xargs -P conventions. A caller reaching for parallelism was therefore
reconfiguring DIARIZATION, silently.
That is not a hypothetical. On 2026-08-19 a re-transcription of four sessions
was submitted as -n 4 meaning four workers. Four speakers would have
over-diarized those sessions against the 361 siblings in the same delivery,
which were built with two.
How visible would that have been? Less than nothing, and more than an
earlier draft of this page claimed. The run SUCCEEDS, and no warning is
printed. The effect is visible if you look: @Participants and the @ID
headers list only the speakers that actually occur in the output, so an
over-diarized transcript lists more of them and carries extra speaker tiers.
What is genuinely absent is the CAUSE. The requested count is recorded
nowhere in the file, so a reader seeing four speakers cannot tell an
over-diarized run from a session that really had four.
An UNDER-count is not absorbed either. The count is passed to Rev.AI and to
the dedicated diarizer, and the local pyannote and nemo engines produce
exactly the number they are given, so a count below the real number of
voices merges voices together. (An earlier version of this page said the
pipeline took max(num_speakers, detected); no such step exists.) Only
Rev.AI can infer the count, with --auto-speakers.
A count of 1 is refused, not obeyed. With --diarization enabled, a
count of 1 asks a diarizer to separate the speakers of a recording asserted
to hold one, which is a contradiction rather than a request. The job is
refused at submission:
diarization was requested with a speaker count of 1, which asks a diarizer to separate speakers in a recording asserted to have one. Pass the real count (2 or more); or omit diarization, for a single-speaker recording; or pass auto_speakers to have the count inferred, where the ASR engine supports it.
So with diarization the count is 2 or more, or it is omitted and the number
is detected (on Rev.AI, --auto-speakers). One is neither, and it is refused
rather than silently treated as detection. It used to be obeyed: the count
reached the diarizer, which returned a single track, which is why
--diarization enabled runs came back with one PAR0.
Speaker codes are also per recording. Each file is diarized on its own, so
PAR0 in one transcript and PAR0 in another need not be the same person,
even when a corpus has fixed participants; see
diarize.
-n is now a hard error, which is the point. A caller who meant parallelism
goes looking and finds --workers; a caller who meant speakers finds
--num-speakers. Both land where they intended, and neither is silently
misread. On every command but diarize the flag defaults to 2, so most
callers never type either; diarize has no default, because omitting it and
letting the engine auto-detect is the recommendation there.
Note that --workers has its own trap, documented on the flag itself: it
applies to NEW daemons, and does not change the parallelism of a daemon that
is already running and being reused.
ASR engines
Every engine is reachable through the single --asr-engine flag. This list is
generated from the engine set itself, as is the one --help prints.
| Name | Notes |
|---|---|
rev | Rev.AI cloud ASR. The default. |
whisper | Local Whisper. |
whisper_hub | HuggingFace Whisper fine-tune by model id. See whisper-hub-asr.md. |
whisperx | Not implemented. Accepted as a name and refused at submission; nothing here runs WhisperX. |
whisper_oai | Not implemented. Accepted as a name (whisper-oai is the historical spelling) and refused at submission; nothing here calls the OpenAI Whisper API. |
whisper_rs | Rust-native whisper.cpp, run in process. |
tencent | Tencent Cloud ASR. |
aliyun | Aliyun ASR. |
funaudio | FunASR / SenseVoice. Local, no credentials, no network. |
qwen | Qwen3-ASR. Local. |
paraformer | FunASR loading the Paraformer checkpoint: shorthand for funaudio with funaudio_model=paraformer-zh. Commonly wanted for Mandarin. An explicit --engine-overrides '{"funaudio_model":"..."}' wins over the implied checkpoint. Either way the transcript’s asr_model= records what actually loaded: the alias resolves to the checkpoint this build pins, and a checkpoint it does not pin is loaded anyway and recorded at the revision the worker reports for it. |
# Mandarin with Paraformer.
batchalign3 transcribe Mandarin_mp3 -o out --lang zho --asr-engine paraformer
The engine NAME goes to --asr-engine. --engine-overrides takes a JSON
object of per-engine settings, and it is parsed while the command line is,
so a name passed to it (--engine-overrides paraformer) is refused before
anything runs, with --asr-engine paraformer named in the message rather
than a JSON syntax complaint.
Speaker labeling and segmentation
This is the most common source of confusion with transcribe.
Rev.AI (default engine): Rev.AI returns speaker labels as part of its ASR
response. These labels are always applied, you get multi-speaker output
without passing --diarization enabled. Passing --diarization enabled
explicitly makes dedicated diarization authoritative and ignores Rev’s speaker
projection. The default dedicated engine is pyannoteAI Precision-2.
Whisper-based engines (--asr-engine whisper, whisper_hub, whisper_rs):
these engines produce no speaker labels. Without --diarization enabled, all
utterances are attributed to a single default speaker. Pass
--diarization enabled to run a dedicated speaker stage that assigns speaker
identities.
--diarization auto (the default) = disabled dedicated stage. Equivalent
to BA2’s --nodiarize. The BA2 help text claiming Rev.AI ignored --diarize
was stale, the actual BA2 transcribe_s pipeline wiring ran the dedicated
stage.
Dedicated diarization is integrated before utterance segmentation. BA3 first post-processes the timed ASR words, then assigns each timed word to the speaker with the greatest summed diarization overlap and splits prepared chunks where that label changes. The language-specific utterance model therefore sees the speaker boundaries and cannot merge across them. Untimed punctuation inherits the nearby timed label. Timed words in gaps take the nearest dedicated segment label. Once dedicated evidence exists, no word can re-enter the unrelated ASR label space and create a phantom participant. Diagnostics report contested words, unattested words, and inserted speaker boundaries.
Retaining the exact diarization turns used by transcription
For research, replay, or merge-pipeline evaluation, pass --debug-dir PATH
with --diarization enabled. In addition to the other debug artifacts, BA3
writes one <audio-stem>.turns.json file containing the exact
dedicated segments used to build that transcript. The artifact uses the same
deterministic PAR coordinate system as the generated CHAT and records typed
backend provenance, including batchalign3:pyannote_ai:precision-2 for the
cloud default.
BA3 also writes a versioned *_speaker_evidence.json sidecar. It records the
source digest, preparation revision, backend and expected-speaker request,
model and normalization revisions, raw and derived cache keys, whether the run
replayed derived evidence, re-normalized raw evidence, or inferred after a
miss, the named segment-projection revision, and a versioned digest and count
of the exact validated segments. It excludes machine-local source paths and
credentials.
When this artifact is requested, failure to create or write it fails the file
instead of silently discarding the evidence. With a remote server, PATH is
on the server host; the CLI sends an absolute path.
Keep both sidecars, the run manifest, and generated CHAT together: the causal record explains where the evidence came from, while the turns artifact is the exact normalized projection consumed downstream.
For Rev ASR, the same --debug-dir run also writes a versioned,
collision-resistant *_rev_evidence.json sidecar. It records the source and
provider-media digests, preparation recipe, exact multipart presentation,
language and speaker request, model/request revisions, raw cache key, cache
outcome, and local projection revision. This sidecar is also fail-closed when
requested and excludes the Rev credential and machine-local source path.
For languages with a TalkBank utterance-boundary model, BA3 also writes a
versioned *_pre_chat_utseg_evidence.json sidecar. Normal model-backed
transcription writes a separate *_post_chat_utseg_evidence.json sidecar for
the second pass over completed main tiers. Keeping the phases separate prevents
an experiment from confusing boundaries over timed ASR chunks with later
boundaries over main-tier words.
Each item records the exact input words and applied group assignments. A boundary-model result additionally records its model ID and revision plus one evidence state per word: raw action, action after adjacency policy, and sentence-end probability, or an explicit normalization-omission or short-input state. Constituency-tree projection and compatibility assignments without model evidence are separate source variants. BA3 refuses a worker result whose assignments or evidence do not exactly parallel the input words. As with the Rev and speaker causal records, an enabled utseg evidence write is atomic and fail-closed.
The current boundary model is lexical and contextual. It does not itself receive waveform energy, pause duration, pitch, diarization overlap, or CHAT retrace structure. The sidecar makes its contribution inspectable so research code can compare it with those signals without rerunning the model.
--lang auto behavior
With --asr-engine whisper, --lang auto omits the language parameter from
Whisper’s generation kwargs, letting the model detect the spoken language from
the audio. The multilingual openai/whisper-large-v3 model is always used
with auto: language-specific fine-tuned models are bypassed because they
are trained for a single language.
With Rev.AI, --lang auto submits a true auto-language request to the Rev.AI
API. Note that Rev.AI auto-detect and explicit --lang eng can produce
different punctuation, diarization, and turn boundaries from the provider.
What gets created
A new .cha file per audio input (audio extension replaced: foo.wav →
foo.cha). Contains:
- a structured provenance
@Comment([fc-ba3 transcribe | ...]) plus a human-readable warning,fc-ba3 <build identity>, ASR engine <engine>. Unchecked output of ASR model, DO NOT USE., carrying the build identity, the actual ASR engine name (with the models that produced the text in parentheses, in the same formasr_model=uses), andDO NOT USEfor unchecked model output. A run that reported no models, such as one replaying legacy evidence, carries no parenthetical at all rather than naming what was requested. Re-transcribing replaces an earlier warning of ours, including the olderBatchalign <version>, ASR Engine <engine>.form @Languages,@Participants,@IDheaders- Utterance lines with timing bullets
%wortier (if--woris set)
No %mor or %gra tiers are created by transcribe. Run morphotag
afterwards if morphosyntactic analysis is needed.
Gotchas
Rev.AI skip_postprocessing: For English and Spanish (the only
languages Rev.AI’s API documents the parameter as supporting), Rev.AI
is called with skip_postprocessing=true. The hint table is in
crates/batchalign/src/revai/preflight.rs::skip_postprocessing_hint,
which matches Rev.AI’s own 2-letter codes "en" | "es" and returns
None for everything else. The flag is true because CHAT records
spoken form ("eighty percent", "seventeen year old"); leaving it
off causes Rev.AI to apply ITN and return main-tier-illegal forms
like "80%" / "17-year-old". For languages outside the en/es
support pair, no flag is sent (the parameter is a no-op there per
Rev.AI’s docs), and BA3’s downstream post-processing handles
spoken-form normalization.
A recording with no recognized words fails; it does not produce an empty
file. If the ASR engine returns no words, or post-processing keeps no
utterance from the words it did return, or every token that reaches CHAT
assembly is a terminator or separator, the job fails and says which of those
three happened. Until 2026-09-16 the first of these wrote a transcript of
headers and nothing else and reported the job completed, which is
indistinguishable from a correct transcript of a silent recording. If the
recording does contain speech, the usual causes are the wrong --lang for the
audio or an engine that has no model for it.
--server requires server-visible audio. With --server, the server
resolves audio paths on its own filesystem. Paths valid on your machine must
also be reachable from the server, or you must use a shared media mount.
Memory on developer machines. Each Whisper model instance uses 2-15 GB.
For large corpus runs (more than a handful of files or >1 GB audio total),
prefer a dedicated server with substantial RAM (via --server) over a
developer laptop, and always pass --workers 1 for local smoke tests.
Related documentation
- Rev.AI Integration, API key setup, engine behavior
- Cantonese Engines, Tencent, Aliyun, FunASR engines
- Utterance Segmentation, post-ASR BERT utseg
- Command I/O: transcribe, I/O patterns and mutation behavior
- Command Flowcharts: transcribe, full architecture flowchart
- ASR Token Pipeline, ASR post-processing details
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
morphotag
Status: Current Last updated: 2026-09-05 04:22 EDT
Add morphosyntactic analysis (%mor POS/lemma tiers and %gra dependency
tiers) to existing CHAT transcripts. Text-only, no audio involved.
Language is per-file, not job-level
Morphotag has no --lang flag. Every input file’s processing language
is read from that file’s own @Languages: header at the start of the
per-file pipeline (pipeline/morphosyntax.rs::resolve_per_file_lang).
A single morphotag invocation can therefore process a heterogeneous
corpus, English files routed to Stanza English, Spanish files to Stanza
Spanish, Cantonese files to Stanza Chinese with the PyCantonese POS
overlay, etc., all from one command. The job’s wire-level language
spec is LanguageSpec::PerFile, surfaced on the dashboard and JSON API
as "per-file". No English placeholder is ever stored.
If a file’s @Languages: header is missing, malformed, or names a
language that Stanza does not support, morphotag does not silently
fall back to English. The file is reported in the job’s status with a
typed error and returned unchanged.
Quick start
# Tag one file in place, language is read from the file's @Languages header
batchalign3 morphotag file.cha
# Tag a corpus directory
batchalign3 morphotag corpus/ -o tagged/
# Retokenize main lines to match UD tokenization (expands contractions)
batchalign3 morphotag corpus/ -o out/ --retokenize
# Use remote server
batchalign3 --server http://your-server:8001 morphotag corpus/ -o out/
# Deliberately analyze CA transcripts while preserving @Options: CA
batchalign3 morphotag corpus/ -o out/ --ca-policy analyze
To “override” the language, edit the file’s @Languages: line. There is
no CLI shortcut, and there cannot be, because a single command may span
many languages.
Pipeline
All files are batched together through the batched-text-infer pool
(crates/batchalign/src/runner/dispatch/infer_batched.rs handles the
recipe-driven dispatch family; ReleasedCommand::Morphotag is the
discriminant used by the planner at
crates/batchalign/src/runner/dispatch/plan.rs).
Utterances are pooled across all files, grouped by language, and
dispatched to a Stanza worker per language group with semaphore-bounded
concurrency. Repeated morphotag runs on the same input run the full
Stanza pipeline again, text-NLP results are not cached
(CacheTaskName at crates/batchalign/src/chat_ops/cache_key.rs:58
covers only ForcedAlignment and UtrAsr).
flowchart TD
start([morphotag invoked]) --> parse[Parse all files → ASTs]
parse --> clear[Clear existing %mor/%gra tiers]
clear --> collect[collect_payloads\nPer-utterance word lists with language metadata]
collect --> retok_check{--retokenize?}
retok_check -->|Yes: --retokenize| stanza_retok[TokenizationMode::StanzaRetokenize\nStanza may split/merge words]
retok_check -->|No: --keeptokens| preserve[TokenizationMode::Preserve\nKeep original tokenization]
stanza_retok --> lang_check
preserve --> lang_check
lang_check{--skipmultilang?}
lang_check -->|Yes| skip_non_primary[MultilingualPolicy::SkipNonPrimary\nSkip utterances in non-primary language]
lang_check -->|No: --multilang| process_all[MultilingualPolicy::ProcessAll\nProcess all utterances regardless of language]
skip_non_primary --> worker
process_all --> worker
worker[execute_v2(task='morphosyntax')\nprepared_text batch → Stanza NLP pipeline\nper-language semaphore-bounded dispatch]
worker --> repartition[Repartition responses by file]
repartition --> inject_results[inject_results → insert %mor/%gra tiers]
inject_results --> before_check{--before path?}
before_check -->|Yes| incremental[process_morphosyntax_incremental\nSkip NLP for unchanged utterances]
before_check -->|No| full_inject[Process all utterances]
incremental --> merge_check
full_inject --> merge_check
merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| validate
merge --> validate[Alignment validation\n%mor word count must match main tier]
validate --> done([Output .cha files])
Options
Path options
| Option | Meaning |
|---|---|
PATHS... | Input .cha files or directories |
-o, --output DIR | Output directory (omit to overwrite in place) |
--file-list FILE | Read input paths from a text file |
--in-place | Explicit in-place flag |
If you combine --file-list with in-place processing on a large corpus, do
not expect the .cha files on disk to rewrite one by one during the run.
morphotag batches and stages text-NLP work internally; the visible in-place
file updates may land only when the current invocation finishes. For long
repair runs where you want output to appear incrementally, split the file list
into smaller chunks and run those chunks sequentially.
Morphotag options
There is no --lang flag. Each file’s processing language is read
from its own @Languages: header. Passing --lang to morphotag is a
clap parse error, the CLI surface deliberately rejects it. See the
“Language is per-file, not job-level” section above for the rationale.
| Option | Default | Meaning |
|---|---|---|
--retokenize / --keeptokens | --keeptokens | Retokenize main lines to UD tokenization (may split/merge words), or preserve existing tokenization |
--skipmultilang / --multilang | --multilang | Skip utterances in non-primary languages, or process all |
--lexicon FILE | : | Comma-separated manual lexicon override file (read on client, injected as typed options) |
--merge-abbrev | off | Merge abbreviations in the output |
--no-l2-morphotag | off | Opt out of L2 dispatch. With this flag, @s code-switched words emit L2|xxx placeholders instead of real POS/lemma/deprel annotations (legacy behavior, kept for reproducibility of older analyses) |
--no-pos-hints | off | Opt out of transcriber $POS hint respect. By default, after morphotag the pipeline overrides any %mor POS that disagrees with the CLAN→UD-mapped hint on main-tier words carrying $POS suffixes. Lemma and features from Stanza are preserved. Pass --no-pos-hints to skip the override pass and keep Stanza’s POS as-is. See Transcriber $POS Hints for the mechanism and coverage table |
--ca-policy honor|analyze | honor | Honor @Options: CA pass-through, or explicitly run morphotag while preserving that header. Use analyze only when the corpus reconstruction record calls for an analyzed CA edition |
--before PATH | : | Previous version of the file for incremental processing (skip unchanged utterances) |
@Options: CA files pass through by default
Files whose header declares @Options: CA (Conversation Analysis mode)
are passed through morphotag unchanged. The pipeline parses the file,
detects the option, and serializes it back as-is, no %mor / %gra
tiers are added, and any pre-existing %mor / %gra tiers are
preserved verbatim. Provenance comments are not injected for these
files.
This mirrors how align skips files with @Options: NoAlign. The mechanism
is the option header plus the submitted typed CA policy; per-utterance content
(CA prosody markers, pauses, &= events, etc.) does not influence the
decision. --ca-policy analyze explicitly selects the normal morphotag
pipeline for these files and retains the @Options: CA line in the result.
This makes a historical corpus reconstruction reproducible without silently
deleting or editing the source declaration.
What changes in the .cha file
%mortier added or replaced with POS tags and lemmas per word%gratier added or replaced with dependency relations- A provenance
@Commentnaming the Stanza models that ran, and counting any dependency relations that had to be repaired because Stanza produced a label outside Universal Dependencies (ud_repairs=, absent when there were none). See Provenance - Main tier text may be retokenized when
--retokenizeis set - Special
@Options: dummynotation is auto-detected and preserved - No audio is involved; this is a text-only transform
Language routing
The language for each file is read from its @Languages: header (first
declared language). Individual utterances tagged with a [- lang] precode
are routed to the appropriate language-specific Stanza model regardless of
the file-level language. There is no CLI override, see the “Language is
per-file, not job-level” section at the top of this page.
For Cantonese, files declared with primary @Languages: yue route to
Stanza’s Chinese (zh) pipeline with a PyCantonese POS overlay applied
after Stanza finishes (Stanza zh scores ~50% on Cantonese vocabulary;
PyCantonese ~94%, only upos is replaced; lemma and dependency parse
from Stanza are preserved). Mandarin files (zho / cmn) use Stanza zh
without the PyCantonese overlay. See
Cantonese language details and
Mandarin.
See Language Routing.
--retokenize warning
--retokenize allows Stanza to split or merge words on the main tier to match
UD tokenization (e.g. expanding “don’t” → “do n’t”). This may invalidate
existing %wor timing bullets. If the file has already been aligned, re-run
align after retokenizing.
Reading the server log
If you see WARN Stripped N Stanza control-token leak(s) ... lines
in ~/.batchalign3/server.log, those are working-as-designed
signals from a known-upstream-defect workaround firing, not errors.
See the troubleshooting page section
Stripped N upstream-library warnings
for the full explanation and what to do.
L2 dispatch for code-switched words (default: on)
@s (code-switched) words are routed to secondary-language Stanza
models and annotated with real POS tags, lemmas, and dependency
relations, including proper handling of contractions
(it's@s:eng → pron|it~aux|be) and phrasal verbs
(wake@s up@s → verb|wake part|up with COMPOUND-PRT GRA deprel).
This is the default behavior. Mandarin-marked words (@s:cmn /
@s:zho) route through the Chinese zh morphosyntax path, and
Cantonese-marked words (@s:yue) use the same secondary-dispatch
surface as other supported Stanza languages. Unresolved or unsupported
targets still fall back to L2|xxx.
To opt out and emit legacy L2|xxx placeholders (e.g. for
reproducibility of older analyses), pass --no-l2-morphotag:
batchalign3 morphotag bilingual.cha --no-l2-morphotag
Validation. L2 dispatch has been validated at scale: across 19
language pairs and ~17K @s words, well above 99% dispatch to a
secondary-language Stanza model on most pairs, with 100% dispatch on
the majority of evaluated language pairs. The remaining cases fall
back to L2|xxx.
Unsupported non-primary languages
morphotag only requires the primary @Languages code to be
Stanza-supported. Files whose primary is not Stanza-supported are
skipped with a typed diagnostic and never enter the pipeline.
When the primary IS supported, non-primary content in any language
that Stanza does not support is processed cleanly with an L2|xxx
fallback:
[- UNSUPPORTEDLANG]whole-utterance precodes, the entire utterance is grouped underUNSUPPORTEDLANG, the worker partition routes that group to the fallback bucket (no Stanza dispatch), and every word in the utterance receivesL2|xxxin%morwith no%grarelation emitted for those positions.@s:UNSUPPORTEDLANGper-word markers, the secondary L2 dispatch path for that span is short-circuited the same way; the host primary analysis is preserved and the@stoken’s slot stays asL2|xxx.
Both fallbacks are graceful: the worker never crashes on an
unsupported secondary, and other utterances in the same file (or other
spans in the same utterance) that target supported languages continue
to receive real morphology. The mechanism is a partition step in
infer_batch (partition_groups_by_stanza_support) that splits each
batch’s language groups into “dispatchable” and “fallback” before
calling Stanza.
Example. German-English code-switching:
*EVA: was ich jetzt machen möchte ist film@s studies@s .
%mor: ... noun|film noun|study-Plur . ← default (L2 dispatch on)
%mor: ... L2|xxx L2|xxx . ← with --no-l2-morphotag
See also:
- L2 Morphotag: Per-Word Code-Switching Analysis , full design, merge algorithm, phrasal-verb diagram
Validation and repair for @s input
- Whole-utterance same-language runs written as
word@s word@s ...are rejected by pre-validation (E255). The canonical CHAT form is[- lang], andchatter debug fix-srewrites the qualifying whole-utterance pattern in place. - Explicit
@s:LANGwords still dispatch toLANGeven ifLANGis missing from@Languages, but validation emits warn-only E254 so the header drift is visible.chatter debug fix-sappends those missing explicit languages to@Languages. chatter debug fix-sis a true no-op on already-correct files: it only rewrites a file when it can prove a[- lang]conversion or@Languagesrepair is needed.
When fix-s will and will not rewrite
The rewrite predicate is conservative on purpose: an incorrect
[- LANG] insertion silently changes the language scope of an entire
utterance, including fillers and nonwords. The predicate only fires
when every word-bearing item in the utterance, words, fillers
(&~, &-, &+), nonwords, AND retraced material, carries an
explicit language attribution that resolves to the same target
language. A single unmarked token (e.g. a filler &~dang3 with no
@s: marker) blocks the rewrite, even if every other word would
qualify.
When the rewrite fires, fix-s clears bare @s shortcuts from
fillers and nonwords as well as from regular words. This is critical:
a bare @s resolves relative to the surrounding tier language, so
adding a [- LANG] precode without clearing the shortcut would flip
the filler’s resolved language to the precode target. (A previous
version of the tool skipped fillers and corrupted a corpus this way;
the fix-s predicate now walks all word-bearing items.)
Failure modes
morphotag fails fast on engine failures rather than emitting partial
output. When the Stanza worker reports a per-utterance error (model
runtime error, Stanza output parse failure, protocol violation), the
affected file is marked failed with a typed ItemErrors message
naming the first few offending items and the total count. Other
files in the same batch continue normally, one bad file does not
poison the rest (BA2-parity multi-file semantics). The output .cha
for a failed file is not written; there is no silent path where
the job appears successful but the %mor tier is missing.
Note: items in languages Stanza does not support (code-switches into
@s:<lang> for unsupported languages) are an intentional fallback,
not a failure, those items keep their L2|xxx placeholders in
%mor and the file still succeeds.
Related documentation
- Morphosyntax Pipeline, %mor/%gra format, Stanza model details
- Language Routing,
[- lang]precodes, auto-detection, per-word routing limits - L2 & Language Switching,
@sannotation, code-switching - Multi-Word Tokens, MWT expansion and
--retokenize - Command I/O: morphotag, I/O patterns and mutation behavior
- Command Flowcharts: morphotag, full architecture flowchart
- Incremental Processing,
--beforeflag mechanics
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
utseg
Status: Current Last updated: 2026-09-16 08:18 EDT
Re-segment utterance boundaries in an existing CHAT transcript. Text-only , no audio involved. The model selected per language is either a trained BERT per-word boundary classifier (eng / cmn,zho / yue) or, for other languages, Stanza constituency parsing where it is available.
transcribe already runs this same step at the end of every run
(with_utseg = true is the default in the transcribe pipeline). The
standalone utseg command is for already-existing corpora, files
transcribed elsewhere, hand-typed transcripts, or older BA2 output,
where utterances run on into long blobs and need to be split.
Quick start
# Re-segment a single file in place
batchalign3 utseg file.cha --lang eng
# Re-segment a corpus directory
batchalign3 utseg corpus/ -o segmented/ --lang eng
# Use the remote server
batchalign3 --server http://your-server:8001 utseg corpus/ -o out/ --lang eng
Pipeline
Each file is dispatched on its own, dispatch_utseg_job in
crates/batchalign/src/execution/utseg.rs calls
gateway.utseg_batch(&[one_file], lang) per file and writes that
file’s result to disk before starting the next. (This replaced an
earlier “pool everything across all files, batch through one worker,
write at end” pattern, which lost the entire run’s work on a daemon
redeploy mid-batch. The per-file shape limits a mid-run interruption
to losing only files currently in flight.) Per-file concurrency is
bounded by plan.kernel_plan.file_parallelism_hint (clamped to ≥ 1),
the same heuristic as fa_pipeline.rs.
flowchart TD
start([utseg invoked]) --> parse[Parse one file → AST]
parse --> collect[collect_payloads\nExtract word sequences per utterance]
collect --> worker[gateway.utseg_batch(&[file], lang)\n→ BERT assignments\nor Stanza constituency trees]
worker --> apply[Apply segmentation\nSplit/merge utterances at predicted boundaries]
apply --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| serialize
merge --> serialize[Serialize → .cha output]
serialize --> done([Write file's .cha; next file in pool])
Options
Path options
| Option | Meaning |
|---|---|
PATHS... | Input .cha files or directories |
-o, --output DIR | Output directory (omit to overwrite in place) |
--file-list FILE | Read input paths from a text file |
--in-place | Explicit in-place flag |
In-place rewrites with --file-list on a large corpus do appear
file-by-file as the run progresses (each file is written to disk
before the next file’s worker call starts). This is a deliberate
property of the per-file dispatch shape, interruption mid-run loses
only the files currently in flight, not the entire batch. Splitting
the file list into smaller chunks is therefore unnecessary for
incremental visibility, though it remains useful for managing memory
or scheduling.
utseg options
| Option | Default | Meaning |
|---|---|---|
--lang CODE | eng | 3-letter ISO language code |
--num-speakers N | 2 | Number of speakers. No short flag; -n was removed 2026-08-19. |
--merge-abbrev | off | Merge abbreviations in the output |
What changes in the .cha file
- Utterance boundaries (
*SPK:lines) are recomputed, utterances may be split or merged - Existing
%morand%gratiers on recomputed utterances will be invalidated; re-runmorphotagafterutsegif those tiers are needed - No audio is involved
The boundary model uses lexical context only. It does not receive audio pause,
energy, pitch, or diarization evidence. Internally BA3 validates one assignment
per input word and retains typed model evidence across the worker boundary.
The versioned --debug-dir evidence sidecars described in the transcribe guide
belong to transcribe’s distinct pre-CHAT and post-CHAT phases; standalone
utseg does not currently write those sidecars.
Language support
Per-language model selection is driven by UTSEG_BOUNDARY_MODELS in
crates/batchalign/src/model_manifest.rs, which also pins the exact revision
each one loads:
--lang | Model loaded | Source |
|---|---|---|
eng | talkbank/CHATUtterance-en (BERT per-word classifier) | TalkBank fine-tune |
cmn / zho (Mandarin) | talkbank/CHATUtterance-zh_CN (BERT) | TalkBank fine-tune |
yue (Cantonese) | PolyU-AngelChanLab/Cantonese-Utterance-Segmentation (BERT) | PolyU AngelChanLab |
| any other language | refused by default; opt in via --utseg-fallback-stanza | Stanza |
The English BERT is not applied cross-lingually, running utseg --lang fra does not load CHATUtterance-en. For any language without
a TalkBank BERT model in the table above, utseg refuses the
substitution by default, and the job is refused when it is planned:
the language and the fallback policy are both known before any work is
dispatched, so the run stops there with a message naming the language.
Nothing is written, and the input is not quietly copied through as if it
had been segmented. To permit the legacy Stanza constituency-parser
fallback (the same segmenter Batchalign 2 used for unsupported
languages), pass --utseg-fallback-stanza:
batchalign3 utseg corpus-fra/ --lang fra --utseg-fallback-stanza
Quality varies, Stanza ships constituency models for ~11 languages (en, de, es, it, pt, da, id, ja, tr, vi, zh-hans). The opt-in design prevents accidental quality regressions on unsupported languages.
See Utterance Segmentation for the algorithm details and the Stanza Capability Registry for the per-language processor availability table.
Provenance
Every file utseg writes records what segmented it in a
[fc-ba3 utseg | engine=... ; lang=... | ...] comment. engine= is one of:
| Value | Meaning |
|---|---|
<model id>@<revision> | The TalkBank boundary model, with the exact revision the worker loaded (for example talkbank/CHATUtterance-en@764ec3f...) |
stanza-constituency | The opt-in Stanza constituency fallback (--utseg-fallback-stanza) |
A boundary model is always written with its revision. It is loaded from a
pinned snapshot and the commit is read off the directory on disk, so a worker
that cannot say which revision it loaded refuses the job rather than reporting
a bare id. Files written before that pin landed may carry an id with no
revision; re-running utseg over them records the full identity.
Several sources on one file are joined with + in text order. There is no
placeholder: a worker that returns boundaries without naming what produced them
leaves the file with no comment at all, and the run says so.
Files segmented by a build before 2026-09-15 carry no comment, because the
standalone command wrote none; re-running utseg over them adds one. See
Processing Provenance.
Failure modes
utseg fails fast on engine failures rather than emitting partial
output. When the BERT or Stanza worker reports a per-utterance error
(model runtime error, malformed constituency tree, protocol
violation), the affected file is marked failed with a typed
ItemErrors message naming the first few offending items and the
total count. Per-file dispatch (utseg-specific, BA3 utseg
deliberately does NOT cross-file-batch) means one failing file has
no effect on the next file in a multi-file run. The output .cha
for a failed file is not written.
Related documentation
- Utterance Segmentation, algorithm and model details
- Stanza Capability Registry, which languages support constituency parsing
- Command I/O: utseg, I/O patterns and mutation behavior
- Command Flowcharts: utseg, full architecture flowchart
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
translate
Status: Current Last updated: 2026-09-15 20:20 EDT
Add English translations to non-English CHAT transcripts by injecting a
%xtra tier after each utterance. Text-only, no audio involved.
What gets translated
What was spoken. Each utterance is sent as every word the speaker produced, in
order, followed by the utterance’s terminator, so a question is translated as a
question. Retraced words (<I like> [/]) and filled pauses (&-um, sent as
um) are included, which is what batchalign2 did; a word the transcriber
replaced (hafta [: have to]) is sent as the replacement.
Left out, because they are not words a translator can read: 0-prefixed
omissions (recorded as not said), &~ nonwords, &+ fragments, and the
untranscribed markers xxx / yyy / www. An utterance that produced no words
at all is not sent and gets no %xtra tier.
Only punctuation an ordinary reader would recognise travels with the words: the
comma, and a terminator that is a period, question mark or exclamation mark
(the question-bearing CHAT terminators +/?, +!?, +//? and +..? are sent
as a question mark). CHAT-only notation is not sent at all, so +..., +/.,
+//., the tag marker and the vocative never reach the engine as text to
translate.
For languages written in Han script (zho, cmn, yue, wuu, nan, hak)
the words are joined without spaces and the period is written as the ideographic
full stop; the translation is converted back on the way in. This follows the
writing system, not a list of two codes, so Mandarin tagged cmn is handled
like zho.
Before 2026-09-15 batchalign3 sent only the %mor-domain words: no retraces, no
filled pauses and no terminator. Files translated by an earlier build were
translated from a different source text; re-run translate over them to get
translations of what was actually said.
Engine
Five backends are available:
- Google Translate (
googletrans), calls the public Google Translate endpoint. Requires outbound reachability totranslate.google.com; unsuitable for hosts behind the Great Firewall unless a VPN is active. Rate-limited to one item per 1.5 seconds inside the worker. Default. - Tencent Cloud TMT (
tencent), China-friendly cloud API. Strong quality on Mandarin (zh→en); produces correct “Hello world” for你好世界where NLLB renders it “Good day”. Does NOT support Cantonese (yue→en); route Cantonese throughaliyun(cloud) ornllb(self-hosted) instead. Requires CAM credentials withtmt:TextTranslatepermission in~/.batchalign.ini[asr]section (engine.tencent.id/key/region), or via theBATCHALIGN_TENCENT_{ID,KEY,REGION}environment variables that the Rust control plane uses to inject fleet-managed credentials. Free tier: 5M characters/month; throttled to 5 QPS in-worker (0.2 s/item). - Aliyun Machine Translation (
aliyun), China-friendly cloud API via Alibaba Cloud’salimtGeneral service. Supports Cantonese (yue→en): the canonical cloud option for HK material, where Tencent TMT does not list Cantonese as a source language. Requires Aliyun access-key credentials in~/.batchalign.ini[asr]section (engine.aliyun.ak_id/ak_secret, shared with the Aliyun ASR backend), or via theBATCHALIGN_ALIYUN_AK_{ID,SECRET}environment variables. Region is pinned tocn-hangzhou(Aliyun MT exposes one global endpoint atmt.aliyuncs.com, so the region only affects request signing). Quotas and pricing per Aliyun MT service terms. - Meta NLLB-200-distilled-1.3B (
nllb), runs locally in the Python worker. Model downloaded from HuggingFace on first use (~5 GB) and cached thereafter; no outbound network at inference time. Best self-hosted fallback: handles Cantonese first-class; for Mandarin short greetings prefertencent. Long-form CJK is excellent. Runs unthrottled. - Meta SeamlessM4T (
seamless), runs locally in the Python worker. BA2-inherited fallback. Empirical 2026-05-23 comparison found short-CJK quality is poor and the model hallucinates on empty inputs; prefernllbortencentfor new work. Retained for back-compat.
Select with --translate-engine; batchalign3 translate --help lists every
accepted value, derived from the engine enum, and rejects anything else while
parsing.
Default is Google. Operators on hosts where Google Translate is
unreachable pass --translate-engine tencent (best Mandarin),
--translate-engine aliyun (Cantonese-capable cloud option), or
--translate-engine nllb (self-hosted, handles Cantonese, no cloud
account required) explicitly per invocation (a shell alias is the right
place to make that persistent for a given user), there is no per-host
config file knob for engine selection, by design.
For symmetry with how ASR and FA engines are selected, the shared
--engine-overrides '{"translate":"<engine>"}' global flag also
works and takes precedence over --translate-engine.
Migrating from BA2
BA2 read the translation engine from ~/.batchalign.ini:
[translate]
engine = seamless_translate
BA3 does not honor that key. The replacement is the explicit CLI flag
--translate-engine seamless (or the shared
--engine-overrides '{"translate":"seamless"}'). If you previously
relied on the INI entry for routine runs, drop the line from
~/.batchalign.ini and add the flag to whatever wrapper or alias you
invoke batchalign3 translate through.
Re-running on already-translated files
Running translate on a file that already has %xtra tiers will
overwrite them with fresh output. This is a deliberate change from
batchalign2, which preserved the first translation and skipped any
utterance that already had one. If you want to keep prior translations,
copy the file first or filter your inputs.
Quick start
# Translate a single file in place, source language is read from @Languages
batchalign3 translate file.cha
# Translate a corpus directory
batchalign3 translate corpus/ -o translated/
# Use the remote server
batchalign3 --server http://your-server:8001 translate corpus/ -o out/
translate has no --lang flag. Source language for each file is
read from that file’s own @Languages: header. Translation target is
fixed to English. To “override” the source language, edit the file’s
@Languages: line.
Pipeline
flowchart TD
start([translate invoked]) --> parse[Parse all files → ASTs]
parse --> collect[collect_payloads\nSpoken words and terminator per utterance]
collect --> worker[execute_v2(task="translate")\nprepared_text batch → raw translations]
worker --> inject[inject %xtra tiers with translated text]
inject --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| serialize
merge --> serialize[Serialize → .cha output]
serialize --> done([Output .cha files])
Translation results are not cached: the CacheTaskName enum (at
crates/batchalign/src/chat_ops/cache_key.rs:58) only has
ForcedAlignment and UtrAsr variants, and translate.rs does not
call cache.put. Repeated translate runs on the same input
re-invoke the worker.
Options
Path options
| Option | Meaning |
|---|---|
PATHS... | Input .cha files or directories |
-o, --output DIR | Output directory (omit to overwrite in place) |
--file-list FILE | Read input paths from a text file |
--in-place | Explicit in-place flag |
translate options
| Option | Default | Meaning |
|---|---|---|
--translate-engine google|tencent|aliyun|nllb|seamless | google | Pick the translation engine for this invocation. tencent is best for Mandarin (requires CAM credentials, no Cantonese support); aliyun is the Cantonese-capable cloud option (requires Aliyun access keys); nllb is the recommended self-hosted fallback and handles Cantonese; seamless is BA2-inherited and retained for back-compat. |
--merge-abbrev / --no-merge-abbrev | off | Merge abbreviations in the translated output |
Provenance
Every file translate writes records the engines that produced its translations
in a [fc-ba3 translate | engine=... ; lang=... | ...] comment. Each engine is
the one the worker named on the translation it returned, so a file where
nothing was translated (for example, only blank utterances) gets no comment and
the run says why. Files translated by a build before 2026-09-15 carry no
comment at all, because the batch path wrote none; re-running translate over
them adds one. See Processing Provenance.
Failure modes
batchalign3 translate fails fast on engine failures rather than emitting
partial output. When the worker reports a per-utterance error (engine
network failure, GFW block on Google, rate-limit exhaustion, model
runtime error), the affected file is marked failed with a typed
ItemErrors message naming the first few offending items and the
total count. Other files in the same batch continue normally, one
bad file does not poison the rest (BA2-parity multi-file semantics).
The output .cha for a failed file is not written. There is no
silent path where a job appears successful but produced a .cha
with missing %xtra tiers, if a tier is missing, the job result
will say so.
Common cases
| Situation | What happens |
|---|---|
| Google Translate unreachable (GFW block, network outage, DNS failure) | File marked failed with translate failed for N item(s): item 0: Translation failed: ConnectionResetError .... Use --translate-engine tencent (best Mandarin quality, requires CAM credentials) or --translate-engine nllb (self-hosted, handles Cantonese). |
| Rate-limit (429) on one or more items | File marked failed citing the 429 message verbatim. Retry; if persistent, switch to --translate-engine tencent or --translate-engine nllb or split the workload. |
| Self-hosted model first-download (HuggingFace) fails | File marked failed with the underlying HF error. If on a host where the default HF endpoint is slow, set HF_ENDPOINT=https://hf-mirror.com before the worker starts. Applies to both nllb (~5 GB) and seamless (~1.2 GB). |
| Tencent CAM credentials missing / wrong | File marked failed citing ~/.batchalign.ini parse error or AuthFailure.UnauthorizedOperation. Ensure engine.tencent.id/key/region are populated and the CAM user has tmt:TextTranslate policy attached. The TMT product itself must also be “opened” at the Tencent Cloud account level (FailedOperation.UserNotRegistered indicates this is missing). |
Tencent yue→en request | Raises ValueError: Tencent TMT does not support source language 'yue'; use --translate-engine aliyun (cloud, supports Cantonese) or --translate-engine nllb (self-hosted local model). Switch the Cantonese run to aliyun or nllb. |
| Aliyun MT credentials missing / wrong | File marked failed citing ~/.batchalign.ini parse error or an Aliyun SDK ClientException/ServerException. Ensure engine.aliyun.ak_id / ak_secret are populated (same keys the Aliyun ASR backend uses); the Aliyun MT service must also be activated in the Alibaba Cloud console for the access key’s account. |
| Aliyun MT unmapped source language | Raises ValueError: Aliyun MT does not have a mapped source language for '<iso>'; use --translate-engine nllb for this language. Use nllb for the unmapped language or extend _ISO_639_3_TO_ALIYUN_LANG in batchalign/worker/_model_loading/translation.py. |
| Engine returns an empty translation for an utterance (nothing, or only the punctuation batchalign3 sent) | File marked failed with translate failed for N item(s): item 0: <engine> returned an empty translation; try a different --translate-engine or options and run the file again. The file is not written. The verdict is terminal, because the same request gets the same answer from the same engine: the remedy is another engine or different options, not a retry. Before 2026-09-15 the utterance was silently left without a %xtra tier. |
| googletrans library import error in a stripped venv | Worker startup fails (loud), not a per-job failure. |
What changes in the .cha file
- A
%xtra:tier is added after each utterance that produced words, containing the English translation - An utterance the engine returned nothing usable for does not get an empty tier: the file fails instead, and nothing is written
- All other tiers (
%mor,%gra,%wor) are preserved unchanged - No audio is involved
Related documentation
- Command I/O: translate, I/O patterns and mutation behavior
- Command Flowcharts: translate, full architecture flowchart
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
coref
Status: Current Last updated: 2026-09-15 20:20 EDT
Add sparse coreference annotation tiers (%xcoref) to CHAT transcripts.
English-only. Uses full document context, all utterances in the file are
processed together as a single document. Text-only, no audio involved.
Quick start
# Annotate a single file in place
batchalign3 coref file.cha
# Annotate a corpus directory
batchalign3 coref corpus/ -o coref-output/
# Use the remote server
batchalign3 --server http://your-server:8001 coref corpus/ -o out/
Pipeline
coref does not use the utterance cache. Note that no text-NLP command
caches either (CacheTaskName at
crates/batchalign/src/chat_ops/cache_key.rs:58 covers only
ForcedAlignment and UtrAsr), so this is consistent with
morphotag/utseg/translate. What’s specific to coref is the
reason: coreference chains span the entire document, so a
per-utterance cache key would be unsound even if the infrastructure
existed, the same utterance has different coreference in different
document contexts.
flowchart TD
start([coref invoked]) --> parse[Parse all files → ASTs]
parse --> collect[collect_payloads\nExtract sentences: full document context]
collect --> worker[execute_v2(task="coref")\nprepared_text batch → structured chain refs]
worker --> inject[inject %xcoref tiers, sparse\nOnly utterances with coreferent mentions]
inject --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| serialize
merge --> serialize[Serialize → .cha output]
serialize --> done([Output .cha files])
style collect fill:#ffd,stroke:#aa0
note1[No caching: full-document context\nmakes per-utterance keys meaningless]
collect --- note1
Options
Path options
| Option | Meaning |
|---|---|
PATHS... | Input .cha files or directories |
-o, --output DIR | Output directory (omit to overwrite in place) |
--file-list FILE | Read input paths from a text file |
--in-place | Explicit in-place flag |
coref options
| Option | Default | Meaning |
|---|---|---|
--merge-abbrev / --no-merge-abbrev | off | Merge abbreviations in the output |
coref has no --lang flag. It is English-only, and each file’s
English-ness is read from that file’s own @Languages header; a file with no
header is treated as English. Non-English files pass through unchanged
(Stanza’s coreference model is English-only).
Passing a job-level language is refused at submission rather than ignored, so nothing can record a language coref did not use.
What changes in the .cha file
%xcoref:tiers are added sparsely, only on utterances that contain mentions participating in a coreference chain- All other tiers are preserved unchanged
- No audio is involved
Gotchas
English-only. Non-English files pass through without modification. Stanza’s coreference model is only available for English.
No caching. Re-running coref always calls the worker. This is
true of every text-NLP command, morphotag, utseg, and translate
also re-run from scratch each time, so this is not a coref-specific
slowdown vs the others. What is specific to coref is the
document-level scope: even if a per-task text-NLP cache were added
later, coref’s cache key would have to include the entire document
because coreference depends on full context.
Best suited for local or direct-server execution. coref is a
document-level workflow that benefits from locality. It is not an interactive
remote-server command in the same way as align or transcribe.
Provenance
Every English file coref resolves records the engine that produced its chains
in a [fc-ba3 coref | engine=... ; lang=eng | ...] comment. The engine is the
one the worker named on the result it returned, so a file with nothing to
resolve gets no comment and the run says why. A non-English file passes through
with nothing added, comment included. Files processed by a build before
2026-09-15 carry no comment, because the batch path wrote none; re-running
coref over them adds one. See Processing Provenance.
Failure modes
coref fails fast on engine failures rather than emitting silent
no-coref output. When the Stanza coref worker reports a per-file
error (model runtime error, protocol violation, batch IPC failure),
the affected file is marked failed with a typed ItemErrors message
carrying the engine error verbatim. A batch-level coref failure
(worker spawn / IPC) marks every English-eligible file in the same
batch as failed; non-eligible files (dummy or non-English) pass
through unchanged. The output .cha for a failed file is not
written, there is no path where the file appears successful but
the %xcoref tier is silently missing.
Related documentation
- Command I/O: coref, I/O patterns and mutation behavior
- Command Flowcharts: coref, full architecture flowchart
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
compare
Status: Current Last updated: 2026-05-02 02:30 EDT
Compare CHAT transcripts against gold-standard references to compute word
error rate (WER) and produce annotated output. For each primary .cha input,
the command first looks for a FILE.gold.cha companion in the same directory.
If that is absent, it falls back to template.gold.cha in the same directory.
Outputs two files per input:
- A projected reference
.cha: the gold transcript with%xsrep/%xsmorannotation tiers showing substitutions, insertions, and deletions - A
.compare.csvsidecar with aggregate and per-POS metrics
Text-only, no audio involved.
Quick start
# Compare a corpus (each FILE.cha must have FILE.gold.cha alongside it)
batchalign3 compare corpus/ -o compared/
# Override language
batchalign3 compare corpus/ -o out/ --lang eng
# Use the remote server
batchalign3 --server http://your-server:8001 compare corpus/ -o out/
Pipeline
flowchart TD
start([compare invoked]) --> discover[Discover primary .cha files\nskip *.gold.cha companions]
discover --> pair[Pair FILE.cha with FILE.gold.cha]
pair --> found{Gold companion or template found?}
found -->|No| fail[Report file error]
found -->|Yes| morph[process_morphosyntax\nmain transcript only\n→ validated document, carried as a proof]
pair --> parse_gold[parse_lenient raw gold\n→ gold AST]
morph --> bundle[compare()\nconform + local window search + local DP\nComparisonBundle: main view, gold view,\nstructural word matches, metrics]
parse_gold --> bundle
bundle --> released[materialize_released\nproject_gold_structurally]
bundle --> internal_main[materialize_main_annotated\ninternal/benchmark\ninject %xsrep / %xsmor on main]
released --> safe{Exact structural match?}
safe -->|Yes| copy[Copy %mor / %gra / %wor]
safe -->|No, full gold coverage| mor_only[Project %mor only]
safe -->|No, partial or unsafe| keep[Keep gold dependent tiers unchanged]
copy --> goldannot[Inject %xsrep / %xsmor on gold]
mor_only --> goldannot
keep --> goldannot
goldannot --> merge_check
internal_main --> internal_done([Internal main-annotated view])
merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| metrics[Write .compare.csv]
merge --> metrics
metrics --> done([Output .cha + .compare.csv])
Options
Path options
| Option | Meaning |
|---|---|
PATHS... | Input .cha files or directories (.gold.cha companions are auto-skipped) |
-o, --output DIR | Output directory |
--file-list FILE | Read input paths from a text file |
--in-place | Explicit in-place flag |
compare options
| Option | Default | Meaning |
|---|---|---|
--lang CODE | eng | 3-letter ISO language code |
--num-speakers N | 2 | Number of speakers |
--merge-abbrev | off | Merge abbreviations in the output |
Use --override-media-cache (global flag) when you need to force fresh
morphosyntax on the main transcript before scoring.
Gold file convention
For each FILE.cha input, compare first looks for FILE.gold.cha in the
same directory. If that companion is absent, it falls back to
template.gold.cha in the same directory. Files ending in .gold.cha are
automatically treated as companions and skipped as primary inputs. If neither
gold file is found, the file is reported as failed.
Output: %xsrep and %xsmor tiers
The projected reference .cha output uses:
%xsrep:: word-level comparison: unchanged words,+wordinsertions in gold,-worddeletions from hypothesis%xsmor:: same alignment with POS tags (NOUN,+ADJ,-?)
The output is the projected reference transcript, not the main hypothesis. The gold transcript’s structure is preserved; morphosyntactic information from the main transcript is projected onto it structurally where safe.
Where the part of speech comes from
compare morphotags the main transcript itself and reads the gold companion off
disk as it is, so the usual pairing is a tagged main side and an untagged gold
one. Which transcript a tag is read from is decided once per file, from whether
the gold companion carries %mor at all:
| Gold companion | Matches report | Insertions report | Deletions report |
|---|---|---|---|
Carries %mor | the gold tag | the main tag | the gold tag |
Carries no %mor | the main tag | the main tag | ? |
With a tagged gold companion the gold tag is what a reviewer needs: when the two transcripts disagree about a word they both contain, the gold-standard tag is the point of running compare.
With an untagged one there is no gold tag to report. A match means both sides
hold the same word, and the main side was morphotagged by this very run, so its
tag describes that word and is the only tag in existence for it. A deletion is a
gold word the main transcript does not contain, so nothing tagged it anywhere
and it reports ?.
This differs from batchalign2 on purpose. batchalign2 attributes the gold
form’s tag to every match and falls back to the literal ? per form, with no
notion of whether the gold side is tagged at all. Against the untagged gold
companion that is the normal case, that makes every matched word ? and
collapses the whole per-POS breakdown in .compare.csv into a single ? row.
BA3 reports the tag it actually has.
One consequence is worth knowing. Punctuation is excluded from the comparison by
its surface form or by a PUNCT tag, and the second test can only fire on a
document that has tags. On an untagged gold companion, a token that is
punctuation only by its tag is therefore aligned as an ordinary word. Nothing
can recover a tag from a document that has none; the tagged-versus-untagged
decision is what makes that a stated property of an untagged companion rather
than an accident.
Output: .compare.csv
A companion .compare.csv is written alongside each output .cha file. It
contains:
- Aggregate metrics row: WER, accuracy, match/insertion/deletion counts, total words
- Per-POS breakdown rows
Gotchas
compare outputs the gold transcript, not the hypothesis. The released
command materializes the projected-reference view. The main-annotated view
(hypothesis CHAT with comparison annotations) is an internal path used by
benchmark.
Gold files are never modified. Only the primary .cha and the output
.cha and .compare.csv are written.
The main transcript is never re-read from text. compare morphotags the main transcript and then compares the document that morphotag produced, which is the document its own validation gate judged. The main side used to be serialized and parsed again with the lenient parser before being compared, so the compared document was the parser’s recovery of those bytes and a parse failure on that path was a log line rather than a failure. The gold companion is still parsed leniently, because it is read off disk as it is and nothing vouched for it.
Related documentation
- Command I/O: compare, I/O patterns, gold file convention, output shapes
- Command Flowcharts: compare, full architecture flowchart
- Benchmarks, WER metrics and evaluation methodology
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
compare-runs
Last modified: 2026-08-03 12:42 EDT
compare-runs is an offline comparator for two immutable, already-produced
artifact sets. It does not run Batchalign, contact a server, or treat either
side as gold. The existing compare command remains the
primary-versus-.gold.cha workflow.
Author manifests
batchalign3 compare-runs manifest machine \
--artifacts ours/ --output ours.manifest.json --run-id ours-2026 \
--source-id session-17 --implementation batchalign3 \
--command transcribe --build git-identity
batchalign3 compare-runs manifest human \
--artifacts review/ --output review.manifest.json --run-id review-2026 \
--source-id session-17 --protocol iisrp-v1 --cohort reviewed
Manifests hash every regular file with BLAKE3. Roots must contain regular files and may not contain symlinks; an existing identical manifest is a no-op, while conflicting output is rejected.
Plan and execution
Paths in the TOML plan are relative to the plan file. Artifact pair paths are
relative to their verified roots. A run-wide speaker_map may be overridden
per pair; a partial map is valid and leaves omitted speakers visibly
unmatched. Pairs can be held out of aggregates with a required reason.
schema_version = 1
pairing = "same_source_chat"
output = "comparison-output"
exclusion_tokens = ["xxx", "yyy"]
[left]
manifest = "ours.manifest.json"
artifacts = "ours"
[right]
manifest = "review.manifest.json"
artifacts = "review"
[[pairs]]
left = "session.cha"
right = "session.cha"
[pairs.aggregate]
status = "included"
Run one typed mode:
batchalign3 compare-runs transcribe --plan comparison.toml
batchalign3 compare-runs morphotag --plan comparison.toml
batchalign3 compare-runs align --plan comparison.toml
Transcription reports agreement WER/cWER, never accuracy, and count excluded tokens separately. Morphotag reports tokenization, lemma, POS, feature-set, clitic/chunk, dependency-head, and relation differences. Alignment first requires identical normalized token identities, then reports each token’s timing state, absolute deltas, distributions, and independent order violations.
Alignment timing states
Every alignment token carries a timing STATE rather than a timing that may be absent, so a token with no timing says why it has none:
| State | Meaning |
|---|---|
timed | the %wor tier was corroborated and times this word; start_ms and end_ms sit beside the state |
unaligned | the tier was corroborated and simply carries no bullet for this word |
no_wor_tier | the utterance has no %wor tier, so no word in it is timed |
wor_tier_drifted | the tier’s slot count disagrees with the main tier’s (wor_slots, main_words), which is what an edit made after alignment ran looks like |
wor_tier_uncorroborated | the counts agree but mismatches display tokens do not match the words they would time, so the bullets describe a different reading of the utterance |
The three failure states are not interchangeable: a missing tier means alignment never ran, a drifted one means the transcript changed after it ran, and an uncorroborated one means the tier belongs to different words than the ones beside it. They were one empty value until 2026-09-16.
summary.csv carries left_timing_state and right_timing_state beside the
millisecond columns. A delta is reported only where both sides are timed.
Results are written under OUTPUT/runs/COMPARISON_ID/: complete
report.json, summary.csv, content-addressed pairs/PAIR_ID.json, and
evidence-only review/PAIR_ID.json. Pair caches are reused by default;
--recompute regenerates them. The algorithm version is part of the
comparison identity, so a change to what a comparison computes lands under a
new COMPARISON_ID and rows cached by an earlier version are never reused. Unpairable or unparsable pairs are recorded,
all pairs continue, and the command exits 2 after materialization. Differences
are evidence for human review, not automatic winner selection or golden-fixture
creation.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
benchmark
Status: Current Last updated: 2026-09-07 07:04 EDT
Transcribe audio via ASR and evaluate word error rate (WER) against gold
.cha transcripts in the same directory. A composite command that runs
transcribe followed by compare internally.
Outputs per audio file:
- A hypothesis
.chatranscript - A
.compare.csvwith WER metrics
Quick start
# Benchmark a directory of audio files against gold .cha companions
batchalign3 benchmark input/ -o output/ --lang eng
# Use a specific ASR engine
batchalign3 benchmark input/ -o output/ --lang eng --asr-engine whisper
# Use the remote server
batchalign3 --server http://your-server:8001 benchmark input/ -o output/ --lang eng
Pipeline
flowchart TD
start([benchmark invoked]) --> resolve[Resolve audio file + companion gold .cha]
resolve --> transcribe[Rust transcribe workflow\nProduce hypothesis CHAT]
transcribe --> compare[Rust compare workflow\nDP alignment + WER metrics]
compare --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[Merge abbreviations in hypothesis CHAT output]
merge_check -->|No| output
merge --> output[Write hypothesis .cha + .compare.csv]
output --> done([Output results])
Options
Path options
| Option | Meaning |
|---|---|
PATHS... | Input audio files (.mp3, .mp4, .wav) or directories |
-o, --output DIR | Output directory |
benchmark options
| Option | Default | Meaning |
|---|---|---|
--lang CODE | eng | 3-letter ISO language code |
--num-speakers N | 2 | Number of speakers. No short flag; -n was removed 2026-08-19. |
--asr-engine NAME | rev | ASR engine. --help prints the list, which is generated from the engine set. |
--asr-engine-custom NAME | : | Deprecated alias for --asr-engine, still honoured, hidden from --help. |
--wor / --nowor | --nowor | Include or suppress the %wor tier in the hypothesis output |
--merge-abbrev | off | Merge abbreviations in the output |
--bank NAME | : | Server media bank name from server.yaml media_mappings (server-backed runs only) |
--subdir PATH | : | Subdirectory under the selected --bank to scope the run |
Gold file convention
For each audio file FILE.mp3, the gold companion must be FILE.cha in the
same directory. If the gold file is missing, the audio file is reported as
failed.
Pass only the audio. The gold transcript is found for you, by taking each
recording’s own path and replacing the extension, so it is not an input you
submit. Handing a .cha file to benchmark as a source is refused when the
job is submitted, with a message naming the file:
command 'benchmark' takes media recordings as its sources, but "session.cha" is a CHAT transcript. Submit only the recording; benchmark finds each recording's gold transcript beside it by replacing the extension, so the gold must not be passed as an input.
This refusal is specific to benchmark, because benchmark is the command that
derives a gold companion from each source. It does not apply to transcribe,
opensmile, avqi or diarize.
This used to be accepted. Every submitted source became a recording to
transcribe, so the transcript became a work unit whose “audio” was the
transcript and whose gold was itself, and it was passed to ffmpeg to be decoded
as a recording. Pointing benchmark at a directory is unaffected: directory
expansion selects media by extension and never picks up the golds.
What gets created
FILE.cha: hypothesis transcript produced by ASRFILE.compare.csv: WER metrics: aggregate row plus per-POS breakdown
The hypothesis .cha contains a main-annotated view (unlike compare, which
outputs the projected reference). The %xsrep and %xsmor tiers are
injected on the hypothesis utterances showing how the hypothesis deviates from
the gold.
Gotchas
benchmark prefers the local daemon when auto_daemon is enabled. Use
explicit --server to override.
Gold files are not passed through the network with --server. The server
must be able to find the gold .cha files on its own visible filesystem
alongside the audio.
Related documentation
- Benchmarks, WER metrics and evaluation methodology
- compare, standalone transcript comparison
- transcribe, ASR transcription pipeline
- Command I/O: benchmark, I/O patterns
- Command Flowcharts: benchmark, full architecture flowchart
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
opensmile
Status: Current Last updated: 2026-05-02 07:30 EDT
Extract acoustic features from audio files using openSMILE. Produces
.opensmile.csv output, not CHAT. This is the only processing command
that does not produce .cha output.
Uses positional INPUT_DIR OUTPUT_DIR arguments (not the shared
PATHS... -o OUTPUT form used by align, morphotag, etc.).
Quick start
# Extract default eGeMAPSv02 features from all audio in a directory
batchalign3 opensmile input_dir/ output_dir/
# Use a different feature set
batchalign3 opensmile input_dir/ output_dir/ --feature-set ComParE_2016
# Use the remote server
batchalign3 --server http://your-server:8001 opensmile input_dir/ output_dir/
Pipeline
flowchart TD
start([opensmile invoked]) --> resolve[Resolve audio files]
resolve --> prep[Rust audio prep\nprepare mono PCM artifact]
prep --> feature_check{--feature-set?}
feature_check -->|eGeMAPSv02| egemaps[eGeMAPSv02 features\n88 acoustic descriptors]
feature_check -->|ComParE_2016| compare[ComParE_2016 features\n6,373 acoustic descriptors]
feature_check -->|Custom| custom[Custom feature set name]
egemaps --> worker
compare --> worker
custom --> worker
worker["execute_v2(task='opensmile') → Python worker\nExtracts acoustic features from prepared audio"]
worker --> output[Write CSV output\nContent-type: csv]
output --> done([Output .opensmile.csv files])
Options
Positional arguments
| Argument | Meaning |
|---|---|
INPUT_DIR | Directory containing audio files |
OUTPUT_DIR | Directory for output .opensmile.csv files |
opensmile options
| Option | Default | Meaning |
|---|---|---|
--feature-set SET | eGeMAPSv02 | Feature set: eGeMAPSv02, eGeMAPSv01b, GeMAPSv01b, or ComParE_2016 |
--lang CODE | eng | 3-letter ISO language code |
--bank NAME | : | Server media bank name from server.yaml media_mappings (server-backed runs only) |
--subdir PATH | : | Subdirectory under the selected --bank to scope the run |
Output format
Each audio file produces FILE.opensmile.csv with feature names as column
headers and one row per file (or one row per frame for frame-level sets).
BA3 uses a row-oriented CSV (feature names as columns), which differs from
BA2’s transposed feature-per-row export.
Related documentation
- Command I/O: opensmile, I/O patterns
- Command Flowcharts: opensmile, full architecture flowchart
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
avqi
Status: Current Last updated: 2026-04-08 07:40 EDT
Calculate the Acoustic Voice Quality Index (AVQI) from paired audio files.
Requires paired continuous speech (.cs.*) and sustained vowel
(.sv.*) recordings per speaker. Produces .avqi.txt output.
Uses positional INPUT_DIR OUTPUT_DIR arguments (not the shared
PATHS... -o OUTPUT form used by align, morphotag, etc.).
Quick start
# Calculate AVQI for all paired audio in a directory
batchalign3 avqi input_dir/ output_dir/
# Use the remote server
batchalign3 --server http://your-server:8001 avqi input_dir/ output_dir/
Pipeline
flowchart TD
start([avqi invoked]) --> resolve[Resolve paired audio files\n.cs.wav + .sv.wav per speaker]
resolve --> prep[Rust audio prep\nprepare CS + SV PCM artifacts]
prep --> worker["execute_v2(task='avqi') → Python worker\nparselmouth + torchaudio analysis"]
worker --> output[Write AVQI results\nHarmonics-to-noise ratio, jitter, shimmer, etc.]
output --> done([Output .avqi.txt files])
Options
Positional arguments
| Argument | Meaning |
|---|---|
INPUT_DIR | Directory containing paired .cs.* and .sv.* audio files |
OUTPUT_DIR | Directory for output .avqi.txt files |
avqi options
| Option | Default | Meaning |
|---|---|---|
--lang CODE | eng | 3-letter ISO language code |
Input file naming convention
For each speaker, place two files in INPUT_DIR:
SPEAKER.cs.wav(or.mp3,.mp4), continuous speech sampleSPEAKER.sv.wav: sustained vowel sample
The pair is matched by the common stem before .cs. / .sv.. Missing
partners are reported as an error.
Output format
Each speaker pair produces SPEAKER.avqi.txt with AVQI metrics including:
harmonics-to-noise ratio (HNR), jitter, shimmer, and the composite AVQI
score. BA3 uses the same metrics and text format as BA2 while moving audio
preprocessing behind the typed media-analysis worker boundary.
Gotchas
avqi prefers the local daemon when auto_daemon is enabled. Use
explicit --server to override.
Both files must be present. A missing .cs.* or .sv.* partner causes
the whole pair to fail.
Related documentation
- Command I/O: avqi, I/O patterns
- Command Flowcharts: avqi, full architecture flowchart
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
diarize
Status: Current Last updated: 2026-09-16 03:36 EDT
Detect speaker turns in audio (speaker diarization) without transcribing.
Each input media file produces a speaker-turns JSON artifact naming which
anonymous voice track speaks during which media span. The output schema is
exactly what chatter rediarize --turns consumes, so the two commands
compose into a speaker-attribution repair pipeline: batchalign3 supplies
anonymous acoustic tracks, and chatter projects those tracks onto the
transcript. Neither command can infer that an anonymous track is the child,
mother, investigator, or another semantic CHAT role without additional role
evidence.
The standalone command defaults to the local TalkBank-pinned Pyannote
pipeline. Pass --speaker-engine pyannote-ai to use the paid pyannoteAI
Precision-2 service instead. This is not merely an alternate spelling of
transcribe --diarization enabled: the integrated transcription path defaults
to the paid pyannoteAI Precision-2 cloud service and applies its speaker
evidence before utterance segmentation and CHAT construction. Use standalone
diarize when you need reusable acoustic turns for an existing transcript;
use integrated transcription when creating a new transcript from audio.
Quick start
# Turns JSON for every audio file in a directory (auto-detect speaker count)
batchalign3 diarize recordings/ -o turns/
# One file, with a known speaker count
batchalign3 diarize session.mp3 -o turns/ --num-speakers 2
# Explicitly use paid pyannoteAI Precision-2 (requires its API key)
batchalign3 diarize session.mp3 -o turns/ --speaker-engine pyannote-ai
# Then repair a transcript's speaker attribution with chatter
chatter rediarize session.cha --turns turns/session.turns.json
Pipeline
flowchart TD
start([diarize invoked]) --> resolve[Resolve input media]
resolve --> source[Admit exact inference-source bytes\nand versioned PCM-preparation recipe]
source --> key[Hash source bytes + backend +\nspeaker-count semantics + model revision]
key --> derived{Validated derived-turn cache}
derived -->|hit| map
derived -->|miss| raw{Validated raw-evidence cache}
raw -->|hit| normalize[Versioned local normalization]
raw -->|miss / forced refresh| pcm[Rust prepares canonical\nmono 16 kHz float32 PCM]
pcm --> backend{--speaker-engine}
backend -->|pyannote default| local["execute_v2(task='speaker')\nlocal TalkBank-pinned Pyannote"]
backend -->|pyannote-ai| cloud["execute_v2(task='speaker')\npaid pyannoteAI Precision-2"]
backend -->|nemo| nemo["execute_v2(task='speaker')\nlocal NeMo"]
local --> commit[Validate + durably commit raw evidence]
cloud --> commit
nemo --> commit
commit --> normalize
normalize --> map[Map diarizer labels to anonymous tracks\nsorted labels → PAR0..PARn]
map --> output([Write .turns.json per input])
Options
| Option | Default | Meaning |
|---|---|---|
PATHS... | Input media files and/or directories (.mp3, .mp4, .wav) | |
-o, --output DIR | Output directory for .turns.json artifacts | |
--num-speakers N | auto-detect | Expected speaker count, 2 or more. Omit unless known: auto-detection is the point of the engine. A count of 1 is refused when the arguments are parsed |
--speaker-engine {pyannote,pyannote-ai,nemo} | pyannote | Local TalkBank Pyannote, paid pyannoteAI Precision-2, or local NeMo |
--lang CODE | eng | 3-letter ISO code for worker-pool selection only; diarization itself is language-independent |
Output format
For input session.mp3, the artifact is session.turns.json:
{
"source": "batchalign3:pyannote",
"turns": [
{ "start_ms": 1887, "end_ms": 2039, "track": "PAR0" },
{ "start_ms": 2039, "end_ms": 4672, "track": "PAR1" }
]
}
Track codes (PAR0..PARn) are anonymous acoustic identities, not
CHAT roles. PAR0 is not “the target participant”, and it is not
necessarily the first voice heard either: diarizer-native labels are mapped
to track codes deterministically by sorting the distinct labels lexically
into PAR0..PARn, so re-running the same audio yields the same assignment
whatever order the provider returned its turns in. Each recording is
diarized on its own, with no speaker identity carried between recordings,
so PAR0 in one file and PAR0 in another need not be the same person even
when a corpus has fixed participants. Track-to-tier projection happens
downstream in chatter rediarize; semantic role assignment remains a
separate step, for example chatter speaker-id using additional evidence or
adjudication, or batchalign3 speaker-identify scoring each track against
voices enrolled from the same recording.
The command does not run ASR and does not modify a CHAT file. The later
chatter rediarize step uses interval overlap to assign existing transcript
material to acoustic tracks; it does not turn anonymous tracks into known
participant roles by itself.
Local model download: today’s truth, including a gated dependency
Standalone diarize runs the open-source pyannote.audio pipeline locally,
and pins three artifacts by exact Hugging Face commit in its release
manifest: the pipeline config (talkbank/dia-fork), the segmentation model
(talkbank/seg-fork-3.0), and the speaker-embedding model
(hbredin/wespeaker-voxceleb-resnet34-LM). All three repositories are public
and ungated, and “pinned” is literal: a later update to a repository’s
default branch does not silently change a released BA3 runtime or reuse
evidence produced by another model graph.
A fourth, UNPINNED dependency is fetched anonymously behind those three,
and it is currently gated. pyannote.audio’s SpeakerDiarization pipeline
class unconditionally loads a PLDA calibration artifact during construction,
regardless of which clustering algorithm the pinned config selects; when the
config does not name a PLDA artifact of its own (ours does not), the class’s
own default applies, and that default is the gated
pyannote/speaker-diarization-community-1 repository. On a machine with no
accepted terms and no Hugging Face token, standalone diarize and
integrated transcribe --speaker-engine pyannote therefore fail on first
use with a “model access” error naming that repository.
The fix is a Hugging Face token, in either of two places, checked in this order:
-
~/.batchalign.ini, section[auth], keyhf_token:[auth] hf_token = <your Hugging Face token, after accepting the model's terms> -
Hugging Face’s own resolution: the
HF_TOKENenvironment variable, or the token saved by runninghf auth login.
Accepting the gated repository’s terms at
https://huggingface.co/pyannote/speaker-diarization-community-1 is required
regardless of which of the two the token comes from. An operator who instead
wants to avoid a Hugging Face account entirely should use
--speaker-engine pyannote-ai (below) or --speaker-engine nemo, neither of
which touches this dependency.
This local model download must not be confused with the pyannoteAI API key.
That separate credential authorizes the paid pyannoteAI cloud service selected
by standalone --speaker-engine pyannote-ai and used by default in integrated
diarized transcription. It is not a Hugging Face token. BA3 reads it from
either place, in this order:
- the environment:
BATCHALIGN_PYANNOTE_API_KEY(also accepted:BATCHALIGN_PYANNOTE_KEY,PYANNOTE_API_KEY); - the configuration file
~/.batchalign.ini, section[diarize], keyengine.pyannote.key:
[diarize]
engine.pyannote.key = <your pyannoteAI API key>
With the key in place, batchalign3 diarize ... --speaker-engine pyannote-ai
needs nothing further. That route neither touches the gated PLDA dependency
above nor sends audio anywhere but pyannoteAI’s own service. See
transcribe for the integrated cloud path.
An operator who changes the local engine to a different, gated custom Hugging Face model must independently accept that model’s terms and authenticate as required by its publisher. That is not the released default.
Gotchas
diarize prefers the local daemon when auto_daemon is enabled, like
the other audio commands. Use --no-server for one-off in-process runs or
explicit --server to target a remote daemon.
Auto-detect beats a wrong hint. Passing --num-speakers 2 when four voices are
present forces the model to collapse speakers, which is the classic failure
mode this command exists to repair. Omit --num-speakers unless the count is
certain. (-n was removed on 2026-08-19; it read as a worker count.)
A count of 1 is refused. The count is 2 or more, or it is omitted so the diarizer detects the number itself. One is neither: it asks the engine to separate the speakers of a recording asserted to hold one, so it is refused when the arguments are parsed rather than silently treated as detection:
a diarization speaker count must be at least 2, and 1 was given. Omit the count to have the diarizer detect it, which is the recommended mode.
Turns JSON is strict on the chatter side. chatter rediarize rejects
files with unknown or missing fields rather than guessing; do not
post-process the artifact with ad-hoc scripts.
Standalone and integrated diarization share the speaker-evidence cache. A
warm standalone run replays validated derived turns or re-normalizes retained
raw evidence; it does not call the selected backend again. This matters most
for pyannote-ai, where a repeat miss could otherwise incur another paid job.
Use global --require-media-cache for a fail-closed replay experiment, or
--override-media-cache only when deliberately requesting fresh inference.
Related documentation
- transcribe, the composed ASR + diarization path
- Command I/O, I/O patterns
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
speaker-identify
Status: Current Last updated: 2026-09-09 13:24 EDT
Score each timed utterance of a CHAT transcript against one or more voices you enroll from the recording itself, and write the scores and verdicts beside the transcript as evidence.
You supply one or more enrollment spans: stretches of the same recording known to contain a single speaker alone. The command embeds each of them, embeds every timed utterance, and reports how acoustically similar each utterance is to each enrolled voice.
It does not modify the transcript. The output is a JSON evidence file. What to do with a verdict, in particular whether to change a speaker code, is a decision about your corpus’s own conventions, and it stays yours.
Quick start
# One investigator, enrolled from the opening span of the recording
batchalign3 speaker-identify session.cha \
--enroll 1500-9000:INV \
--threshold 0.62 \
-o evidence/
# Two known voices, and only the utterances currently on *PAR0
batchalign3 speaker-identify corpus/ \
--enroll 1500-9000:INV \
--enroll 12000-20000:CHI \
--tiers PAR0 \
--threshold 0.62 \
-o evidence/
For session.cha the artifact is session_speaker_identity.json.
What an enrollment span is
A span of the recording, in milliseconds from its start, that you know holds one speaker, alone. The same coordinates a CHAT timing bullet uses.
--enroll <start_ms>-<end_ms>:<label>
The label names the voice (INV, CHI, MOT, anything without a colon, a
dash or a space). It is what appears in every score and verdict.
Choosing the span is the part that matters, and only you can do it:
- Longer is better. A few seconds of continuous speech is a much steadier acoustic identity than half a second. Spans below the model’s own minimum (about 105 ms) are refused outright.
- One voice only. If anyone else speaks inside the span, or the span includes a long silence, the enrolled vector describes a mixture, and every score computed against it inherits that.
- From this recording. Enrolling from a different session’s audio compares the two rooms and the two microphones as much as the two people.
Common source of a good span: an opening stretch where one person is speaking alone before anyone else joins.
Options
| Option | Default | Meaning |
|---|---|---|
PATHS... | Input .cha transcripts and/or directories | |
-o, --output DIR | Output directory for the evidence artifacts | |
--enroll SPAN | required | <start_ms>-<end_ms>:<label>. Repeat once per known voice |
--threshold F | required | Similarity at or above which a voice counts as a match |
--tiers CODES | all tiers | Speaker tiers to score, comma-separated or repeated |
--permutations N | 1000 | Shuffled labellings drawn for each track contrast’s p-value; a thousand resolve p to 0.001 |
--permutation-seed S | 0 | Seed of that shuffle, so a run reproduces byte for byte. Both are recorded in the file |
--lang CODE | eng | 3-letter ISO code, for worker-pool selection only; embedding is language-independent |
Why --threshold has no default
How much acoustic agreement counts as “the same person” depends on the recording, the microphone, how much enrollment audio you gave, and what a wrong answer would cost you. Nothing in this tool knows any of that. A built-in number would produce confident verdicts under a value nobody chose, and no reader of the output could tell it had been chosen by accident. So you state it, and the value you stated is written into the evidence beside the verdicts it produced.
How to pick one. Run once on a session where you already know the answer
for a handful of utterances, read the scores in the output, and choose a
value that separates them. Cosine similarity runs from -1 to 1; values in the
0.5-0.75 region are where useful thresholds usually fall for this model, but
that is an observation about where to start looking, not a recommendation.
Enrollment rules the command enforces
- At least one
--enroll. With none there is nothing to identify against. - Labels are unique. Each label names one voice.
- Enrollments may not overlap. Two spans claiming the same audio for two different single speakers cannot both be true, so the run is refused rather than producing two contaminated vectors that look like ordinary ones.
Spans that touch end-to-start (0-5000 and 5000-9000) do not overlap and are
fine.
Output format
For session.cha, session_speaker_identity.json:
{
"provenance": {
"schema_version": 2,
"interpretation": "Scores are acoustic AGREEMENT with an enrolled span ...",
"transcript": "session.cha",
"media": "/corpus/media/session.mp3",
"prepared_sample_rate_hz": 16000,
"embedding_backend": "pyannote",
"embedding_model_revision": "pyannote-embedding:0ae88dca...",
"embedding_dimension": 256,
"embedding_minimum_frames": 1680,
"match_threshold": 0.62,
"tiers": ["PAR0"],
"enrollments": [
{ "label": "INV", "start_ms": 1500, "end_ms": 9000 }
],
"permutation": { "seed": 0, "count": 1000 },
"produced_by": "batchalign3 <build>"
},
"utterances": [
{
"utterance_index": 0,
"line": 42,
"speaker": "PAR0",
"start_ms": 12000,
"end_ms": 14500,
"scores": [{ "label": "INV", "score": 0.81 }],
"verdict": { "verdict": "matches", "label": "INV", "score": 0.81 }
}
],
"tracks": [
{ "kind": "voiced", "track": "PAR0", "lines_embedded": 212, "lines_refused": 3,
"centroid": ["..."], "scores": [{ "label": "INV", "score": 0.71 }] }
],
"track_contrasts": [
{ "kind": "one_track", "label": "INV", "track": "PAR0" }
]
}
Every utterance carries its similarity to every enrolled voice, not only the winner, so you can try a different threshold without re-running the model.
Tracks as voices
Beside the per-line verdicts, every speaker code among the scored tiers is
scored as one voice: the centroid of its lines’ vectors, compared to each
enrolled voice. The mean of a track’s line scores is not the similarity of
the track’s voice, and the centroid is robust to the short lines that drag a
mean down. For each enrolled voice, track_contrasts then says how far the
best track stands out from the runner-up, with a permutation p-value in
place of a margin somebody chose: line-to-track membership is shuffled with
track sizes kept, and the p-value is how often a shuffle reaches the observed
margin. The seed and count are yours to set and are written into the file.
Field by field: the evidence reference.
The three verdicts
verdict | Meaning |
|---|---|
matches | Exactly one enrolled voice scored at or above the threshold, with nothing tied. Carries that label and score. |
no_match | No single voice was established. best names every label that reached the highest score, and that score. |
unscored | No similarity was computed, and reason says why. |
A tie never matches. If two enrolled voices reach the same highest score,
even above the threshold, the evidence does not say which speaker it is,
because it does not know. best then lists both.
Why an utterance is unscored
reason | Meaning |
|---|---|
too_short_for_embedding | Shorter than the model can measure. Carries frames and minimum_frames. |
no_bullet | The utterance has no timing, so there is no audio to embed. |
no_comparable_embedding | It had timing, but no enrolled embedding could be compared with its embedding. |
audio_missing | Its bullet names audio the recording does not contain. Carries the bullet and the recording’s length. |
overlaps_enrollment | It falls inside an enrolled span. Scoring it would compare that audio with a vector computed from it. |
Unscored utterances are reported, never omitted: a file that dropped them would let you conclude the transcript has fewer utterances than it has.
Scores are agreement, not accuracy
A score says how similar two stretches of audio are under one embedding model. It does not say the speaker is who you think. The enrolled span is your own claim about who is talking, and it carries your error rate: if the span holds the wrong person, or two people, every score against it is confidently wrong in a direction nothing in the output reveals.
Treat a high score as evidence to act on, not as a verdict to publish. The
evidence file repeats this in its own interpretation field, because the file
is what gets forwarded and this page is not.
Gotchas
The enrollment span is not scored against itself. Utterances inside an
enrolled span come back unscored with overlaps_enrollment. That is
deliberate; a similarity there would measure the arithmetic.
No Hugging Face account is needed. Unlike diarize, this command loads only
the speaker-embedding model, which lives in a public, ungated repository. It
never builds the diarization pipeline, so it never reaches that pipeline’s gated
calibration dependency.
Enrollment spans must be inside the recording. A span past the end fails the
run rather than scoring everything against a truncated vector; an utterance
past the end is unscored, because one bad bullet should not end a file.
A transcript with no timings produces an all-unscored file. That is the
correct answer, not a failure: run align first if you want timings.
Related documentation
- Speaker identity evidence, the artifact’s field-by-field reference
- diarize, which finds anonymous speaker turns without enrollment
- align, which produces the timings this command reads
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
eval
Status: Current Last updated: 2026-09-15 21:24 EDT
batchalign3 eval contains offline evaluators. They consume retained artifacts
and never submit ordinary processing jobs.
UTR alignment replay
batchalign3 eval utr-alignment replays global utterance-timing-recovery word
matching from an exact CHAT document and retained UTR timing tokens. It does
not invoke a model or provider and does not modify CHAT.
batchalign3 eval utr-alignment \
--chat recording_utr_input.cha \
--tokens recording_utr_tokens.json \
--fuzzy-threshold 0.85 \
--participation all-utterances \
--output recording_utr_alignment.json
| Flag | Meaning |
|---|---|
--chat <CHAT> | Clean exact CHAT input used for global word alignment. |
--tokens <JSON> | Retained AsrTimingToken JSON array, normally a debug _utr_tokens.json artifact. |
--output <JSON> | Fresh report path. Existing paths are refused, and a complete report is atomically published. |
--fuzzy-threshold <0..1> | Use fuzzy Jaro-Winkler matching at this finite threshold. Omit for case-insensitive exact matching. |
--participation <POLICY> | all-utterances by default, or exclude-marked-overlap to reproduce the first pass of two-pass UTR. |
The report fingerprints both inputs, records the executable build identity,
and retains exhaustive per-utterance match or refusal states. A matched state
owns a nonempty word-to-token collection and a positive or nonpositive timing
proposal. This is research evidence, not permission to overwrite main-tier or
%wor timing, and it never generates %xalign.
Schema 2 matches words inside provider segments and records both the original token index and its within-token word index. Each word retains its provider segment’s interval; these are coarse timing proposals, not newly measured word timestamps. Input token JSON is unchanged, so retained older runs can be replayed into a fresh report without inference.
flowchart LR
CHAT["Exact CHAT"] --> PLAN["Global UTR plan"]
TOK["Retained timing tokens"] --> PLAN
PLAN --> EVIDENCE["Typed match and proposal evidence"]
EVIDENCE --> REPORT["Atomic non-clobbering JSON report"]
Utterance segmentation replay
batchalign3 eval utseg-replay reapplies the utterance-boundary evidence a run
retained and reports whether it still produces the document that run wrote. No
model loads, no worker starts, and no artifact is modified.
This answers one question: is the segmentation in a retained transcript still what this build produces from the same evidence? A difference means the local segmentation path changed between the two builds, which is a finding, not a failure of the command.
What a run has to have retained
Everything this command consumes is written only when the run passed
--debug-dir (see transcribe). A run without it retained
nothing, and nothing here can be replayed. The standalone utseg command
writes no sidecars at all, so the post-CHAT pass replays a transcribe run.
| Artifact | Written as | Is |
|---|---|---|
| ASR response | <stem>_asr_response.json | The retained provider response. |
| Post-ASR CHAT | <stem>_post_asr.cha | The document as built, before either segmentation pass touched it. |
| Pre-utseg CHAT | <stem>_pre_utseg.cha | The input to the post-CHAT pass. |
| Post-utseg CHAT | <stem>_post_utseg.cha | The output of the post-CHAT pass. |
| Pre-CHAT evidence | <stem>-<12 hex>_pre_chat_utseg_evidence.json | Boundaries over timed ASR chunks. |
| Post-CHAT evidence | <stem>-<12 hex>_post_chat_utseg_evidence.json | Boundaries over main-tier words. |
The sidecars carry a 12-hex digest of the complete submitted identity whenever
that identity included a directory, which a transcribe run’s always does, so
two corpus branches holding the same basename cannot overwrite one another in a
shared debug directory. The .cha dumps use the plain stem.
Pick the pair that belongs to the pass. The run’s FINAL .cha is not the
output of either pass: it has been through the post-CHAT pass and carries the
morphology tiers, so replaying against it reports a difference that says
nothing about segmentation.
The two passes
Transcribe segments twice, over different populations, and each pass retains its own sidecar. Each pass is its own subcommand consuming its own artifacts, so neither can be run against the other’s evidence:
# The pass over main-tier words. Its input is the pre-utseg dump and its
# output is the post-utseg dump, not the run's final transcript.
batchalign3 eval utseg-replay post-chat \
--input-chat recording_pre_utseg.cha \
--evidence recording-1a2b3c4d5e6f_post_chat_utseg_evidence.json \
--output-chat recording_post_utseg.cha
# The pass over timed ASR chunks, before the document existed. Its output is
# the post-ASR dump, which is the document as built.
batchalign3 eval utseg-replay pre-asr \
--asr-response recording_asr_response.json \
--evidence recording-1a2b3c4d5e6f_pre_chat_utseg_evidence.json \
--output-chat recording_post_asr.cha \
--media-name recording.wav
| Flag | Pass | Meaning |
|---|---|---|
--input-chat <CHAT> | post-chat | The document the run segmented, normally the _pre_utseg.cha dump. |
--asr-response <JSON> | pre-asr | The run’s retained *_asr_response.json. |
--evidence <JSON> | both | The run’s retained utseg evidence sidecar for that pass. |
--output-chat <CHAT> | both | The document that pass wrote, to reproduce. |
--media-name <NAME> | pre-asr | The media name the run recorded in @Media. Optional, like transcribe’s own; omit it for a run that recorded none. |
--wor | pre-asr | Reproduce a run that generated %wor tiers from ASR word timings. |
What it admits, and what it refuses
The evidence goes through the same admission a live worker result goes through, so an artifact is reapplied only when it still describes applicable work:
- The sidecar must be this build’s evidence schema, which is 4, and must
record the pass the subcommand reproduces. Any other version is refused by
name, older or newer alike, and nothing is migrated. Every utseg sidecar
retained before this build is schema 3, and therefore cannot be replayed at
all. Schema 4 exists because the boundary model’s revision became a
required part of its identity: where a schema-3 sidecar recorded a revision,
it recorded whatever a floating load happened to resolve to that day, and
reading that back as “the revision the plan pinned and the worker verified”
would reinterpret an accident as a pin. Regenerate the evidence with the
current build, which is cheap: these sidecars are
--debug-dirresearch artifacts, not a result cache. - Every item’s assignments must be parallel to the words retained with it, and boundary-model evidence must be parallel to those words and consistent with the assignments and the adjacency policy it declares.
- The requests this build collects from the input must match the retained items one for one: the same count, the same transcript positions, the same words and text. A count or wording mismatch means the evidence belongs to a different input, and the replay says so rather than segmenting anyway.
- A locally rederived decision must explain itself: the receipt retained with it has to name the policy its evidence declares, reproduce the worker’s own assignments under the worker’s policy, and reproduce both the applicable assignments and the exact suppressions it claims.
- The input document is gated exactly as
utseggates its own input: parsed leniently, then judged by the same validity gate with its parse errors in hand. The replay therefore refuses what the run itself would have refused, with the message the run would have given.
Each refusal names the artifact and what was wrong with it. Nothing is compared when an input is refused.
What the pre-ASR pass cannot reproduce
A --lang auto run detects languages twice: once per file, which can put
several codes in @Languages, and once per utterance, which writes a
[- code] code-switch precode wherever an utterance differs from the primary
language. This pass does neither: it builds with the one resolved language the
evidence names and tags no utterance.
So it refuses, rather than comparing, when the retained output declares any
language set other than that one language, or carries a code-switch precode.
Comparing would report a difference and appear to blame the boundaries for
something segmentation never touched. Reproducing an --lang auto run is out
of scope for this command.
What the comparison ignores
A run also writes comments recording that a run happened: its [fc-ba3 ...]
stamp and, for transcribe, the unchecked-ASR warning. A stamp carries a
timestamp and the warning carries a build identity, so neither can ever match
by equality. Both are recognized through the same provenance codec that writes
them, left out of the comparison on both sides, and listed in the report. Every
other line is compared for CHAT semantics, so formatting that does not change
meaning is not a difference.
Both passes compare on one basis: the AST of the CHAT text. The recomputed document is serialized and parsed back before the comparison, because text is what a run writes and what every later stage and every reader sees. That also keeps a serialization-only defect visible in both passes rather than in whichever one happened to reparse.
Outcomes
The typed report goes to stdout whatever the result.
| Outcome | Exit code | Meaning |
|---|---|---|
reproduced | 0 | Every compared line matches. |
differing | 1 | The replay ran and the documents disagree. The report names the comparable line counts and where they first differ. |
| refusal | 2 | An input could not be admitted, so nothing was compared. |
A difference is printed on stderr in the CLI’s usual failure form, prefixed
error:, because that is how the executable renders any nonzero exit. The exit
code is what tells the two apart: 1 means the replay ran and the comparison
answered “no”, while a command used wrongly or a broken machine stays in the 2
to 6 range described in the CLI reference.
flowchart LR
EV["Retained utseg evidence"] --> ADMIT{"Admit: schema, pass,<br/>per-item invariants"}
IN["Input CHAT or retained ASR response"] --> COLLECT["Collect requests<br/>with the current build"]
COLLECT --> BIND{"Bind one-to-one:<br/>count, position, words"}
ADMIT --> BIND
ADMIT -->|refused| STOP["Refuse, naming the artifact"]
BIND -->|refused| STOP
BIND --> APPLY["Reapply boundaries"]
APPLY --> CMP["Compare, ignoring<br/>generated comments"]
RET["Retained output CHAT"] --> CMP
CMP --> OUT["reproduced, or a typed difference"]
The pre-ASR pass reads speakers from the retained ASR response, which is where
they come from when no separate diarization artifact was projected onto the
chunks. A run whose speakers came from such an artifact is the subject of
eval transcribe-replay, which admits the turns artifact as well.
L2 morphotag evaluation
batchalign3 eval l2-morphotag evaluates the output of batchalign3 morphotag (L2 dispatch is on by default) against a curated evaluation
corpus. It produces aggregate per-pair statistics.
What it does
For every @s word in every post-morphotag CHAT file, the command:
- Walks the CHAT AST with
talkbank-model::walk_words(TierDomain::Mor)so the pairing with%mor/%graitems is domain-correct (retraces, fragments, untranscribed placeholders do not consume positions). - Classifies the splice outcome as
Spliced,L2Xxx(dispatch failed toL2|xxx), orMissingMor(no MOR item at the expected position, should be near-zero with the AST walker). - Applies rule-based suspicious-output detectors (heuristic flags):
PropnForFunctionWordandFeaturePosMismatch. Flags are candidates for manual review, not confirmed errors. - Writes four artifacts to
--output:per-word.csv: one row per@swordper-pair.csv: one row per language pair, with dispatch rate, splice rate, heuristic-clean rateflagged.csv: subset ofper-word.csvwhere at least one flag firedsummary.md: human-readable report against the pre-registered gates
flowchart LR input["eval-set.jsonl\n(basename → pair_key)"] --> walker["analyze_file()\n(eval_cmd/l2_morphotag/analysis.rs)"] chafiles["post-morphotag\n*.cha files"] --> walker walker -->|"typed Mor/Gra items"| flags["flags_for()\n(heuristics.rs)"] flags --> aggregate["aggregate_by_pair()\n(report.rs)"] aggregate --> perword["per-word.csv"] aggregate --> perpair["per-pair.csv"] aggregate --> flagged["flagged.csv"] aggregate --> summary["summary.md"]
Usage
# Step 1: run morphotag (L2 dispatch is on by default), collecting
# outputs in one directory.
batchalign3 morphotag \
--sequential \
corpus/ /tmp/l2-eval-out/
# Step 2: run the evaluator against the curated eval set.
batchalign3 eval l2-morphotag \
--eval-set <eval-set>.jsonl \
--morphotag-output /tmp/l2-eval-out/ \
--output /tmp/l2-eval-report/
CLI options
| Flag | Meaning |
|---|---|
--eval-set <JSONL> | File listing input CHAT files with their pair_key labels. One JSONL object per line with at least path and pair_key. The selection script that originally produced these files lives outside this repo (private workspace under docs/l2-eval-batchalign3/data/); for new evaluations, hand-write or generate the JSONL with whatever process labels each input. |
--morphotag-output <DIR> | Directory (flat or nested) of post-morphotag CHAT files. Matched against the eval set by filename basename, so the input-side path in the JSONL does not have to match. |
--output <DIR> | Destination for per-word.csv, per-pair.csv, flagged.csv, summary.md. Created if missing. |
Why this replaces the legacy Python analyzer
An earlier Python analyzer used regexes over serialized CHAT to pair
@s words with %mor items by token position. That approach
mis-counted positions under CHAT retrace markers ([/], [//],
<foo bar> [//]), producing ~2% missing_mor noise that had to be
disclaimed in every summary. It has been removed; the Rust analyzer
in crates/batchalign/src/cli/eval_cmd/l2_morphotag/ replaces it,
driving off the typed AST via walk_words(TierDomain::Mor) with a
counts_for_tier gate so position counts lock-step with
mor_tier.items by construction, eliminating the analyzer artifact.
On a 2026-04-15 eval corpus (54 files, 19 language pairs), the side-by-side numbers (Python analyzer vs the Rust replacement, captured before the Python analyzer was removed) were:
| Metric | Python analyzer | Rust analyzer |
|---|---|---|
@s words counted | 17,352 | 16,845 |
| Aggregate splice rate | 98.4% | 99.9% |
| Aggregate heuristic-clean rate | 95.0% | 98.0% |
Pairs with zero missing_mor | 6 / 19 | 18 / 19 |
The 502-word delta is retraced @s words the regex counted but that
have no paired %mor item by CHAT spec. The Rust number is the true
measure; the Python number was inflated by analyzer noise.
See also
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
merge-verify
Status: Current Last updated: 2026-07-17 01:36 EDT
Tier the machine-flagged utterance placements of a merged draft set against engine verdicts, rewriting promoted flags into provenance notes and exporting the rest as a review queue. Fully offline: no daemon, no models, no audio; the heavy signals arrive as a verdicts JSON produced separately.
A “merge” workflow places utterances from one source (e.g. ASR with
timings) into a manually transcribed session and flags every placement
it is not sure of with a %com comment. merge-verify is the second
pass: given per-line verification verdicts from three engines (forced
alignment confirmation, pitch banding, a machine ear), it applies a
calibrated composed rule to every flagged line:
- Auto-trust: the flag is REWRITTEN into a machine-verified
provenance note carrying the three signals; never silently deleted.
A human transcriber note sharing the same
%comtier survives verbatim ahead of the note. - Review: the flag is left unchanged and the line is exported to
review-queue.json(directly consumable as a review-campaign spot scope). - Hold: the flag is left unchanged and the line is not queued (categories the calibration says need session-level treatment).
- Demote: a previously unflagged line whose verdicts contradict its placement gains a review flag; text and timing are never moved.
The rewritten drafts must be logically identical to the input on every
main tier (a built-in preservation invariant fails the run otherwise):
this pass edits only %com flags.
Quick start
batchalign3 merge-verify \
--draft merged-drafts/ \
--verdicts verdicts.json \
--out verified-drafts/ \
--flag-prefix merge
Output: one rewritten .cha per input session plus
review-queue.json in --out, and a one-line tier summary on stdout.
Options
| Flag | Meaning |
|---|---|
--draft <DIR> | Directory of merged .cha drafts (one per session) |
--verdicts <FILE> | Engine verdicts JSON (shape below) |
--out <DIR> | Output directory for rewritten drafts + queue |
--flag-prefix <P> | The %com flag marker (default verify; merged corpora typically use merge) |
Verdicts JSON
{
"sessions": [
{
"session": "S-001",
"lines": [
{"utterance_index": 12, "category": "other",
"fa_mean_score": 0.52, "pitch": "child", "ear": "yes"}
]
}
]
}
utterance_index is the 0-based ordinal over main-tier utterance
lines. category is the flag taxonomy the calibration was performed
over; pitch is child / adult / ambiguous; ear is yes /
no. fa_mean_score orders the review queue worst-first and is never
a promote/demote gate (calibration finding: disfluent child speech
aligns poorly, and the aligner happily aligns the wrong voice).
The verify engines
The verdicts are produced by the three calibration-locked engines in
batchalign.inference: fa_confirm (windowed MMS_FA alignment
scoring), pitch_band (librosa pyin child/adult banding), and
machine_ear (local audio-LLM YES/NO). Each module documents its
calibration constants; changing any of them requires recalibration
against blind listening verdicts.
This page last changed: 2026-07-17 (commit 03c89afe). The whole book last changed: 2026-09-16 (commit 34d249d8).
No Python API
Status: Current Last updated: 2026-07-14 10:18 EDT
Batchalign3 does not have a public Python API. Python lives inside the
package as a worker-side ML inference layer, strictly an internal
implementation detail of the Rust runtime. As of 2026-05, only Rev.AI
ASR is Rust-owned (driven directly from the server); every other ASR
engine, plus all morphosyntactic / segmentation / translation / coref
pipelines, runs through a Python worker. The long-term direction is to
keep narrowing the Python layer as Rust gains coverage of more ML
pieces. A Rust-native Whisper path (whisper_rs, whisper.cpp via whisper-rs)
now exists behind the opt-in whisper-rs-backend Cargo feature, though the
default build still routes Whisper through the Python worker.
The CLI is the entry point
All processing is done through the batchalign3 command-line tool:
batchalign3 transcribe input/ -o output/ --lang eng
# morphotag has no --lang, per-file @Languages: header drives routing
batchalign3 morphotag input/ -o output/
batchalign3 align input/ -o output/ --lang eng
For programmatic use from Python, call the CLI as a subprocess:
import subprocess
subprocess.run(
[
"batchalign3", "morphotag",
"input/", "-o", "output/",
"--lang", "eng",
],
check=True,
)
Anything else, importing batchalign.* modules, calling
batchalign_core.* symbols, depending on batchalign.providers,
batchalign.worker.*, or any Python class or function, is unsupported
and will break without notice. There is no compatibility surface to
build against.
If you used the BA2 Python API
The following BA2 Python entry points were removed during the BA3 rewrite. The CLI is the replacement for all of them:
| BA2 Python entry point | Replacement |
|---|---|
BatchalignPipeline | batchalign3 <command> |
WhisperEngine, RevAIEngine, etc. | batchalign3 transcribe --asr-engine <name> |
CHATFile, Document, ParsedChat | the chatter CLI in talkbank-tools (chatter to-json, chatter validate, etc.) |
run_pipeline(), LocalProviderInvoker, PipelineOperation | batchalign3 <command> |
compute_wer() | batchalign3 compare |
batchalign.compat | none, its purpose was to bridge BA2 callers; rewrite around the CLI |
If you have BA2 Python integration code, port it to subprocess calls
into batchalign3. The CLI’s output format is the long-term stable
contract.
See also
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
Cantonese Engines
Status: Current Last updated: 2026-09-16 01:41 EDT
Batchalign includes alternative ASR and forced alignment engines for Cantonese. These are built-in modules shipped in the base package, selected with the per-category engine flags below.
Available Engines
| Engine | Task | Description |
|---|---|---|
qwen | ASR | Qwen3-ASR-1.7B local model (Alibaba). Open-weight Cantonese-capable ASR; external evaluations report competitive CER on per-utterance child speech. Downloads ~3.4 GB weights on first use; no cloud credentials. |
tencent | ASR | Tencent Cloud speech recognition with speaker diarization. |
aliyun | ASR | Alibaba Cloud NLS real-time speech recognition (Cantonese only). |
funaudio | ASR | FunASR/SenseVoice local model (no cloud credentials needed). |
cantonese | FA | Cantonese forced alignment with jyutping preprocessing. |
Installation
The standard install (see the Installation guide) already includes these engines.
For a source checkout, the standard build (cargo build -p batchalign
plus uv run maturin develop for the PyO3 bridge) already includes
these engines. There are no Cantonese-specific extras to install.
Usage
Each engine category has one flag, and --help lists every value it
accepts: --asr-engine for transcription, --fa-engine for forced
alignment, --utr-engine for utterance timing recovery.
--engine-overrides is for per-engine PARAMETERS, such as which
checkpoint to load. It is no longer how an engine is chosen; earlier
versions of this page used it that way, which is why the Cantonese
engines were hard to find.
# Recommended: Qwen3-ASR (local, no credentials)
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine qwen
# Pick the 0.6B model for faster inference on tight hardware
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine qwen --engine-overrides '{"qwen_model": "Qwen/Qwen3-ASR-0.6B-hf"}'
# Transcribe with Tencent Cloud ASR (cloud, needs CAM credentials)
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine tencent
# Transcribe with FunASR (local, no credentials)
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine funaudio
# Benchmark against a gold CHAT companion in the input directory
batchalign3 benchmark input/ --output output/ --lang yue --num-speakers 1 \
--asr-engine qwen
# Force align with Cantonese FA engine
batchalign3 align input/ -o output/ --lang yue \
--fa-engine cantonese
# Align a transcript whose utterances have no timings yet, so utterance
# timing recovery has to run. Rev.AI, the default UTR engine, does NOT
# support Cantonese; pass one that does.
batchalign3 align input/ -o output/ --lang yue --utr \
--fa-engine cantonese --utr-engine tencent
Utterance timing recovery on Cantonese
--utr recovers timings for utterances that have none. Its default
engine is Rev.AI, which has no Cantonese support, so a Cantonese file
needing UTR fails validation unless another engine is named:
--utr-engine | Cantonese | Notes |
|---|---|---|
rev (default) | no | Rev.AI has no yue model. |
whisper | yes | Local, no credentials. |
tencent | yes | Cloud, needs CAM credentials. Chinese variants only. |
The error names the engines that would work, so there is nothing to
guess. If none does, --no-utr skips the pass and alignment proceeds
with interpolated timings.
Credential Configuration
Cloud engines (Tencent, Aliyun) require API credentials in
~/.batchalign.ini:
Tencent Cloud
[asr]
engine.tencent.id = <secret-id>
engine.tencent.key = <secret-key>
engine.tencent.region = ap-guangzhou
engine.tencent.bucket = <cos-bucket-name>
Aliyun NLS
[asr]
engine.aliyun.ak_id = <access-key-id>
engine.aliyun.ak_secret = <access-key-secret>
engine.aliyun.ak_appkey = <appkey>
Missing or empty credentials raise ConfigError with a clear message
indicating which keys are needed.
Qwen3-ASR
Qwen3-ASR has no cloud credentials, it is a local HuggingFace model
downloaded on first use (~3.4 GB for the default Qwen/Qwen3-ASR-1.7B-hf).
Two --engine-overrides knobs are recognized:
qwen_model: override the HuggingFace model id. The 1.7B default is the recommended-quality variant; passQwen/Qwen3-ASR-0.6B-hffor faster inference at some accuracy cost.qwen_device:"cpu"(default),"cuda", or"mps". The Apple Silicon fleet defaults to CPU because empirical testing found MPS inference produced degraded output on the 1.7B model as of 2026-05-26.
Cantonese Text Normalization
All Cantonese ASR output is automatically normalized from simplified/mixed Chinese to Traditional Chinese. This normalization:
- Simplified → Traditional via the
ferrous-openccRust engine (embedded OpenCCS2hkconversion tables) - Domain-specific corrections via a 31-entry replacement table for Cantonese character variants (e.g., 系→係, 呀→啊, 中意→鍾意)
It runs in the Rust server during ASR post-processing for lang=yue, once per
speaker monologue, before anything splits the words. No configuration, no
additional Python dependencies (like OpenCC), and nothing engine-specific: the
ASR engines report their own characters and the server normalizes them, so the
same recording reads the same whether it was transcribed by FunASR, Tencent,
Aliyun, Qwen or Whisper. Until 2026-09-16 some engines normalized their own
output and others did not, which is why older Cantonese transcripts from Qwen
and Whisper may still carry simplified characters; re-running them corrects it.
Two consequences worth knowing:
- Phrases are converted, not characters in isolation. The whole monologue is
converted in one pass, so a replacement that spans two words (
真系to真係) still applies even though the engines report one word per character. - A run that could not be normalized fails the file, with both character counts in the message. Handing each word back its own characters requires the count to be unchanged; a transcript whose words silently moved onto different characters would carry wrong timings and look correct. No input measured so far does this (191,125 strings checked, none changed length), so this is a guard rather than something to expect.
An empty transcript is a failure, not a result
A Cantonese run that recognizes nothing now fails and names the stage that came up empty, instead of completing with a transcript of headers and no utterances. Three stages can each end with no words, and they mean different things:
- the ASR engine returned no words at all. Nothing downstream ran, so
normalization is not implicated: it only executes when there are words to
normalize. Check that the engine has a Cantonese model and that
--lang yuereached it. - post-processing kept no utterance from the words the engine returned.
- CHAT assembly kept no utterance line. The words were all terminators or
separators. This is the shape a provider produces when it returns only CJK
sentence marks: each
。becomes a bare., and an utterance of nothing but a terminator has no content to write.
Until 2026-09-16 the first of these produced a completed job and an empty file.
What a cloud provider does not send
Tencent and Aliyun both document every field of a result as nullable, and their
SDKs leave an attribute as None when the service omits it. Batchalign treats
those absences as facts rather than as zeros:
- A word the provider did not time arrives untimed. It appears in the
transcript with no timing bullet of its own rather than at the start of its
segment. Previously a missing offset read as
0, which is a real time, so the word claimed a position the provider never gave it. - A Tencent segment with no
StartMsleaves every word in it untimed, because the per-word offsets are relative to that start and locate nothing without it. Previously the whole segment was placed at the beginning of the recording. - A word with no text refuses the file, naming the segment and word. Previously it became an empty string and was dropped silently, so a word the service failed to return left no trace at all.
- A field of the wrong type refuses the file, as does a time that is negative, inverted, or far larger than any recording (the shape a provider returning an absolute timestamp would produce).
A refusal names the provider, the position and the fault, for example
invalid Tencent ASR output at segment 3 word 7: Word is absent. Re-run the
file after the provider issue is resolved; batchalign does not guess a value in
order to finish.
Aliyun performs no speaker separation, and the FunASR engines do not produce
speaker labels either, so their output is one undiarized track. Use
--diarization enabled (see transcribe) when speaker
attribution is needed with those engines.
Engine Details
Tencent Cloud ASR
- Supports speaker diarization with configurable speaker count
- Uploads audio to COS (Tencent Cloud Object Storage), submits ASR job, polls for results
- 10-minute safety timeout on ASR polling
- Automatic COS cleanup after transcription
- Per-word timestamps with speaker attribution
Aliyun NLS ASR
- Cantonese only (
lang=yuerequired, other languages rejected at load time) - WebSocket streaming with real-time sentence callbacks
- Automatic token refresh (23-hour TTL)
- WAV format input required (16 kHz mono)
- Shared result shaping and Cantonese fallback tokenization happen in Rust, not in the Python transport adapter
FunASR/SenseVoice
- Local model, no cloud credentials, no network required
- Default model is
FunAudioLLM/SenseVoiceSmall. Pass--asr-engine funaudio --engine-overrides '{"funaudio_model": "<hf-id>"}'to swap to a different FunASR model (e.g. a Paraformer variant); the loader’s downstream code branches on whether the chosen model name containsparaformer. - Which checkpoint you name changes what the transcript records. This build
pins the default SenseVoice checkpoint and its voice-activity model, and
pins the Paraformer checkpoint together with the voice-activity and
punctuation models it loads; the stamp’s
asr_model=names every one of them with its revision. A checkpoint this build does not pin loads anyway and is recorded at the revision the worker reports for it, so an override never leaves the transcript naming the engine alone. - VAD (Voice Activity Detection) built in via
fsmn-vad - Timestamps are paired with FunASR’s OWN units, never with a retokenization of
the display surface: SenseVoice’s
words, which FunASR builds in lockstep with itstimestamparray, and Paraformer’s pre-punctuationraw_text, which holds one whitespace token per timestamp. The unit and timestamp counts must agree before anything is paired
Qwen3-ASR
- Local model via the
qwen-asrPyPI package, no cloud credentials, no network at inference time. - Default model is
Qwen/Qwen3-ASR-1.7B-hf. The 0.6B variant is noticeably faster (smaller model, lighter compute) at some accuracy cost. - First run downloads ~3.4 GB (1.7B fp16) or ~1.2 GB (0.6B) from HuggingFace; subsequent runs read from the local cache.
- The
qwen-asrpackage handles long-audio chunking internally; no per-utterance pre-segmentation is required at the call site. - Word-level timestamps emitted when the model returns them; falls back to whole-utterance text when timestamps aren’t available.
- Single-speaker output (no built-in diarization); BA3’s downstream diarization stage attaches speaker tags.
- Apache-2.0 licensed.
Cantonese FA
- Converts Chinese characters to jyutping romanization (via pycantonese)
- Strips tones from jyutping (Wave2Vec MMS expects toneless input)
- Runs Wave2Vec forced alignment on the romanized text
- Maps word-level timings back to original Chinese characters
See Also
- Cantonese and CJK, Architecture, engine architecture, normalization pipeline, segmenter selection
- Adding Inference Providers, how to add new built-in engines
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Caching
Status: Current Last updated: 2026-09-15 17:23 EDT
What gets cached
Batchalign caches only audio-task results:
| Analysis | Cached? |
|---|---|
Forced alignment word timings (align) | Yes |
ASR results for utterance timing recovery (align’s UTR pre-pass) | Yes |
Dedicated speaker evidence (transcribe --diarization enabled or standalone diarize) | Yes |
Media conversion (.mp4/.m4a → .wav) | Yes |
Raw Rev.AI transcript evidence (transcribe, benchmark, Rev-backed align UTR) | Yes |
Other ordinary ASR output (transcribe) | No |
Morphosyntax (morphotag) | No: always recomputed |
Utterance segmentation (utseg) | No: always recomputed |
Translation (translate) | No: always recomputed |
Coreference (coref) | No: always recomputed |
OpenSMILE features (opensmile) | No |
AVQI scores (avqi) | No |
The text-NLP cache that previously covered morphotag, utseg, and
translate was removed after a benchmark on a 15,748-file corpus
showed it was about 25× slower than just re-inferring (6-16% hit rate;
2,500 ms SQLite lookup beat ~100 ms inference savings). See the
architecture page on Caching for the detailed reasoning.
In practice: a re-run of morphotag on the same corpus takes the
same time as the first run. A re-run of align on the same audio is
much faster. A repeat transcribe --diarization enabled run with the
same speaker settings reuses the exact normalized speaker turns that
the first run consumed, instead of calling the diarization backend
again. BA3 also retains backend-shaped evidence separately, so a changed
local normalization algorithm can derive new turns without repeating
inference.
For Rev.AI transcription, BA3 reads the transcript endpoint as bytes, requires strict UTF-8 JSON, and stores that exact application response before converting its monologues and elements to BA3 tokens. It does not use lossy or encoding-normalizing text decoding. Unknown provider fields are retained for future projections. A warm run therefore avoids both Rev submission and polling, and later post-processing experiments can replay the same provider transcript evidence locally.
Dedicated speaker evidence
The speaker cache applies to the dedicated diarization stage of transcribe
and to standalone diarize, for all three speaker engines (pyannote-ai,
pyannote, and nemo). It is especially useful with the paid pyannoteAI
service.
The key includes:
- a BLAKE3 digest of the full media-source bytes;
- the canonical audio-preparation recipe revision;
- the selected speaker backend;
- the expected speaker count;
- the speaker-model revision; and
- the stored evidence schema version.
Paths and modification times are deliberately excluded. Renaming or copying an unchanged recording therefore reuses its speaker evidence. Re-encoding the recording changes its bytes and causes a miss even if it sounds identical.
BA3 stores two different artifacts:
- Raw inference evidence. For pyannoteAI this is the completed provider job ID, complete output object, and optional warning. Local Pyannote and NeMo retain their backend-specific segment evidence.
- Derived speaker segments. These are the sorted millisecond intervals consumed by transcript speaker projection. Their identity includes both the raw-evidence fingerprint and a separate normalization-algorithm revision.
Changing only BA3’s normalization algorithm invalidates the second artifact, not the first. BA3 re-normalizes the retained provider response locally and does not upload audio or submit another paid pyannoteAI job. Changing the model, speaker count, backend, audio bytes, or preparation recipe changes the raw identity and therefore requires inference.
BA3 validates schema versions, fingerprints, backend provenance, provider job identity, speaker labels, interval direction, and ordering. A missing raw entry permits inference; a corrupt raw or derived entry fails the file instead of being treated as a miss and silently causing another billable call. Concurrent identical requests in one BA3 server are serialized: the first miss performs and commits inference, while followers wait and then replay the result.
BA3 also rereads the source after a speaker-cache miss and verifies that its bytes still match the digest used for the cache decision. Speaker inference is prepared from that verified in-memory copy. If another process replaces the media between lookup and inference, the file fails instead of running a paid job under the wrong cache identity.
Raw Rev.AI transcript evidence
Normal transcribe, benchmark, and Rev-backed align UTR runs check durable
raw Rev evidence before submitting anything to Rev. Only a missing entry, or
an explicit --override-media-cache, authorizes submission. Corrupt evidence
fails closed without a service call, and concurrent identical requests are
coalesced. Concurrent identical forced refreshes also share the first fresh
commit rather than each issuing a sequential paid call.
The key includes the full bytes of the provider-visible inference media, media preparation recipe, normalized upload filename, multipart MIME type, requested language, expected speaker count, Rev request-policy revision, provider/model alias, and request-identity revision. The stored envelope contains the resolved language plus the exact admitted transcript response JSON bytes. It does not store credentials or temporary job IDs.
Before a paid call, BA3 rereads the prepared source and verifies that the exact bytes still have the digest used for the cache decision. If the file changed between preparation and submission, the run fails instead of uploading different bytes under the old key. The authorization is then consumed into one evidence-inference run plus a separate commit permit; the inference capability cannot be cloned into repeated runs. An auto-language run intentionally contains both Rev language identification and transcription requests.
Request-identity revision 2 names this stronger provider presentation. Entries
from revision 1 do not satisfy the new keys. Storage schema 3 adds exact JSON
retention without changing the revision-2 request key: schema-2 entries remain
replayable and are explicitly traced as legacy_typed_projection, while new
provider responses are traced as exact_provider_json. Replay-only mode never
turns either storage migration into a paid call.
For controlled Rev transcribe and Rev-backed align experiments,
--debug-dir PATH additionally writes versioned *_rev_evidence.json causal
sidecars. Each records the keyed media and
multipart presentation, request identity, replayed versus fresh-miss outcome,
raw evidence key, transcript fidelity, and deterministic projection revision
without exposing the credential or local source path.
Dedicated-speaker transcribe runs likewise write
*_speaker_evidence.json. This joins the source digest, request/model
semantics, raw and derived cache identities, cache outcome, normalization
revision, segment-projection revision, segment count, and a versioned digest
of the exact normalized timing/label projection. The companion .turns.json
holds those normalized segments in the canonical review format.
BA3’s regression suite exercises this at the complete Rust transcribe-pipeline boundary, not only at the cache row. It closes and reopens SQLite, replays the same retained Rev response, and requires identical final CHAT plus identical ASR-response debug output with no second inference call. The Rev causal sidecars intentionally differ in exactly one meaning: the cold run records a fresh missing-key inference and the warm run records replay. Their media, request, retained-evidence, and projection identities must remain equal.
The old batch pre-submission shortcut has been removed because it submitted paid jobs before a cache hit could be known. Cold misses currently fan out through BA3’s normal per-file worker limit. Reintroducing wider parallel submission is a performance follow-up; it must consume the same typed miss authorization and cannot restore the old unguarded optional-job-ID path.
Other ASR engines remain uncached in ordinary transcribe runs. Align’s UTR
ASR also keeps its older normalized-result cache. If that derived entry is
missing, Rev-backed UTR can re-project the durable raw transcript without a
service call. A corrupt normalized UTR entry fails closed instead of silently
falling through to inference. The legacy Rev pre-submission path no longer
exists.
How to guarantee cache-backed stages do not infer
Use --require-media-cache for replay-only experiments:
batchalign3 --require-media-cache transcribe recordings/ -o output/ \
--asr-engine rev --diarization enabled
batchalign3 --require-media-cache align corpus/ -o output/
For every cache-backed stage reached by the command, a reusable hit is
required. Missing raw Rev or speaker evidence fails the file before an
inference authorization can be constructed, so the miss cannot become a Rev
or pyannoteAI call. Forced alignment likewise refuses to send missing groups
to its worker. Existing clean %wor evidence can still satisfy an FA group.
Raw and derived evidence remain separate. If normalized speaker turns are missing but the backend-shaped speaker response exists, BA3 derives and stores new turns locally. Rev-backed UTR can similarly rebuild its normalized UTR entry from retained raw Rev evidence; if the raw entry is also missing, the raw-evidence gate refuses the provider call.
Forced alignment also keeps raw and derived layers. On a normal hit, BA3 prefers the admitted worker response and reruns the current local timing projection; an admitted versioned derived envelope is the fallback when raw evidence is absent or refused. Both envelopes prove the requested engine, selected-worker version, semantic group key, and word cardinality. Historical bare timing vectors are refused and treated as misses because they cannot prove whether the result came from the requested engine or an unversioned fallback. This means experiments with Rust-side timing interpretation can reuse identified direct model work automatically.
A live Wave2Vec-to-Whisper fallback is intentionally not cached today. It is
valid for the current output and is recorded in the debug trace, but the Wave
request’s version namespace does not identify the effective Whisper model.
Persisting that response would make later replay ambiguous, so a future run
repeats the fallback. --override-media-cache and
--override-media-cache-tasks forced_alignment bypass both FA layers and
therefore request fresh model inference.
When a current build first opens an older cache, it also removes from live
lookup any legacy raw FA row whose requested engine contradicts the model
family in its stored namespace. Those rows did not retain an exact producer
version and cannot be safely reused or relabeled. Their exact stored bytes are
retained in the database’s cache_quarantine table for audit. This cleanup can
turn an apparent historical hit into an honest miss;
--require-media-cache still refuses that miss without running inference.
batchalign3 cache stats reports the quarantine total and its stable reason
counts separately from reusable entries.
This flag is not a general offline, no-network, or zero-compute mode. Ordinary non-Rev ASR output is not cached, so Whisper, Tencent, Aliyun, FunAudio, or Qwen transcription can still run its configured inference path, including a network service where that backend uses one. OpenSMILE and AVQI are outside the analysis cache. The guarantee is specifically that a missing entry at a cache-backed boundary cannot authorize inference.
--require-media-cache is mutually exclusive with
--override-media-cache and --override-media-cache-tasks: one run cannot
both require existing evidence and request fresh evidence.
What invalidates the cache
| What changed | What re-runs | What stays cached |
|---|---|---|
| Edited the transcript words | FA (per-group cache key includes text) | UTR ASR (only depends on audio) |
| Re-recorded or replaced the audio | FA, UTR ASR, Rev evidence, speaker evidence | (n/a, audio is the cache key) |
| Changed the language code | UTR ASR and Rev evidence | (other corpora’s entries) |
| Changed expected speaker count | Rev evidence and speaker evidence | FA and UTR ASR |
| Changed speaker backend | Speaker evidence | FA, UTR ASR, and Rev evidence |
| Changed only the speaker normalization algorithm | Derived speaker segments | Raw speaker inference evidence |
| Upgraded batchalign or an identified model revision | Affected audio evidence | Entries from unchanged engines/models |
Cache keys hash the inputs relevant to each task. FA and UTR use the legacy
path/mtime/size AudioIdentity; Rev and speaker evidence use a true digest of
the inference-media bytes. Rev also keys provider-visible presentation, so
copies and renames share results only when their normalized upload extensions
match. Engine or model revision strings are stored alongside each entry.
pyannoteAI currently exposes the precision-2 model alias, but not an
immutable backend build hash. BA3 scopes cloud evidence to that alias and its
own evidence schema. If the provider changes the implementation behind the
same alias and you want fresh evidence, use --override-media-cache. The
local Pyannote and NeMo identifiers likewise include their configured model
identity and the BA3 package version; floating external model revisions remain
a reason to force a refresh during controlled experiments.
How to force fresh results
Use the --override-media-cache global flag:
batchalign3 --override-media-cache align corpus/ -o output/
# Force and store fresh Rev and dedicated speaker evidence. This may incur charges.
batchalign3 --override-media-cache transcribe recordings/ -o output/ \
--diarization enabled
This skips all applicable cache lookups, forcing fresh inference. New results
replace the matching entries and are stored for future runs. With
Rev ASR or --speaker-engine pyannote-ai, this can make new paid service calls
even when reusable evidence exists.
The narrower --override-media-cache-tasks flag accepts forced_alignment,
utr_asr, rev_asr_evidence, and
speaker_diarization_raw_evidence. This permits a controlled transcribe run to
refresh Rev while replaying speaker evidence, or the reverse, instead of
repeating both paid boundaries.
Use this when you suspect cached results are wrong, or after manually updating model files outside of a normal batchalign upgrade.
UTR ASR results after an upgrade
UTR ASR results are stored under a namespace naming the timing-recovery engine
AND the models it ran, for example
utr-asr-v1:whisper_utr:whisper|asr=openai/whisper-large-v3@06f233fe....
Earlier builds stored them under the forced-alignment engine’s version instead,
and then under the recovery engine’s name alone. The first align run on this
build therefore misses those older UTR ASR entries once, recomputes them, and
stores them under the new namespace; later runs hit as before.
Forced-alignment entries are unaffected, because their namespace did not
change: it is still exactly the forced-alignment engine name the worker
reports.
Naming the models is what makes an upgrade safe rather than merely noticed. Upgrading a recovery model now lands in a different namespace, so results produced by the previous weights are never reused for the new ones. Nothing reads the older rows: a namespace move makes them unreachable by construction, so there is no compatibility path to keep true.
This costs one recompute, not two. The namespace already moved once in this release, and the model identity was folded into that same move deliberately.
With --require-media-cache, that one recompute is refused like any other
miss for Whisper and Tencent recovery. Rev recovery can still rebuild the
entry from retained raw Rev evidence without a new provider call.
Where the caches are stored
| Cache | macOS default | Linux default |
|---|---|---|
| Analysis cache DB | ~/Library/Caches/batchalign3/cache.db | ~/.cache/batchalign3/cache.db |
| Media conversion cache | ~/Library/Application Support/batchalign3/media_cache/ | ~/.local/share/batchalign3/media_cache/ |
The analysis cache is a single SQLite database file. The media cache
stores converted WAV artifacts for inputs such as .mp4 and .m4a.
For isolated runs or testing, you can relocate them with environment variables:
export BATCHALIGN_ANALYSIS_CACHE_DIR=/tmp/ba-analysis-cache
export BATCHALIGN_MEDIA_CACHE_DIR=/tmp/ba-media-cache
BATCHALIGN3_ANALYSIS_CACHE_DIR is accepted as an alias for the analysis
cache setting. If both spellings are set, the canonical
BATCHALIGN_ANALYSIS_CACHE_DIR value wins. Set the variable before starting a
server: a client process cannot relocate the cache owned by an already-running
server.
How to clear the cache
Use the built-in cache command:
batchalign3 cache stats # See cache size and entry count
batchalign3 cache clear --yes # Clear the cache
cache stats and cache clear operate on both the analysis cache and
the media conversion cache.
Or delete the cache.db file and/or the media-cache directory directly.
To selectively refresh without clearing everything, use
--override-media-cache on specific runs instead, old entries for
other corpora remain available.
Old text-NLP cache entries
If you used batchalign before the text-NLP cache was removed, your
cache.db may still contain old morphosyntax_v*, utseg_v*, and
translate_v* rows. Those are dead weight, they’re never read
anymore. Run batchalign3 cache clear --yes (or rm -f ~/Library/Caches/batchalign3/cache.db*) to reclaim the disk space.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Server Mode
Status: Current Last updated: 2026-09-16 09:47 EDT
Batchalign includes a built-in HTTP server managed by batchalign3 serve ....
Ordinary local processing commands can still run inline, but when
auto_daemon: true (the default) the CLI first tries to reuse or start a
loopback daemon so warm workers survive across commands. --no-server and
--sequential still force direct local execution.
Current routing rules
- With
--server URL, the CLI submits supported jobs to that server in content mode. transcribe,transcribe_s,benchmark, andavqiprefer the local daemon whenauto_daemonis enabled.- Without an explicit remote target,
auto_daemon: truemakes the CLI reuse or start a loopback daemon before it falls back to direct local execution. - Local-daemon and auto-detected loopback-server paths use shared-filesystem
paths_modefor local-audio commands such asalign,transcribe,benchmark,opensmile, andavqi. - Explicit
--serveralways stays on content mode, even when the URL islocalhost.
Build identity check
Before it submits anything, the CLI reads the server’s /health and compares
its build_hash with the CLI’s own build identity. This applies to every
server the CLI submits to: an explicit --server, the local daemon, and a
loopback server it detects. If the server reports another build, or no build
at all, the command is refused before any job is submitted, so no work runs,
and it exits with code 5 (server/job lifecycle error). The message names
both builds and the remedy: restart that server with this build (on its host,
batchalign3 serve stop, then batchalign3 serve start) and run the command
again.
A server left running across an upgrade would otherwise produce results with the older build’s engines and fixes, which the CLI would then write as if this build had produced them. Earlier builds only printed a warning and ran the job anyway.
The same rule governs REUSE, not only submission. When the CLI finds a
manually started server on the port that server published, it reads that
server’s /health before adopting it: on this build the server is reused, and
on another build, or one reporting no build at all, the command is refused
there with the same message and the same exit code 5. Earlier builds printed
a warning at that point and reused the server anyway, leaving the refusal to
happen later at submission. A server that answers nothing recognizable is not
reused either; the CLI falls through to its own daemon path, which probes the
configured port and reports what holds it.
Backend model
The server now has a single local in-process control plane.
- There is no Temporal backend and no backend-selection config.
- Job detail surfaces still report
control_plane.backend, but the only released value islocal. - On restart, in-flight work from the old process does not continue running in place. Recovery reloads queued/interrupted work from SQLite and re-dispatches resumable jobs when the server comes back up.
- A recovered file that had finished is named by that command’s primary output artifact, with that artifact’s content type, rather than by the input file it was produced from. The persisted file-status rows name inputs, not artifacts, so a recovered result would otherwise be offered under the wrong name.
Start a server
Foreground:
batchalign3 serve start --foreground
Background:
batchalign3 serve start
Useful flags:
batchalign3 serve start --foreground --port 8000
batchalign3 serve start --foreground --config ~/server.yaml
batchalign3 serve start --foreground --test-echo
Check and stop a server
batchalign3 serve status
batchalign3 serve status --server http://myserver:8000
batchalign3 serve stop
Inspect remote jobs:
batchalign3 jobs --server http://myserver:8000
batchalign3 jobs --server http://myserver:8000 <JOB_ID>
Server configuration
Default config path:
~/.batchalign3/server.yaml
Minimal example:
default_lang: eng
port: 8000
max_concurrent_jobs: 8
auto_daemon: true
media_roots: []
media_mappings: {}
Important keys:
port: server listen porthost: bind address (defaults to0.0.0.0)max_concurrent_jobs:0means auto-tuneauto_daemon: reuse or start a loopback daemon for ordinary CLI processingmedia_roots: local execution-host media lookup rootsmedia_mappings: local execution-host root mappings from corpus paths to mounted media pathsmemory_tier: override auto-detected tier:small,medium,large,fleetmemory_gate_mb: host headroom reserve in MB (default: 2048)gpu_startup_mb/stanza_startup_mb/io_startup_mb: per-profile startup reservation overridesworker_health_interval_s: health check frequency in seconds (default: 30)job_ttl_days: auto-delete completed jobs after this many days (default: 7)
OTLP tracing can be enabled by setting BATCHALIGN_OTLP_ENDPOINT
(or OTEL_EXPORTER_OTLP_ENDPOINT) in the server environment.
server.yaml uses a strict schema. Unknown keys are rejected at startup
instead of being silently ignored, so stale config must be updated to the
current key set.
Remote use
Commands that support explicit remote dispatch look like this:
batchalign3 --server http://myserver:8000 morphotag corpus/ -o output/
batchalign3 --server http://myserver:8000 align corpus/ -o output/
For audio commands, --server now means “run this on a host that can already
see these filesystem paths.” The clean operational model is to run the CLI on
the execution host itself (or to reach it over SSH/VNC) rather than expecting
the server to infer media from a different client machine’s directory layout.
When the corpus clone root and the mounted media root differ on that execution
host, use local media_mappings or --media-dir as explicit root replacement.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Server and Fleet Setup
Status: Current Last updated: 2026-08-30 19:35 EDT
Overview
Batchalign3 can run as a persistent server that accepts jobs from remote clients. This is useful when multiple people share one powerful machine, when you want warm workers to survive across commands, or when audio files live on a central server instead of on each laptop.
Architecture
┌───────────────────────────────────────┐
│ Server machine (GPU, lots of RAM) │
│ │
│ ┌───────────────────────────────┐ │
│ │ batchalign3 server (port 8001)│ │
│ └───────────────────────────────┘ │
│ │ │
│ Python workers │
│ (Stanza, Whisper, etc.) │
└───────────────────────────────────────┘
▲ ▲
Laptop A Desktop B
--server URL --server URL
Clients use --server http://server:8001 to send work. The server dispatches
to Python workers, manages job lifecycle, and returns results.
Single-machine server
1. Install batchalign3 on the server
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/FranklinChen/talkbank-tools/releases/latest/download/install-batchalign3.sh | sh
2. Configure batchalign3
Create ~/.batchalign3/server.yaml:
port: 8001
host: "0.0.0.0"
max_concurrent_jobs: 4
# Map data repository names to media file locations.
media_mappings:
my-corpus: /path/to/audio/files
another-corpus: /path/to/more/audio
3. Start the server
batchalign3 serve start --port 8001 --host 0.0.0.0 -v
4. Connect from clients
On any machine that can reach the server:
batchalign3 --server http://server:8001 morphotag corpus/ -o output/
batchalign3 --server http://server:8001 align corpus/ -o output/
Shared media via NFS or mounted storage
For audio commands (align, transcribe), the execution host must be able to
read the media files.
Recommended approach:
- Export or mount the media directories on the server at a canonical path.
- Configure
media_mappingsso corpus-relative roots resolve to that mounted storage. - Run remote submissions against the server that can already see those paths.
Server management
# Check server status
batchalign3 serve status
# Stop the server
batchalign3 serve stop
# View server health
curl http://localhost:8001/health | python3 -m json.tool
After the server has run a Python-hosted task, the health response includes one
entry in worker_runtime_identities. These path-free SHA-256 values identify
the interpreter, installed Batchalign code, and installed distribution
inventory that actually executed worker requests. An empty list means no
current-protocol local Python worker has been observed since server startup;
it does not mean the server guessed the identity from whichever python is on
your shell path. A second worker with different code is refused before it can
run a job, so the field never presents several possible producers.
The package digest covers executable package source, native modules, and data;
generated bytecode, hidden scratch, and the package’s test subtree are excluded
so parallel test-runner bookkeeping cannot masquerade as a runtime change.
curl -s http://localhost:8001/health | \
python3 -c 'import json,sys; print(json.load(sys.stdin)["worker_runtime_identities"])'
Direct mode vs server mode
| Aspect | Direct mode | Server mode |
|---|---|---|
| Setup | None | server.yaml + batchalign3 serve |
| Model loading | ~4-7s on first run | Warm workers can be reused |
| Crash recovery | None (restart manually) | SQLite-backed recovery requeues resumable work on next server start |
| Multi-user | No | Yes (concurrent jobs) |
| Remote audio | Must be local | Via shared storage / media_mappings |
| Monitoring | Terminal output | Web dashboard |
Most users should start with direct mode. Server mode is for teams managing shared infrastructure.
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Rev.AI Integration
Status: Current Last updated: 2026-09-07 07:04 EDT
Rev.AI is the default ASR engine for batchalign3 transcribe, and the default
UTR engine for batchalign3 align.
In server mode, those Rev.AI paths are now Rust-owned end to end: the server submits or polls Rev.AI jobs directly and keeps Python reserved for engines that genuinely require Python-hosted model libraries.
Configure a Rev.AI key
Interactive setup:
batchalign3 setup
Non-interactive setup:
batchalign3 setup --non-interactive --engine rev --rev-key <YOUR_REV_AI_KEY>
This writes the key and default engine selection to ~/.batchalign.ini.
Use Rev.AI explicitly
batchalign3 transcribe recordings/ -o transcripts/ --asr-engine rev --lang eng
batchalign3 align corpus/ -o aligned/ --utr-engine rev
Speaker labels, utterance segmentation, and --diarize
- Rev.AI already returns first-pass speaker labels. BA3 applies those labels by default, so plain Rev transcription already produces multi-speaker output.
- BA3 still performs its own utterance segmentation after ASR; speaker attribution and utterance boundary detection are separate steps.
- If you pass
--diarize(or--diarization enabled), BA3 runs the separate speaker stage even on top of Rev output. Dedicated labels replace Rev’s speaker projection, are applied to timed ASR words, and split chunks before utterance segmentation. pyannoteAI Precision-2 is the default;--speaker-engine pyannoteandnemoselect local alternatives.
Provider-visible audio format matters
Rev.AI can return different words, timings, confidence values, and speaker boundaries for perceptually equivalent encodings of one recording. In a controlled 94-clip test, submitting original MP3 bytes versus decoded PCM16 WAV changed the lexical response in 81 cases and measured speaker/monologue boundaries in 54; matched word starts and ends shifted by a median 40 ms. This proves sensitivity, not that WAV is inherently more accurate. Individual clips showed plausible gains and losses, so a default-format change needs blinded quality adjudication against the audio.
BA3’s durable Rev cache therefore keys the exact provider-visible media bytes and their upload presentation, not an assumption that two encodings “sound the same.” Re-encoding produces a different evidence entry and can incur a new Rev call. Renaming or copying byte-identical media with the same extension reuses the existing entry; changing the extension deliberately does not, because the multipart filename is provider-visible.
Current production preparation preserves source bytes. It presents them with a
stable digest-derived metadata label, a normalized filename that retains only
the source extension, and BA3’s historical audio/mpeg multipart type. This
describes the current request exactly; it is not a claim that the historical
MIME choice is ideal. Alternative PCM, FLAC, MIME, or padding recipes must be
revisioned and evaluated as separate evidence identities before becoming a
default.
Retain a reproducible Rev evidence record
Add --debug-dir PATH to a Rev transcription or Rev-backed align run to
write fail-closed *_rev_evidence.json sidecars. They join the source and
provider-media BLAKE3 digests, preparation recipe, exact multipart
filename/MIME/metadata, language, speaker count, request-policy and model
revisions, raw cache key, cache outcome, exact-versus-legacy transcript
fidelity, and local projection revision. The sidecar contains no credential or
machine-local source path. If requested
evidence cannot be serialized or durably written, the file fails instead of
silently completing without the research record.
A transcribe file normally has one record. Align may have one full-file record or several stable cache-keyed records when UTR analyzes partial audio windows.
Use a local model instead
If you do not want cloud ASR, use a local Whisper model:
batchalign3 transcribe recordings/ -o transcripts/ --asr-engine whisper --lang eng
For Rust-native whisper.cpp, run in-process with no Python worker:
batchalign3 transcribe recordings/ -o transcripts/ --asr-engine whisper_rs --lang eng
When an upload fails
A failed Rev.AI upload is reported as a provider failure, never as a validation failure, and the distinction is the one that decides what you should do next:
error_category: provider_transient: the connection dropped, the request body could not be delivered, or Rev.AI answered 5xx. Nothing about your request was wrong; the same files are worth submitting again.error_category: provider_terminal: Rev.AI refused the request (4xx) or failed the job. Repeating it will not help; read the message.
The message carries the provider error together with its full cause chain, and
for an exhausted upload it lists what happened on each of the three
attempts rather than only the last. A transport fault whose top line reads
request or response body error for url (...) will name the underlying socket
condition after the colon.
Until 2026-09-03 every Rev.AI failure was flattened into
error_category: validation, which reads as “you sent bad input” and hid the
fact that a batch was worth retrying.
Privacy note
Using Rev.AI sends audio to an external service. Enabling the default pyannoteAI speaker engine sends it to a second external service. If your workflow has data-use or IRB constraints, review both accounts and your local policy before sending production data.
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Performance
Status: Current Last updated: 2026-07-30 18:21 EDT
This page covers what to expect from Batchalign’s processing times and how to improve throughput.
Cold vs warm starts
The first run of any command downloads ML models and initializes them, expect 5-20x longer than subsequent runs. After the first run:
- Model cache: Stanza, Whisper, and other ML models are cached on disk (~2 GB total). They load from cache on subsequent runs.
- Server warmth: When an explicit server is running, workers can stay warm in memory across multiple jobs. Direct local execution does not keep a daemon alive between CLI invocations.
- Analysis cache: Batchalign caches audio-bound intermediate
results (forced-alignment word timings, UTR ASR) in a local SQLite
database keyed by content hash. Re-running
alignortranscribeon the same audio reuses these and is much faster. Text-NLP commands (morphotag,utseg,translate,coref) are not cached: see Caching.
| Scenario | Relative Speed |
|---|---|
| First run (model download + init) | 1x (baseline) |
| Cold start (models cached on disk) | 3-5x faster |
| Warm server (models in memory) | 5-20x faster |
Cached audio task (align / transcribe UTR re-run) | Near-instant |
Worker count
By default, Batchalign uses one worker per command. For batch processing of many files, increase the worker count:
batchalign3 morphotag ~/corpus/ -o ~/output/ --workers 4
Each worker loads its own copy of the ML models. Memory usage scales linearly with worker count, see the memory section below.
CPU vs GPU
Batchalign automatically uses GPU acceleration when available (CUDA on Linux, MPS on macOS). To force CPU-only processing:
batchalign3 morphotag ~/corpus/ -o ~/output/ --force-cpu
CPU-only is slower but uses less memory and avoids GPU driver issues. On machines without a supported GPU, CPU mode is selected automatically.
Memory patterns
Memory usage depends on the command and number of workers:
| Command | ~Memory per Worker |
|---|---|
morphotag | 1-2 GB (Stanza models) |
align | 2-4 GB (Whisper/Wave2Vec) |
transcribe | 2-4 GB (Whisper + diarization) |
translate | 1-2 GB (translation model) |
utseg | 1-2 GB (constituency parser) |
compare | <500 MB (no ML models, gold-vs-hypothesis WER scoring) |
With --workers N, total memory is roughly N * per-worker cost. The Rust
runtime adds minimal overhead (~50 MB).
Lazy audio loading: Audio files are loaded on demand and released after processing, memory does not grow with corpus size, only with concurrent workers.
Server mode for warm models
For repeated interactive use, keep models loaded in the background:
batchalign3 serve start
Subsequent commands automatically connect to the running daemon. Stop it when done:
batchalign3 serve stop
See Server Mode for configuration details and Worker Tuning for memory budgets and tuning.
The bench command
Measure processing throughput on your hardware. The shape is
bench <command> <in_dir> <out_dir>: both directories are required
positional arguments:
batchalign3 bench morphotag ~/sample-corpus/ ~/bench-out/ --workers 1
batchalign3 bench morphotag ~/sample-corpus/ ~/bench-out/ --workers 4
This runs the command with timing instrumentation and reports files/second and
wall-clock time per file. Use --runs N to repeat the run, --use-cache to
keep cache lookups enabled (the default is to bypass cache for clean
benchmarks), and --dataset <label> to tag structured output.
Estimated times per command
Rough estimates for a single file (~100 utterances) on a modern laptop with warm daemon:
| Command | Warm Daemon | Cold Start |
|---|---|---|
morphotag | 2-5 seconds | 30-60 seconds |
align | 5-15 seconds | 45-90 seconds |
transcribe | 10-60 seconds (depends on audio length) | 60-120 seconds |
translate | 2-5 seconds | 30-60 seconds |
utseg | 3-8 seconds | 30-60 seconds |
compare | <1 second | <1 second |
Times vary significantly with hardware, file size, and language. GPU acceleration typically provides a 2-5x speedup for model inference.
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
Processing Provenance
Status: Current Last updated: 2026-09-16 10:19 EDT
What is provenance?
Every time batchalign3 processes a CHAT file, it records what it did in a
@Comment header. This creates a machine-readable processing history
inside the file itself.
Format
Batchalign3 provenance comments use a structured format inside square brackets:
@Comment: [fc-ba3 morphotag | engine=stanza-1.11.1:eng:standard ; lang=eng | 2026-09-15T18:30:00-04:00]
The format is: [fc-ba3 <command> | <key>=<value> ; ... | <timestamp>]
fc-ba3: identifies this as a batchalign3 provenance comment- command: which operation was performed
- key=value pairs: engine identities and options that affect output
- timestamp: ISO 8601 with timezone, when processing occurred
Values never contain |, ;, ] or a line break, and never begin or end
with whitespace, so a comment always reads back the way it was written. A
comment is always one line, however long it is. If a checkpoint you select for
transcribe (for example a custom model name) breaks that rule, the job is
refused when you submit it, before any work runs, rather than writing a
comment that would read back differently.
Older files: [ba3 ...]
Files processed before 2026-09-15 carry the same format under the name ba3
([ba3 morphotag | ... | ...]). A file keeps whichever name wrote it.
Batchalign3 treats both names as its own: re-running a command replaces the
older stamp, and reading a file’s processing history (for example in the
dashboard) shows both.
If a comment starts like one of these stamps but is damaged (for example the
closing ] is missing, or a field has no =), reading that file’s processing
history reports an error naming the file instead of quietly leaving the entry
out.
Example: Multiple Commands
When you run morphotag, then align on the same file, both comments accumulate:
@UTF8
@Begin
@Languages: eng
@Participants: CHI Target_Child
@ID: eng|test|CHI|2;0.||||Target_Child|||
@Comment: [fc-ba3 morphotag | engine=stanza-1.11.1:eng:standard ; lang=eng | 2026-09-15T18:30:00-04:00]
@Comment: [fc-ba3 align | fa=whisper-fa-large-v2 ; lang=eng | 2026-09-15T19:15:00-04:00]
*CHI: the dog is running . 0_4500
%mor: det|the-Def-Art noun|dog aux|be-Fin-Ind-Pres-S3 verb|run-Part-Pres-S .
%gra: 1|2|DET 2|4|NSUBJ 3|4|AUX 4|0|ROOT 5|4|PUNCT
@End
Re-running a command
If you re-run morphotag on a file that already has a morphotag provenance comment, the old comment is replaced: not duplicated. Comments from other commands (align, transcribe, etc.) are preserved.
Whether the file is rewritten depends on what changed:
- Not rewritten when the only difference is the comment’s timestamp or the
older
ba3name. The same holds for the transcribe warning below when only the build it names, or its older wording, differs. - Rewritten when any recorded value differs: a different engine, a
different spelling of the engine, a language, or a flag such as
retokenize. The comment then states what produced the file now. - Not rewritten for a damaged comment that belongs to a different command, or that is too damaged to say which command wrote it, when it is the same in both. Only the command that wrote a comment replaces it, so rewriting the file would repair nothing. A damaged comment belonging to the command you are running IS replaced, and comes back well formed.
A command that runs other stages writes their comments too: transcribe
writes transcribe, utseg and morphotag comments. Every comment the
command writes is compared this way, so re-running transcribe with a newer
batchalign3 does not rewrite a file when the transcript and every recorded
value come out the same.
Practical consequence: files stamped by older builds with a different
engine= spelling (for example engine=stanza-1.11.1 or
engine=stanza-1.11.1:eng) are rewritten the next time you run morphotag over
them, even if %mor and %gra come out the same. Expect that one-time churn
when re-running over an existing corpus.
What each command records
morphotag
[fc-ba3 morphotag | engine=stanza-1.11.1:eng:standard ; lang=eng | ...]
| Key | Meaning |
|---|---|
engine | Each Stanza model that analyzed the file, as stanza-<version>:<language>:<pipeline>, joined with + in text order when more than one ran (for example a secondary language) |
lang | Language code |
retokenize | Present if CJK retokenization was applied |
incremental | Present if --before incremental mode was used |
ud_repairs | How many dependency relations had to be repaired. Present only when at least one was |
The pipeline names which analysis ran: standard, mandarin_retokenize
(Mandarin with retokenization), or cantonese_pycantonese_pos (Cantonese with
PyCantonese part-of-speech tagging).
The engine is the one the worker that ran the analysis reported, so a
morphotag stage inside transcribe names Stanza rather than the ASR engine.
When no model analyzed anything (for example, only utterances without words),
engine is absent rather than guessed.
Repaired relations
Stanza does not promise that the dependency relations it produces are
Universal Dependencies relations. When one is not, morphotag repairs it rather
than writing it into %gra: a padding label (<PAD>) or an unrecognised
label becomes dep, a relation in the wrong case is lowercased, and a known
non-UD spelling is replaced by the UD relation it means (iob becomes
iobj). Subtypes are never touched: nmod:poss and acl:relcl are
legitimate and language-specific.
Those repairs are counted in the comment:
[fc-ba3 morphotag | engine=stanza-1.11.1:ita:standard ; lang=ita ; ud_repairs=3 | ...]
ud_repairs=3 means three relations in this file were repaired. The key is
absent when nothing was repaired, so there is no ud_repairs=0 to read: no
key means no repairs. A file tagged by a build from before this key existed
says nothing either way.
A high count is worth looking at. It means the model for that language is
producing labels outside UD often, which is a fact about the model rather than
about the transcript, and the transcript now records it instead of leaving it
in a worker log nobody keeps. With --before, the count covers the whole file:
the repairs behind the tiers kept from the --before file, plus this run’s.
With --before, morphotag reanalyzes only the utterances you changed and keeps
the other %mor and %gra tiers from the --before file. The comment then
carries incremental=true, and engine names every model behind the file’s
tiers: the models the --before file’s morphotag comment named, together with
the models used for the reanalysis, each once, in text order. If no model ran
(nothing needed reanalysis, or the changed utterances had no words or were in
an unsupported language), the file’s existing morphotag comment is kept as it
is.
The command fails, rather than writing a comment a later run could not read
back, when the --before file’s morphotag comment is damaged or names an
engine that cannot be written back, or when a model used now has + in its
name.
align
[fc-ba3 align | fa=whisper-fa-large-v2 ; lang=eng ; utr=rev | ...]
| Key | Meaning |
|---|---|
fa | Forced alignment engine the worker reported |
lang | Language code |
utr | Timing recovery engine (rev, whisper, tencent), present only if a recovery pass ran |
wor | Present if %wor tier was written |
incremental | Present if --before incremental mode was used |
transcribe
[fc-ba3 transcribe | asr=rev ; lang=eng | ...]
| Key | Meaning |
|---|---|
asr | ASR engine (rev, whisper, tencent, aliyun, funaudio) |
asr_model | The models that produced the transcript, each at the revision it was loaded at: <id>@<revision>, with +<role>:<id>@<revision> for any helper model (a forced aligner, a voice-activity or punctuation model). Written for every engine. A cloud service records the service and the model type it was called with, never an account credential |
lang | Language code |
diarize | Present if speaker diarization was enabled |
wor | Present if %wor tier was written |
Transcribe also writes a human-readable warning:
@Comment: fc-ba3 <build identity>, ASR engine rev. Unchecked output of ASR model, DO NOT USE.
The build identity names the exact build that produced the file. Re-running
transcribe replaces this warning, including the older form
Batchalign <version>, ASR Engine <engine>. Unchecked output of ASR model.,
but leaves any other comment alone.
When the engine name is followed by a parenthetical, it names the models that
produced the text, in the same form the asr_model= field uses, for example
ASR engine funaudio (FunAudioLLM/SenseVoiceSmall@<commit>+vad:funasr/fsmn-vad@<commit>).
The two always agree, because both are rendered from the same value.
A file whose timings were rebuilt by replaying older evidence carries no parenthetical: nothing in that run reported which models it loaded, and the warning says less rather than repeating what was requested as though it had been checked.
utseg
[fc-ba3 utseg | engine=stanza-constituency ; lang=eng | ...]
engine names the source of the boundaries that were applied: a boundary
model as <model id>@<revision> (or its id alone when the worker exposed no
revision), or stanza-constituency. Several sources are joined with + in
text order. A source that does not name itself is not given a placeholder
name: the file gets no utseg comment, and the job’s per-file record says why.
Files segmented by a build before 2026-09-15 carry no utseg comment, because
the standalone utseg command wrote none. Re-running utseg over them adds
one.
translate
[fc-ba3 translate | engine=googletrans-v1 ; lang=spa | ...]
engine names the engines that produced the translations applied, as each
translation reported them, joined with + in text order. A file where nothing
was translated gets no comment.
Files translated by a build before 2026-09-15 carry no translate comment,
because the batch path wrote none. Re-running translate over them adds one,
and also re-translates from what was spoken (see
translate).
coref
[fc-ba3 coref | engine=stanza-1.11.1/ontonotes-singletons_roberta-large-lora ; lang=eng | ...]
engine names the model that produced the chains (the Stanza release and the
coreference package), as the result reported it.
A file with nothing resolved gets no comment, and neither does a non-English
file, which passes through untouched.
Files processed by a build before 2026-09-15 carry no coref comment, because
the batch path wrote none. Re-running coref over them adds one.
Parsing provenance programmatically
The stamp names make provenance comments easy to extract. Match both names so older files are included:
# Find all provenance comments in a file
grep -E '\[(fc-)?ba3 ' file.cha
# Find all files that were morphotagged
grep -rlE '\[(fc-)?ba3 morphotag' corpus/
In Python:
import re
PROVENANCE_RE = re.compile(
r'^\[(?:fc-)?ba3 (\w+) \| (.*?) \| (\S+)\]$'
)
with open('file.cha') as f:
for line in f:
if line.startswith('@Comment:'):
content = line.split('\t', 1)[1].strip()
m = PROVENANCE_RE.match(content)
if m:
command = m.group(1) # "morphotag"
fields = m.group(2) # "engine=stanza-1.11.1:eng:standard ; lang=eng"
timestamp = m.group(3) # "2026-09-15T18:30:00-04:00"
From the server, the job results endpoints return each file’s provenance already parsed, as one of three states:
{"kind": "parsed", "entries": [{"command": "morphotag", "fields": {"lang": "eng"}, "timestamp": "..."}]}
{"kind": "unparseable", "reason": "provenance stamp \"[fc-ba3 align]\" does not have a command and a timestamp separated by ` | `"}
{"kind": "not_read"}
not_read is non-CHAT output or a file that failed. A damaged stamp is
reported for that file only; the other files in the job are served normally.
When a file carries no comment
A command that writes per-file provenance records what it decided on the file’s own status, so “no comment” is an answer with a reason rather than an absence you have to interpret:
{"kind": "stamped", "command": "translate"}
{"kind": "not_stamped", "command": "translate", "reason": "no engine produced anything that was applied to this file"}
{"kind": "unrecorded"}
unrecorded means no stamp decision was recorded: a command that writes no
per-file comment, or a file whose status was rebuilt from the job database
after a server restart (the decision is not persisted).
What is NOT recorded
Runtime options that don’t affect output are omitted:
--workers(concurrency)--timeout(inference timeout)--server(where processing happened)--verbose(logging)--tui/--no-tui(display)
These are operational, not semantic.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Decision evidence and legacy review tiers
Updated: 2026-08-30 19:35 EDT
Batchalign3 records consequential pipeline decisions as structured run
evidence. It does not generate %xalign or %xrev tiers in CHAT output.
This is true for align, morphotag, and every value of the legacy
--review-level option.
The older tier projection was preliminary review scaffolding. It was rejected
for ordinary TalkBank workflows because it cluttered transcripts, became stale
after transcript or algorithm changes, and encouraged provenance to be deleted
along with presentation metadata. Current commands remove any legacy
%xalign and %xrev tiers they encounter instead of refreshing them.
Where decision information lives
The typed DecisionRecord stream is the source of truth for machine choices
such as:
- forced-alignment timing repair or removal;
- monotonicity corrections;
- utterance-timing recovery failures;
- morphosyntax mapping failures; and
- segmentation decisions that need investigation.
Job traces and evidence artifacts retain these records independently of the
published .cha file. This separation lets experiments analyze provenance
without making temporary diagnostics part of the transcript data model.
The needs_review field is a machine-generated triage signal, not a calibrated
probability. A record can be useful evidence without implying that the output
is wrong.
Legacy --review-level
The values none, low-confidence, and all remain accepted so old scripts,
stored jobs, and clients continue to deserialize. They no longer alter CHAT
output. New automation should omit the option.
This compatibility field will be removed only in a separately announced wire or command-surface change. Code must not use it as authority to generate CHAT tiers.
Reviewing and publishing
Use the run’s structured evidence or the project review application during an
active evaluation. Published and delivered CHAT files should contain neither
%xalign nor %xrev.
If an older transcript contains either tier, rerunning the relevant current command strips it. Removing those tiers does not delete the current run’s structured evidence.
Developer invariant
Decision creation and decision presentation are separate phases:
- pipeline stages construct typed
DecisionRecordvalues and trace them; - orchestration retains the exact records in structured evidence;
- CHAT serialization applies the no-review-tier policy and strips legacy
%xalign/%xrevtiers.
Tests must prove both halves: decisions survive in evidence, and no CLI, API, or internal review-level value can synthesize the legacy CHAT tiers.
This page last changed: 2026-08-30 (commit 02214ccc). The whole book last changed: 2026-09-16 (commit 34d249d8).
Worker Tuning
Status: Current Last updated: 2026-09-15 18:27 EDT
This page explains how the server decides how many workers to run, how memory budgets work, and how to tune the server for your hardware.
The --workers flag
Control how many files are processed in parallel:
batchalign3 --workers 1 transcribe corpus/ -o output/ # One file at a time (safest)
batchalign3 --workers 4 morphotag corpus/ -o output/ # Four files in parallel
batchalign3 transcribe corpus/ -o output/ # Auto-tune (default)
All commands now use a two-stage policy: the runner computes a requested
worker count from file count, CPU, and category caps, then the host-memory
coordinator clamps that request to what the machine can safely fit right now.
GPU-heavy commands (transcribe, align, benchmark) are capped by both
max_gpu_workers and gpu_thread_pool_size.
CPU-only machines (Apple Silicon, no CUDA): the host-facts recommendation now sets
gpu_thread_pool_size = 1automatically when no functional GPU is detected. Leave the field absent inserver.yaml(or set it to0: the legacy “auto” sentinel that still deserializes to “no override”). The recommendation also setsforce_cpu = trueon the same hosts so workers skip GPU detection entirely.PyTorch releases the GIL only during CUDA/MPS native calls; with MPS excluded for batchalign3, every Whisper inference is GIL-bound CPU work and there is no compute parallelism to gain. A higher value lets multiple
execute_v2calls into a single Python process where they fight for cores, slowing each other down by the contention factor. Verify your host’s resolved values withbatchalign3 doctor --check(see Doctor).The Rust-side
dispatch_semaphoremirrorsgpu_thread_pool_sizepermit-for-thread, so Rust dispatch and Python serving share one ceiling. Set this knob to the parallelism your device actually has: 1 on CPU-bound platforms, 2-4 on real GPU. See MPS Exclusion Decision and Worker Protocol V2 § The dispatch semaphore contract.Measured Apple behavior on
dev-machineconfirms that the biggest win is not a larger CPU thread pool. Warm loopback-daemon reuse dominated everything else:align --no-utr --fa-engine wav2vecdropped from17.65sdirect /9.64ssequential to1.06swarm-daemon, andtranscribe --asr-engine whisperdropped from93.92sdirect to13.58swarm-daemon. On Apple CPU-only hosts, preserving warm workers matters much more than tuninggpu_thread_pool_size.
Override with --workers N when you want explicit control, or set
max_workers_per_job in server.yaml for a persistent override.
How worker planning works
When you submit a job, the server decides how many parallel file workers to assign.
- Compute a requested worker count from file count, CPU, and category caps:
- GPU commands:
min(max_gpu_workers, gpu_thread_pool_size) - CPU/IO commands:
max_thread_workers
- GPU commands:
- Ask the host-memory coordinator for a job execution plan.
- The coordinator subtracts active local reservations, preserves
memory_gate_mbas host headroom, and grants the largest safe worker count. - If nothing safe fits, the job is re-queued instead of speculatively running.
For a single file, the server always uses 1 worker, no parallelism needed.
If max_workers_per_job is set in server.yaml, it overrides auto-tuning
(still capped by file count and the category max).
Why GPU commands allow parallelism: GPU-heavy commands share a single
SharedGpuWorker process with a thread pool. While file N’s ASR runs on the
GPU, file N+1 can do post-processing, utseg, or morphosyntax on CPU. The GPU
itself serializes inference, but pipeline stages overlap. On a machine with
256 GB RAM, the coordinator may grant 4-8 parallel files for transcribe.
Worker profiles and host bootstrap mode
The server groups related commands into three worker profiles that share loaded models within a single process:
| Profile | Commands | What it shares |
|---|---|---|
| GPU | align, transcribe, transcribe_s, benchmark | Whisper, Wave2Vec, and speaker models in one process |
| Stanza | morphotag, utseg, coref, compare | Stanza NLP models (POS, constituency, coreference) |
| IO | translate, opensmile, avqi | Lightweight translation and audio analysis |
On large machines, this means running align followed by transcribe reuses
the same GPU worker process, the ASR model loaded for transcription stays in
memory and the FA model for alignment lives in the same process. On a 64 GB
machine, this saves roughly 3 GB compared to loading each model in a separate
process.
On small-memory hosts, the server now resolves a different host execution
policy: local workers use task bootstrap instead of full profile bootstrap.
That lets a weak laptop load only infer:asr or infer:morphosyntax instead
of speculatively loading every model in a profile. The machine trades some
reuse for a much lower idle footprint.
GPU workers handle multiple requests concurrently via internal threading only on CUDA-capable hosts. On CPU-only hosts they stay sequential to avoid oversubscribing OpenMP threads. Stanza and IO workers handle one request at a time but can run multiple processes in parallel for CPU-bound workloads.
Per-command memory profiles
Each command loads different ML models with different memory footprints. These
values come from runtime_constants.toml (generated from crates/batchalign-types/src/command_spec.rs
via xtask gen-runtime-toml; shared between Rust and Python at compile/import time):
| Command | Memory per worker (MB) | What drives it |
|---|---|---|
morphotag | 2,000 | Stanza POS/lemma/depparse models (per language) |
align | 4,000 | Whisper or Wave2Vec forced alignment model |
transcribe | 1,500 | Whisper ASR model |
utseg | 2,000 | Stanza constituency parser |
translate | 4,000 | Translation model (Seamless M4T or Google) |
coref | 2,000 | Stanza coreference model |
opensmile | 500 | Lightweight feature extractor |
avqi | 1,500 | Voice quality analysis |
compare | 2,000 | Stanza models (for normalization) |
These are the thread worker values (shared-model mode). Process worker values are higher because each worker loads its own copy of the models.
Commands in the same profile share a worker process, so the total memory for
a mixed job (e.g., align + transcribe) is roughly the sum of their models
loaded once, not separately. The GPU profile typically uses ~5 GB total for all
its models (ASR + FA + Speaker), regardless of how many commands run.
Worker pre-spawning
There is no startup warmup. Workers are created on demand, by two paths:
Per-job pre-scaling. When a job runs more than one file concurrently, the runner pre-spawns its workers before file dispatch begins, so a batch pays one cold start rather than one per file. The pre-spawned worker is keyed on the job’s own engine selection, so the dispatches that follow reuse it rather than spawning a second process.
Registry adoption. A TCP worker daemon started outside the server registers
itself in workers.json; the server adopts it at startup and routes to it like
any other worker. Each entry records the build that started the daemon
(build_identity, from BATCHALIGN_BUILD_IDENTITY, which batchalign3 worker start and the server’s own daemon spawner set). A server refuses to adopt a
daemon from another build, or one whose entry names no build, and logs the
remedy: stop the daemon (batchalign3 worker stop) and start it again with the
current build. The refused daemon is left running and listed in /health
under refused_registry_workers, with its worker key, pid and reason
(foreign_build or unreported_build).
Idle workers are then reclaimed by memory-pressure eviction (largest resident set first) rather than by a fixed timeout.
Retired: a
--warmupflag and awarmup_commandsconfig key used to pre-load a list of commands’ models when the server started. They were disabled on every real server from 2026-03-26 (following the 2026-03-11 finding that warmed models stay resident for the server’s whole lifetime) and removed on 2026-07-30. Passing--warmupis now an unrecognised-argument error, andwarmup_commandsin aserver.yamlis rejected as an unknown field; delete the key. Nothing else changes: neither had any effect on a real server for the four months before removal.
On-demand loading
No workers are pre-loaded at startup; they spawn lazily on the first job that needs them. Lazy startup does not mean the first real command is allowed to run against unknown infer-task metadata. The current server resolves that by forcing a live capability probe from the worker it is actually about to use. In practice:
/healthmay still show an optimistic command surface immediately after boot- the first real job pays the worker startup cost and records the detected infer-task view
- later jobs reuse that detected capability view instead of the cold-start placeholder
alignis advertised whenever a worker supports forced alignment, even before its FA model has loaded; an align job loads the model first and fails only if the worker still names no FA engine after that- a worker whose capability report was refused is never used, and is listed
with the reason in
/healthunderworker_capability_admissions
server.yaml reference
Key tuning parameters:
# Worker parallelism
max_workers_per_job: 0 # 0 = auto-plan from files, CPU, and category caps
max_concurrent_jobs: 0 # 0 = CPU-based runner slot cap
gpu_thread_pool_size: 4 # Concurrent execute_v2 per shared GPU worker.
# Rust dispatch_semaphore + Python ThreadPoolExecutor
# share this ceiling: set to the device's real
# parallelism (1 on Apple Silicon CPU-only;
# 2-4 on CUDA where the GIL is released).
max_concurrent_worker_startups: 1
# Memory tier override: force a specific tier instead of auto-detecting
# from total RAM. Values: small, medium, large, fleet. Omit to auto-detect.
# memory_tier: small
# Host-memory reserve/headroom (MB) preserved after reservations
# 0 = disable explicit reserve. Default: 2048 MB (one absolute floor on
# every host; the worker-pool admission gate enforces the same number
# live on every spawn attempt, see book/src/batchalign/developer/memory-safety.md)
memory_gate_mb: 4000
# Per-profile startup reservation overrides (MB). 0 = use tier default.
# These control how much memory the coordinator reserves while a worker
# loads its models. Reduce on small machines if the tier defaults are
# too conservative for your actual model sizes.
# gpu_startup_mb: 6000
# stanza_startup_mb: 3000
# io_startup_mb: 2000
# Worker lifecycle
# Idle workers are evicted by host memory pressure (largest-RSS first
# when available memory drops below the eviction threshold); there is
# no fixed idle timeout. Health checks run every `worker_health_interval_s`.
worker_health_interval_s: 30 # Health check frequency
Scenarios
16 GB laptop / shared developer machine
# The Small tier (<24 GB) auto-detects these defaults, so this config
# is only needed if you want to further customize on a small machine.
memory_tier: small # Force small tier (auto-detected on <24 GB)
max_workers_per_job: 1
memory_gate_mb: 2000 # Small tier default
stanza_startup_mb: 3000 # Actual Stanza RSS is ~2-3 GB
gpu_startup_mb: 6000 # Whisper float32 is ~4-5 GB
max_concurrent_worker_startups: 1
gpu_thread_pool_size: 1
The Small tier now also switches local workers to task bootstrap and clamps eligible file-parallel commands to one file at a time. That keeps the execution shape honest for 16 GB laptops: no speculative profile preload, no multi-file GPU stampede, and no assumption that the machine can afford idle models it is not about to use.
32 GB desktop
Default settings usually work well. The coordinator will clamp jobs as host pressure changes.
256 GB server (production)
max_workers_per_job: 0 # Coordinator-backed auto planning
max_concurrent_jobs: 8
max_concurrent_worker_startups: 1
memory_gate_mb: 8000 # Operator override; the default is 2048 MB (MIN_FREE_MEMORY_MB)
With this much RAM, worker profiles let the server run multiple concurrent jobs
efficiently. A GPU worker handling an align job and a Stanza worker handling a
morphotag job run in parallel without duplicating models, leaving plenty of
headroom for additional jobs.
Testing with test-echo workers
For quick iteration during development:
batchalign3 serve start --foreground --test-echo
Workers start instantly (no ML models loaded). Useful for testing server infrastructure without waiting for model initialization.
Troubleshooting
“Job deferred due to memory pressure”
The host-memory coordinator could not fit the requested execution plan. Possible causes:
- Too many concurrent workers. Reduce
max_workers_per_joborgpu_thread_pool_size. - Other processes using RAM. Check system memory usage.
- Idle workers holding memory. Workers that haven’t been used in a while still hold their loaded models. The pool’s pressure-driven eviction releases idle workers automatically when host available memory drops below the eviction threshold; if pressure is genuine but eviction isn’t firing fast enough, restart the server to reclaim immediately.
- Another local batchalign3 server or test run is already holding leases.
Check
/healthforhost_memory_*fields.
Jobs are re-queued when the plan does not fit. /health now reports
host_memory_pressure, current reservations, and active lease labels.
Only 1 worker running
The coordinator decided that only 1 worker currently fits. Check:
/healthhost_memory_pressureandhost_memory_reserved_mbmemory_gate_mbgpu_thread_pool_sizefor GPU commands- other local
batchalign3servers or ML tools on the same host
Override with max_workers_per_job if you know your system can handle more.
The first job takes too long
The first job of a given kind loads its ML models from disk, or downloads them on first run. To speed up:
- The first run after installation is slowest (model downloads)
- Subsequent starts load from the model cache (~5-20 seconds per model)
- Keep the daemon running (
batchalign3 serve start) to avoid repeated cold starts
See also Performance and Server Mode.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Doctor
Status: Current Last updated: 2026-05-11 11:30 EDT
batchalign3 doctor is the diagnostic surface for a batchalign3
deployment. It runs in two modes:
- Default: runs the worker pipeline test (Python availability, Stanza imports, test-echo round-trip, morphotag pipeline, available memory) and prints a host-facts summary.
--check: host-facts only. Skips the Python pipeline entirely for fast config-sanity verification.
This page covers the operator workflow. Implementation details for
contributors live in developer/host-facts.md (when added).
Common workflows
“Is my server.yaml going to deploy cleanly?”
batchalign3 doctor --check
Loads the deployed server.yaml, detects host facts (OS/arch, RAM,
GPU presence), resolves operator overrides against the host-facts
recommendations, and reports any contradictions. Exits 0 if
validation is clean, non-zero if any error fires.
Sample output on a clean Apple Silicon host:
Host facts (snapshot at startup):
os/arch: MacOs/Arm64 (12 logical cores, 8 physical)
ram: 65536 MB total, 32768 MB available
gpu: AppleMps { functional_for_batchalign: false, ... }
Effective config (after operator-override + recommendation merge):
gpu_thread_pool_size: 1
force_cpu: true
max_total_workers: 10
max_concurrent_jobs: 4
memory_gate_mb: 8000
max_workers_per_key: gpu=4 stanza=5 io=1
Validation: OK (no override contradicts detected facts)
Validation passed cleanly.
“Why does this knob have this value?”
batchalign3 doctor --explain gpu_thread_pool_size
Traces one resolved value end-to-end: the resolved number, whether it came from an operator override or the host-facts recommendation, the recommendation rule, and the relevant detected facts.
Useful when --check warns about a knob and you want to see the
recommendation’s reasoning without grepping the source.
Valid knob names:
gpu_thread_pool_sizeforce_cpumax_total_workersmax_concurrent_jobsmax_workers_per_keymemory_gate_mb
“Is this machine ready to run workloads?”
batchalign3 doctor
Default mode. Spawns a test worker, runs the morphotag pipeline end-to-end, validates every word has the expected fields, and reports timing per check. Catches machine-specific issues (stale Stanza models, missing processors, MWT quirks) before they surface during real jobs.
Slower than --check because it loads ML models. Use it after
software updates, model refreshes, or when a new fleet host comes
online, not as a per-deploy gate.
CI gate: zero-warning deployments
batchalign3 doctor --check --warnings-as-errors
Default --check exits non-zero only on errors. Add
--warnings-as-errors for the strict CI posture: any contradiction
between an operator override and a host-facts recommendation
becomes fatal.
Use when you want a server.yaml change to fail review before reaching
production. Skip when operators legitimately need to override the
recommendation (e.g., simulating constrained memory on a large host
via memory_tier: small).
Output formats
Human (default)
Rendered as label/value pairs without color or boxes; composes cleanly with surrounding shell output.
JSON: --format json
For machine consumers (CI scripts, monitoring dashboards). The schema is stable, fields can be added but renames or removals require a deliberate version bump.
batchalign3 doctor --check --format json | jq '.validation.warnings[]'
batchalign3 doctor --explain force_cpu --format json | jq '.source'
Top-level keys in --check mode:
detected: theHostFactssnapshot (OS, arch, RAM, GPU, etc.)effective: resolved knob values (operator overrides merged with recommendations)validation:{ warnings: [string], errors: [string] }
Top-level keys in --explain mode:
knob: the requested knob nameresolved_value: the value the runtime will usesource:"operator_override"or"recommendation"recommendation: what the recommendation function returned (always present, so operators can compare)rule: narrative description of the recommendation rulefacts_used: narrative description of the relevant detected facts
In default mode (the worker-pipeline path), the payload is
{ "checks": [CheckResult], "host_facts": HostFactsReport }.
When validation fires
The validator reports two kinds of finding:
- Warnings: the override is suboptimal but the server can still
run. Surfaced as
tracing::warn!lines at server startup. Default exit policy: non-fatal. - Errors: the override would deterministically crash or produce
wrong output. The server refuses to start;
doctor --checkexits non-zero with the recommendation in the message.
Today’s warning variants:
| Variant | Triggered by |
|---|---|
GpuThreadPoolSizeAboveOneOnCpu | gpu_thread_pool_size > 1 on a host with no functional GPU. The configured threads contend for one CPU-bound model process without parallelism gain. |
MaxConcurrentJobsAboveRamBudget | max_concurrent_jobs higher than the recommendation derived from ram_total_mb and CPU availability. Risks memory-pressure stalls and worker OOMs under load. |
MaxTotalWorkersAboveRamBudget | max_total_workers higher than clamp(ram_total_mb / 6 GB, 2, 32). Risks OOMs under sustained load. |
ForceCpuFalseOnNonFunctionalGpu | force_cpu: false set on a host whose GPU is not functional for batchalign. The asserted intent is wrong; common cause is a CUDA-host server.yaml copied to an Apple Silicon machine. |
Today’s error variants:
| Variant | Triggered by |
|---|---|
MaxConcurrentJobsWouldDeterministicallyOom | max_concurrent_jobs * worst_case_per_job_peak_ram_mb(tier) > ram_total_mb. Worst case = the heaviest worker profile for the detected tier: 6 GB on Small (< 24 GB), 6 GB on Medium (Stanza), 16 GB on Large/Fleet. Even if every job uses the heaviest profile, no scheduling outcome fits, the server refuses to start. The error message suggests --sequential (which forces single-job execution and bypasses the multiplier). Alternatively: drop max_concurrent_jobs from server.yaml (the host-facts recommendation is by construction safe) or set a value that satisfies n * worst_case_per_job_peak_ram_mb(tier) <= ram_total_mb. |
Conservative-vs-recommendation cases (operator under-eager) are
intentionally silent. The operator knows their host better than
recommend() does.
Exit codes
batchalign3 doctor follows the standard CLI exit codes documented
in CLI Reference: Exit codes:
| Code | Meaning |
|---|---|
| 0 | All checks passed. |
| 2 | Usage error (unknown --explain knob name, etc.). |
| Non-zero | Validation found errors (or warnings under --warnings-as-errors); or the worker pipeline failed any check. |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Troubleshooting
Status: Current Last updated: 2026-09-02 07:45 EDT
Start with verbose output
batchalign3 -vvvv align ~/corpus/ -o ~/output/
Use the final error message together with the sections below.
Attach run logs to bug reports
batchalign3 logs --export
CLI-managed runs write structured logs under ~/.batchalign3/logs/.
Worker Python resolution order
When debugging which Python the CLI selects, the full resolution order is:
BATCHALIGN_PYTHONenvironment variable- Active
VIRTUAL_ENV - A sibling Python next to the binary that can import
batchalign.worker - A project
.venvdiscovered by walking up from the binary python3.12on macOS/Linux, orpythonon Windows
The CLI cannot start local workers
For local processing, the selected Python interpreter must be able to import
batchalign.worker.
Check the runtime explicitly:
$BATCHALIGN_PYTHON -c "import batchalign.worker"
If you are not using BATCHALIGN_PYTHON, run the same import test with the
Python you expect Batchalign to discover.
batchalign3: command not found
Install (or reinstall) batchalign3:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/FranklinChen/talkbank-tools/releases/latest/download/install-batchalign3.sh | sh
If uv installed the tool but your shell still cannot find it, ensure the uv
tool bin directory is on PATH.
The daemon will not start
Check the daemon log:
cat ~/.batchalign3/daemon.log
Then force a clean restart:
batchalign3 serve stop
rm -f ~/.batchalign3/daemon.json
batchalign3 morphotag corpus/ -o output/
warning: port <N> is already in use by ...
Symptom: the auto-daemon path refuses to start with a message naming
the configured port and, when it could identify one, the process holding
it (process <pid> (<name>)), instead of spawning a daemon that would
have failed with “Address already in use”.
Cause: before spawning, the CLI probes the configured fixed port
(crates/batchalign/src/cli/daemon.rs::probe_fixed_port): a single bind
attempt, never a retry loop. If the bind fails, it sends one short
/health request. A batchalign3 daemon that answers is adopted instead of
spawning a competing process (... daemon already running on port <N> (<build>); reusing it.); anything else (a foreign service, or a
batchalign3 process that is alive but not answering /health) gets this
refusal rather than a spawn attempt that is guaranteed to fail the same
way every time.
Remediation. Free the named port, or configure a different one in
server.yaml (port: <N>). If the message names a process, kill it
(or investigate why it is holding a port batchalign3 expects to own) before
retrying.
Cache looks stale
Bypass the cache for one run:
batchalign3 --override-media-cache morphotag corpus/ -o output/
Clear cached data:
batchalign3 cache clear --yes
batchalign3 cache clear --all --yes
Runs are slow or memory-heavy
Cap workers explicitly:
batchalign3 --workers 2 morphotag corpus/ -o output/
Force CPU mode:
batchalign3 --force-cpu morphotag corpus/ -o output/
The first real worker-backed run is usually slower because models may still need to load.
Job deferred or rejected due to memory pressure
If a job fails with MemoryPressure or logs say “job deferred due to memory
pressure”, the server’s memory gate detected insufficient RAM. The gate
polls available memory for up to memory_gate_timeout_s seconds before
giving up (default 120; see default_memory_gate_timeout_s in
crates/batchalign/src/types/config/server.rs).
Common causes:
- Too many concurrent workers, reduce
max_workers_per_jobormax_concurrent_jobsin~/.batchalign3/server.yaml - Other processes consuming RAM
- Idle workers holding loaded models, the pool evicts idle workers automatically when host memory pressure rises; if eviction isn’t firing fast enough, restart the server
Quick fixes in ~/.batchalign3/server.yaml:
# Force the server to use small-machine memory budgets
memory_tier: small
# Or override individual values
memory_gate_mb: 2000 # Reduce headroom reserve
stanza_startup_mb: 3000 # Stanza actually uses ~2-3 GB
# Disable the gate entirely (not recommended for production)
# memory_gate_mb: 0
The server auto-detects a memory tier from total RAM (Small <24 GB,
Medium 24-48 GB, Large 48-128 GB, Fleet >128 GB). Use memory_tier
to override. See Worker Tuning for details.
“Cannot find audio file” or “Media conversion failed”
Cannot find audio file: The server could not locate a media file
matching the CHAT file’s stem. Audio --server jobs now require the execution
host to see the same filesystem paths as the CLI invocation. Run the CLI on the
execution host itself (or over SSH/VNC), or make sure the same corpus path is
mounted there. If the corpus root and media root differ on that host, configure
local media_mappings or pass a server-visible --media-dir. See
Media Resolution.
Media conversion failed, ffmpeg not found: MP4 (and M4A, WebM, WMA) files require ffmpeg for conversion to WAV. Install ffmpeg:
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# Or download from https://ffmpeg.org/download.html
After installing, restart the server (batchalign3 serve stop && serve start).
Media conversion failed, ffmpeg error: The ffmpeg conversion itself failed. Check that the source media file is not corrupted. The error message includes ffmpeg’s stderr output for diagnosis.
align / forced-alignment failures: where to look first
When align fails, the useful distinction is which stage failed:
flowchart TD
start["align failed"]
media{"Cannot find audio file\nor conversion failed?"}
caps{"Command unsupported\nor stale-build warning?"}
parse{"Per-group FA parse error\nwith group index/window?"}
generic{"Only generic missing-timings\nor unclear success/failure?"}
media -->|yes| media_fix["Fix execution-host path visibility,\nmedia_mappings, or --media-dir"]
media -->|no| caps
caps -->|yes| caps_fix["Check /health capabilities,\nserver build hash, and client/server versions"]
caps -->|no| parse
parse -->|yes| trace_fix["Re-run with --debug-dir\nand inspect traces / fallback_events"]
parse -->|no| generic
generic -->|yes| cache_fix["Bypass FA cache with\n--override-media-cache-tasks forced_alignment\nthen inspect traces again"]
generic -->|no| done["Use normal output / bug report path"]
The two most useful commands are:
batchalign3 -vvvv align \
--debug-dir /tmp/ba-debug \
--override-media-cache-tasks forced_alignment \
-o output/ \
file.cha
and, for server mode:
curl http://SERVER:8001/jobs/JOB_ID/traces | python3 -m json.tool
What to look for in the trace payload:
fa_timeline.fallback_events[]: confirms a Wave2Vec group retried with Whisper FA- empty
fallback_eventson a successful rerun, often means the run was served from FA cache rather than reproducing the failure - group index +
audio_start_ms/audio_end_ms: the exact failing window to reproduce offline
If you are debugging direct mode instead of --server, the same --debug-dir
switch enables trace capture, but the trace is exported as debug-traces.json
in the local job staging directory rather than through /jobs/{id}/traces.
Some utterances lose timing after align
If align leaves some utterances without timing bullets, or if chatter validate reports E362 (non-monotonic timestamps) on align output, the most
likely cause is overlapping speech in the transcript.
Why this happens
CHAT transcription convention places utterances in conversational order for
readability. Overlapping speech markers (&*SPK:words) interleave one
speaker’s words inside another speaker’s utterance. But in the audio, those
words occur in temporal order, which may differ from the text order.
The alignment engine uses a monotonic matcher: it can only assign timestamps
that increase through the file in text order. When text order and audio order
diverge – which is inherent in transcripts with dense &* markers – the
matcher cannot assign correct timestamps to every utterance without violating
monotonicity.
Rather than write invalid CHAT or silently corrupt timestamps, align strips
timing from the affected utterances. They appear in the output as plain
untimed text, just as they would before alignment. The surrounding utterances
retain their full word-level timing.
How to identify affected utterances
Untimed utterances have no bullet at the end of the main tier line and no
%wor dependent tier. You can find them with:
# Show main-tier lines without timing bullets
grep '^\*' output.cha | grep -v '[0-9]_[0-9]'
If the untimed utterances cluster in blocks (especially around sections with
frequent &* markers), the cause is almost certainly text/audio order
divergence.
What you can do
-
Accept partial coverage. For many workflows, having most utterances timed is sufficient. The untimed utterances are still valid CHAT – they just lack timing.
-
Reorder utterances to temporal order before aligning. If you need full coverage and the transcript has sections where conversational grouping differs from temporal order, reordering those sections will let the aligner assign timestamps to all utterances. This is the most reliable fix.
-
Use
align --beforewhen re-aligning after hand edits. This preserves existing good timing for unmodified utterances and only re-aligns the changed regions, reducing the chance of cascading timing loss.
Known high-impact patterns
-
Dense
&*markers (3+ per utterance, or long stretches where most utterances contain&*): common in aphasia protocols, conversation analysis, and multi-party recordings. These produce the largest untimed blocks. -
Hand-edited transcripts with restructured speaker turns: When a reviewer splits, merges, or reattributes ASR utterances, the resulting text order may diverge substantially from the original audio order.
-
Short backchannels (“mhm”, “yeah”, “okay”): These are often placed after the main speaker’s utterance in the transcript but occurred during it in the audio. A single misplaced backchannel can push subsequent utterances out of monotonic order.
This is a known architectural limitation of monotonic alignment, not a bug.
For moderate-overlap files, improvements to &* handling should reduce the
problem. For heavily restructured transcripts with dense overlap, a more
fundamental change (per-speaker alignment) is needed. See
Monotonicity Invariant
for the technical details and roadmap.
“Command not supported” or missing commands
If the server rejects a command (e.g., align or transcribe) with an error
like “command not supported”, the server did not detect the required Python
dependencies at startup.
Check what the server advertises:
curl http://localhost:8000/health | python3 -m json.tool
Look at the capabilities list. If the command you need is missing:
-
Check the server log for lines containing “excluding from server capabilities”, these show which commands failed the capability gate and why.
-
Verify the Python environment has the required packages installed. All core commands work out of the box with a standard install (
uv tool install batchalign3). If a dependency was removed or failed to build, the capability probe will exclude the affected command. Key dependencies:alignneedstorchandtorchaudiotranscribeneedsopenai-whispertranslateneedsgoogletransmorphotag,utseg,corefneedstanzaopensmileneedsopensmileavqineedsparselmouthandtorchaudio
All of these are included in the base
batchalign3package, including the Cantonese providers. -
Restart the server after installing missing packages, capabilities are detected once at startup:
batchalign3 serve stop batchalign3 serve start
--server seems to be ignored
That should no longer happen for audio commands. If align, transcribe, or
another audio workflow still behaves like a local-only run, double-check that:
- you actually passed
--server http://... - the target server advertises the command in
/health.capabilities - the server can see the same absolute input/output paths on its filesystem
Check remote dispatch with a command that supports it:
batchalign3 serve status --server http://yourserver:8000
batchalign3 --server http://yourserver:8000 morphotag corpus/ -o output/
“Did my long-running job die when the server restarted?”
Yes, the old in-flight process is gone. Batchalign now has a single local
in-process control plane, so restarting batchalign3 serve interrupts running
work on that server.
What survives is the persisted SQLite job state. On the next startup, recovery
reloads queued/interrupted jobs and re-dispatches resumable work. Confirm by
querying http://<server>:<port>/jobs (the public JSON endpoint; /api/jobs
is 404, that path belongs to the SPA shell).
If a job was far enough along to have durable partial state, you should see it return as queued/running after restart rather than disappearing permanently.
Submission errors
server returned 413: length limit exceeded
Symptom: batchalign3 aborts a submission with
server returned 413: length limit exceeded (or the JSON detail
contains that phrase).
Cause: The chunk’s total serialized CHAT content exceeded the server’s
max_body_bytes_mb. This only happens on remote submissions
(explicit --server http://host:port with a non-loopback host), where the
request body carries every selected file’s CHAT text. Local submissions
(the auto-daemon path or a loopback-addressed server) use
paths_mode=true and never put file contents in the body, so 413 is
structurally unreachable on the local path. See
Submission Modes
for the selection rule.
Remediation. Pick one:
-
Raise the remote server’s body limit. On the server, edit
~/.batchalign3/server.yaml:max_body_bytes_mb: 1024Then restart:
batchalign3 serve stop && batchalign3 serve start. The default is 512 MB; raise it only if your payloads genuinely exceed that. -
Submit smaller batches. Split the input set so each submission stays under the current limit.
--file-listmakes chunking trivial: write the filenames for each chunk to a separate list and submit in sequence. -
Use a local daemon instead of remote
--server. If the CLI and the server share a filesystem, dropping--serverlets the CLI use the local daemon on127.0.0.1, which bypasses the body limit entirely (path lists instead of file contents). This is the simplest fix when a fleet machine keeps the corpus on shared storage.
The CLI does not retry 413, a deterministic rejection indicates the payload itself is too large, and re-sending would waste work. See Submit-path retries for the full retry contract.
Submission silently drops chunks
Symptom (historical): A batch script reported success on every chunk but the output directory had fewer files than input, and server logs showed no trace of the missing submissions.
Cause: A transient connect-refused from the daemon during job finalization reached the CLI as an immediate error. Scripts that treated submission errors as terminal silently skipped the chunk.
Remediation. The CLI retries transient connect/timeout failures
automatically (RETRY_ATTEMPTS = 3, exponential backoff). Use a current
batchalign3 build. A non-CLI client talking directly to the REST API
must implement its own retry on connect/timeout, but must not retry
HTTP 4xx/5xx.
External service timeouts
Timeouts reaching api.rev.ai, huggingface.co, or other external providers
usually mean the host cannot reach the required service. Confirm network access
from the machine running the worker runtime.
Apple Silicon / MPS issues
If you hit GPU-specific failures, retry with CPU mode:
batchalign3 --force-cpu align corpus/ -o output/
The --force-cpu flag is the only supported way to force CPU mode at the
CLI surface. (The only BATCHALIGN_* env vars wired into CLI args are
BATCHALIGN_SERVER, BATCHALIGN_PYTHON, and BATCHALIGN_DEBUG_DIR;
there is no BATCHALIGN_FORCE_CPU env override. To pin the
behavior across runs without typing the flag, set force_cpu: true
in ~/.batchalign3/server.yaml and run via batchalign3 serve.)
“Stripped N upstream-library warnings” in the server log
You may see lines in ~/.batchalign3/server.log like:
WARN Stripped 2 Stanza control-token leak(s) from fin item 15
(stanza defect registry: Defect 4). Leaks: [...]
These are the normal signal of an active workaround, not an error. Batchalign knows about specific defects in the upstream libraries it uses (Stanza, Whisper, Apple MPS, and others) and silently corrects them in place. Every correction emits a warning so the workaround is visible in your logs.
What to do:
- If your output CHAT looks correct (check with
chatter validate path/to/output.cha): nothing. The workaround did its job. The warning is informational, it tells you “we applied a known upstream-defect workaround N times on this job.” - If
chatter validatereports errors on the output: the workaround may have missed a new variant of the defect. Please file a bug with the input file, the warning log excerpt, and the validation error, we will extend the workaround vocabulary.
The comprehensive list of known upstream defects and their registered workarounds is maintained at reference/stanza-limitations.md (for Stanza) and developer/apple-mps-workarounds.md (for Apple MPS). The engineering policy governing how we add and retire workarounds is at developer/upstream-defect-policy.md.
Capture a full debug transcript
batchalign3 -vvv morphotag corpus/ -o output/ 2>&1 | tee debug.log
Attach debug.log together with batchalign3 logs --export when filing an
issue.
Filing bug reports
Open an issue at https://github.com/FranklinChen/talkbank-tools/issues.
Attach:
batchalign3 logs --exportoutputbatchalign3 -vvv <your-command> 2>&1 | tee debug.log- Your OS, Python version, and
batchalign3 versionoutput
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Python-Rust Boundary
Status: Current Last updated: 2026-09-16 04:34 EDT
The talkbank-tools workspace has two architectural layers: the CHAT core (entirely Rust, no Python) and the Batchalign runtime (Rust server + Python ML workers). This page describes the one and only seam: the CHAT-ownership boundary between the Batchalign Rust server and its Python workers.
The CHAT core has no Python in it at all. References to Python below apply only to the Batchalign runtime layer.
Why the Runtime Is Deliberately Hybrid
Batchalign3 is not a wholesale Python-to-Rust conversion made to follow an industry trend. The language boundary follows ownership and correctness:
- Rust owns typed CHAT data, typestate-constrained orchestration, validation, cache and provenance policy, concurrency, and deterministic transcript transformation.
- Python owns thin adapters around the ML ecosystem where PyTorch, Stanza, Whisper, Pyannote, and provider SDK support is strongest.
Performance and memory efficiency are benefits of the Rust control plane, but the primary reason for the split is to make invalid transcript and pipeline states difficult or impossible to represent. The boundary may move when a Rust implementation provides a clearer typed design, but replacing working ML bindings solely to eliminate Python is not a goal.
Server Owns the CHAT Lifecycle
Server → parses CHAT → extracts payloads → checks cache →
worker.execute_v2(task, prepared_batch) → Python runs model only →
Server injects results → validates → serializes → CHAT text
Python workers never see CHAT text. They receive structured payloads (words, audio chunks, prepared text) and return raw model output (UD annotations, word timings, ASR tokens, parse trees). The Rust server owns the full CHAT lifecycle.
flowchart TD
cli["CLI\n(batchalign3)"]
server["Rust Server"]
worker["Python Worker\n(stateless ML)"]
subgraph "Server owns CHAT lifecycle"
parse["Parse CHAT → AST"]
extract["Extract words/audio"]
cache{"Cache\nhit?"}
inject["Inject results → AST"]
validate["Validate alignment"]
serialize["Serialize → CHAT"]
end
cli -->|HTTP| server
server --> parse --> extract --> cache
cache -->|miss| worker
worker -->|structured results| inject
cache -->|hit| inject
inject --> validate --> serialize
serialize -->|CHAT text| cli
This architecture eliminates duplicated logic between Python and Rust, enables unified caching at the server level, and makes workers interchangeable, any worker with the right model can serve any request.
Dispatch Decision
Text-only commands require the infer path. If a worker lacks the
required task in its infer_tasks capability list, the job fails with
an “upgrade required” error, there is no Python process fallback.
flowchart TD
job["Incoming job"]
cmd{"Command?"}
allchat{"All files\n.cha?"}
infer["Batched infer path\n(server orchestration)"]
fainfer["FA infer path\n(per-file)"]
transcribe["Transcribe infer path\n(audio → CHAT)"]
benchmark["Benchmark infer path\n(audio → CHAT → WER)"]
media["Media-analysis V2\n(prepared audio + execute_v2)"]
fail["Fail job\n(no fallback)"]
job --> cmd
cmd -->|"morphotag, utseg,\ntranslate, coref, compare"| allchat
cmd -->|"transcribe,\ntranscribe_s"| transcribe
cmd -->|benchmark| benchmark
cmd -->|"diarize, opensmile,\navqi"| media
allchat -->|yes| infer
allchat -->|"yes (align)"| fainfer
allchat -->|no| fail
Per-command dispatch family detail is on the
Dispatch and Execution page (will
move under architecture/runtime/ during the M6 merge).
Wire Protocol
Workers communicate via stdio JSON-lines. The bootstrap handshake plus per-job dispatch:
sequenceDiagram
participant Server as Rust Server
participant Worker as Python Worker
Server->>Worker: spawn (--task morphosyntax --lang eng)
Worker-->>Server: {"ready": true, "pid": N}
loop Per batch
Server->>Worker: {"op": "execute_v2", "task": "morphosyntax", "prepared_text": "..."}
Worker-->>Server: {"result": {"kind": "morphosyntax_result", "items": [...]}}
end
Server->>Worker: {"op": "shutdown"}
Operations
| Op | Handler | Description |
|---|---|---|
health | Python | Worker health status |
capabilities | Python | Available infer tasks + engine versions |
execute_v2 | Rust dispatcher → Python model | Typed V2 execution with prepared artifacts |
infer / batch_infer | Python | Legacy inference path (still supported) |
shutdown | Protocol | Clean worker shutdown |
dispatch_protocol_message() validates the JSON envelope in Rust, then
calls the appropriate Python handler.
execute_v2 request
{
"op": "execute_v2",
"request": {
"request_id": "req-mor-0004",
"task": "morphosyntax",
"payload": {
"kind": "morphosyntax",
"data": { "lang": "eng", "payload_ref_id": "text-ref-0004", "item_count": 2 }
},
"attachments": [
{ "kind": "prepared_text", "id": "text-ref-0004", "path": "/tmp/text-ref-0004.json" }
]
}
}
Prepared artifacts (text, audio) are owned by Rust and read by the
worker via path references in attachments: they do not cross IPC
inline.
execute_v2 response
{
"op": "execute_v2",
"response": {
"request_id": "req-mor-0004",
"outcome": { "kind": "success" },
"result": {
"kind": "morphosyntax_result",
"data": { "items": [ ... ] }
}
}
}
Prepared-text batch items and response items are positionally matched
(items[i] corresponds to batch element i).
Three-Layer Split: Internal Only
The Batchalign worker side is internally split into three layers. The split exists for maintainability, none of these layers is a public extension surface for third-party plugins.
1. Core primitives (Rust)
The Rust core (crates/batchalign/) owns CHAT parsing and
serialization, AST-safe mutation, extraction and injection helpers,
alignment, and validation invariants. This is the only layer that
directly owns low-level CHAT mutation rules.
2. Inference providers (Python, internal)
batchalign/inference/ modules, pure Python task adapters around
third-party ML libraries (Stanza, Whisper, pyannote, FunASR, Tencent,
Aliyun, etc.). They receive typed task payloads from the Rust dispatch
layer and return typed task results. They do not parse .cha files
and do not mutate CHAT directly. Long-term intent: push as much of
this layer into Rust as Rust gains coverage of the underlying ML
pieces (a Rust-native Whisper path, whisper_rs, exists behind the opt-in
whisper-rs-backend Cargo feature; the default build still routes Whisper
through the Python worker).
3. Pipeline operations (Rust)
The CHAT-aware orchestration layers in Rust, choose extraction strategy, batch requests, call providers via worker IPC, read/write cache, inject results, apply task-specific validation and recovery. Pipeline operations compose core primitives instead of editing raw CHAT text themselves.
Why the split is load-bearing. If providers were forced to understand CHAT, simple SDK wrappers would become more complex than necessary, inference adapters would couple to AST details, and language-agnostic providers would be harder to support. If CHAT-aware pipeline work were forced into a provider-only interface, pipelines could not safely reuse extraction/injection logic, would end up reimplementing CHAT logic in Python, and caching/validation policy would drift from the core.
Boundary rules per layer
- Provider layer: typed worker-IPC payloads only. No
.chaparsing, no direct tier editing. Implementations are Python today; long-term they migrate into Rust. - Pipeline layer: operates on
ChatFilein Rust. Uses core extraction and injection primitives. May depend on providers, but does not expose provider internals. - Core layer: owns structural CHAT invariants. Exposes safe primitives upward. Does not depend on provider-specific SDK logic.
No Public Python API
There is no supported way to plug new providers or new pipeline operations into Batchalign from outside the source tree. New ASR backends, FA backends, or pipeline operations are added in-tree. See Adding Inference Providers.
The internal Python re-export module batchalign.providers exists to
give worker-side inference modules a stable import path for worker-IPC
payload types (BatchInferRequest, BatchInferResponse, InferTask,
…). It is not a public API.
The batchalign_core PyO3 extension module is the Rust → Python
bridge for worker processes. Its symbols change with the Rust runtime
and are not part of any compatibility surface.
For the API stability stance see API Stability.
What stays Python
| Surface | Why |
|---|---|
batchalign/worker/ | Thin worker host for Python-native ML runtimes |
batchalign/inference/ | Direct model or SDK invocation (Stanza, Whisper, pyannote, …) |
batchalign/inference/languages/cantonese/ | Python-only Cantonese SDK and model boundaries |
batchalign/models/ | Training code depending on Python ML libraries |
What was removed
| Surface | Why removed |
|---|---|
ParsedChat class + callback methods | Rust server uses ChatFile directly |
batchalign.pipeline_api | Rust server owns pipeline orchestration |
batchalign.compat | Deprecated BA2 shim, no longer needed |
batchalign.inference.benchmark | WER scoring available via batchalign3 compare |
Standalone #[pyfunction] exports (build_chat, WER, extraction, …) | Server calls batchalign directly |
Number expansion lives entirely in Rust (see
Number Expansion);
the Python _number_expansion.py and _expand_numbers_v2.py modules
and the expand_numbers V2 IPC are not used.
batchalign_core Module Layout
crates/batchalign-pyo3/src/ (~3,250 lines):
lib.rs module registration (~80 lines)
worker_protocol.rs IPC message dispatch
worker_asr_exec.rs ASR execution (Whisper, Cantonese providers)
worker_fa_exec.rs forced-alignment execution
worker_media_exec.rs speaker diarization, OpenSMILE, AVQI
worker_text_results.rs text task normalization + align_tokens
worker_artifacts.rs prepared-artifact loading from IPC
cantonese_asr_bridge.rs Cantonese provider projection + field admission
py_json_bridge.rs Python → JSON conversion, dispatched on exact type
py_json_bridge.rs is the gate every worker response and provider payload
passes through, so what it accepts is the real wire contract. It dispatches on
EXACT Python type, in this order: None, bool (before int, because Python’s
bool is an int subclass), str, anything with model_dump, dict,
list/tuple, exact int (refused outside the 64-bit range rather than
wrapped), exact float (a non-finite value is refused naming its path, for
example $.monologues[0].elements[1].start_s), and anything else refused by
type name. It previously tried numeric extraction FIRST, and PyO3’s numeric
extraction honours __int__ / __float__ / __index__, so any number-like
object silently became a JSON number: the conversion was deciding what a value
meant rather than reading what it was.
Worker V2 executors
Each executor loads Rust-prepared artifacts from the IPC message, calls the Python ML model, and returns raw results:
| Executor | Task | What Rust prepares | What Python does |
|---|---|---|---|
execute_asr_request_v2 | ASR | PCM audio bytes | Run Whisper / Cantonese provider |
execute_forced_alignment_request_v2 | FA | PCM audio + word JSON | Run Whisper / Wave2Vec FA |
execute_speaker_request_v2 | Speaker | PCM audio bytes | Run pyannote / NeMo |
execute_opensmile_request_v2 | OpenSMILE | PCM audio bytes | Extract acoustic features |
execute_avqi_request_v2 | AVQI | Paired audio bytes | Calculate voice quality |
normalize_*_result (worker_text_results.rs) | Text tasks | n/a | Parse each host item into its tagged V2 result; an item that does not parse becomes that item’s failure |
Cantonese provider bridges
Python Cantonese ASR engines call back into Rust for output projection
(common monologues + timed_words shape):
| Function | Purpose |
|---|---|
funaudio_segments_to_asr | FunASR segments → monologues + timed words |
tencent_result_detail_to_asr | Tencent output → monologues + timed words |
aliyun_sentences_to_asr | Aliyun output → monologues + timed words (with per-character tokenization when Aliyun sends a sentence without per-word timing) |
Cantonese normalization is NOT on this boundary. normalize_cantonese and
cantonese_char_tokens were exported here until 2026-09-16, for Python callers
that production no longer had; normalization now has one owner in
batchalign-transform and runs in the server.
They also own SPEAKER ADMISSION. A provider adapter reports
{"kind": "attributed", "label": ...} or {"kind": "undiarized"} rather than a
bare speaker number, and the bridge admits that against what the REQUEST asked
for (ProviderDiarizationV2): an absent speaker is Undiarized when no
separation was requested, and a refusal when it was. No adapter writes a track
number it was not given.
The request’s half of that question crosses the boundary as the same tagged
value: AsrBatchItem.diarization is a ProviderDiarizationV2, parsed once by
Pydantic and carrying no default, so every caller says what it asked the
provider for. The one adapter that uses it, Tencent, matches on the two states
to set that service’s own two parameters. It used to receive an integer whose
zero meant “do not separate” beside a Python default of 1, a count that means
“separate this into one speaker” and is refused at submission.
These bridges also own FIELD ADMISSION for the cloud providers. Tencent and
Aliyun document every result field as nullable and their SDKs leave absent
attributes as None, so the Python adapters forward the payload unchanged and
Rust decides what each absence means: an absent time produces an untimed word
with a named cause, never a zero, and a wrong-typed or inadmissible value
refuses the file naming the provider, the position and the fault. The rules and
the one interval owner are on the
ASR Token Pipeline
page.
Rev.AI HTTP client
crates/batchalign/src/revai/ provides Rev.AI HTTP calls. The Rust
server uses this crate directly for all Rev.AI operations (transcribe,
UTR, pre-submission). No Rev.AI functions are exposed to Python, the
PyO3 wrappers were removed as dead code.
GIL strategy
All pure-Rust functions use py.detach() (PyO3 0.29) to release the
GIL during computation. Worker executors hold the GIL only during
Python model invocation.
Python Worker Modules
batchalign/worker/:
| Module | Purpose |
|---|---|
_main.py | Worker CLI entry point and stdio startup |
_model_loading/ | Task-level model-loading package (bootstrap, translation, forced_alignment, asr) |
_stanza_loading.py | Stanza configuration and ISO-code mapping |
_execute_v2.py | Typed V2 execute router for prepared-audio and prepared-text tasks |
_text_v2.py | Thin batched text-task V2 host; Rust owns text-task batch-result shaping |
_artifact_inputs_v2.py | Thin Python wrapper over Rust-owned prepared-artifact lookup, descriptor validation, file-slice reads |
_asr_v2.py / _fa_v2.py / _speaker_v2.py / _opensmile_v2.py / _avqi_v2.py | Thin Python wrappers over Rust-owned executor control planes |
_types_v2.py | Pydantic models mirroring V2 wire format |
_protocol.py | Stdio JSON-lines serving loop |
_protocol_ops.py | Thin Python wrapper over Rust-owned stdio op dispatch |
_handlers.py | Health, capabilities, preflight handlers |
_infer_hosts.py | Bootstrap-owned batch-infer runtime hosts |
_infer.py | Thin request-time batch inference router |
_types.py | Pydantic models mirroring Rust wire format |
batchalign/inference/:
| Module | Input → Output |
|---|---|
morphosyntax.py | words+lang → raw Stanza UD annotations |
utseg.py | words+lang → raw constituency parse tree |
translate.py | text+lang → translated text |
coref.py | sentences → coreference chains |
fa.py | audio+words → raw word-level timings |
asr.py | audio path / prepared waveform → raw ASR payloads |
speaker.py | prepared waveform → backend-specific raw speaker evidence (completed pyannoteAI job or local segments) |
opensmile.py | prepared waveform → raw acoustic feature rows |
avqi.py | paired prepared waveforms → raw voice quality metrics |
Each is a pure inference function, no CHAT parsing, no text processing, no domain logic.
Capability Discovery
Capabilities are detected lazily from the first real worker spawn, no dedicated probe worker at startup. When the first worker for any profile starts up, the Rust server queries it and:
- Infer tasks: which inference backends are available
(
_capabilities()import probes inbatchalign/worker/_handlers.py). - Engine versions: one entry per advertised infer task, keyed by task.
Forced alignment’s entry is a validated engine name (
ReportedEngineName, a wrapper overStampSafeText: non-blank, no surrounding whitespace, none of|,;,]or a line break), ornullbefore an FA model has loaded. Every other task’s entry isnull.
WorkerPool::record_capabilities() admits the report once, into
WorkerEngineReports (crates/batchalign/src/engine_reports.rs), and stores
the admitted form per worker key; nothing downstream reads the raw report. The
released command surface is derived from it by capability::command_supported,
the one availability rule that dispatch applies too: a command is advertised
when the worker supports the primary_infer_task of the command’s
CapabilityPlan (crates/batchalign/src/recipe_runner/command_spec.rs,
declared per entry in recipe_runner/catalog.rs). Engine names are not
consulted. A plan names ONE task. A second declared list, additional_infer_tasks,
was deleted on 2026-09-16: a later stage does not run on the worker this plan
admitted, but goes back to the pool and derives its own key from its own
request, so the speaker stage of transcribe_s is served by a speaker worker
about which the admitting ASR worker’s report says nothing. Server-owned commands (transcribe,
transcribe_s, benchmark) are synthesized there from ASR availability
rather than advertised by the worker.
Infer-task probes
Each InferTask has a set of Python imports that must succeed for it
to be advertised:
| InferTask | Required imports | engine_versions entry |
|---|---|---|
morphosyntax | stanza | null |
utseg | stanza | null |
coref | stanza | null |
translate | googletrans | null |
fa | torch, torchaudio | the loaded FA model name; null until FA loads |
asr | whisper or a configured Rev.AI key | null |
opensmile | opensmile | null |
avqi | parselmouth, torchaudio | null |
speaker | pyannote.audio | null |
Task advertisement uses import probes. The FA engine name is the opposite: it
reflects what has actually loaded, so it is null before that. Only
_reported_engine() in batchalign/worker/_handlers.py decides these
entries, and it names FA’s engine alone; a test-echo worker reports
"test-echo" for FA and null for the rest.
Rev.AI-backed server-mode transcription and Rev-backed UTR are synthesized on the Rust side. The infer-task table represents “can the system satisfy this infer task at all?”, not only “can Python import a local model package?”.
Design note: Probes use import probes (can the dependency be imported?), not loaded model state (is a model warmed up?). This is critical because the worker that reports capabilities may only load models for one command, but Rust still needs enough information to derive the released command surface. All dependencies in the table are part of the base
batchalign3package, so any standard install gives you every built-in engine family. The import probes exist as a safety net for environments where a dependency failed to install or was removed.
speaker is a low-level worker infer task, not a CLI command named
speaker. Two user-facing surfaces compose it: integrated
transcribe --diarization enabled (internally transcribe_s) and standalone
diarize, which writes anonymous .turns.json evidence for later use by
chatter rediarize.
Sample capabilities response
{
"commands": [],
"infer_tasks": ["morphosyntax", "utseg", "translate", "coref", "fa",
"asr", "opensmile", "avqi", "speaker"],
"engine_versions": {
"morphosyntax": null,
"utseg": null,
"translate": null,
"coref": null,
"fa": "whisper-fa-large-v2",
"asr": null,
"opensmile": null,
"avqi": null,
"speaker": null
}
}
Every advertised task has exactly one engine_versions entry: FA’s engine
name (or null until an FA engine has loaded), and null for every other
task. A blank or separator-bearing name, or a key that is not a task, is
refused while the report is deserialized; admission
(WorkerEngineReports::admit) refuses a missing entry, an entry for a task
that was not advertised, or a name for any task other than forced alignment
(EngineReportAdmissionError::EngineNamedForNonFaTask, reported as
{"kind": "engine_named_for_non_fa_task", "task": "<task>"}). A refused report
is recorded as that worker key’s refusal (WorkerError::CapabilitiesRefused
to the caller), and /health lists every key’s latest outcome in
worker_capability_admissions, so an operator sees why a worker is not used.
Only forced alignment reads its engine from this map, because its cache rows
are namespaced by that engine before any worker runs: when the FA worker
reports a new engine, cached FA results for the old one miss. A worker that
supports FA but has not loaded it yet still advertises align; dispatch loads
FA on the selected worker (ensure_task), reads the report again, and
FaCacheNamespace::from_loaded refuses only if the engine is still null
after that load (or the report was taken after another task loaded).
Morphosyntax, translation and coreference name their engines on every result
item instead (see Worker Protocol V2),
so their provenance comes from the results a file applied, never from this
map. The commands field remains only as compatibility metadata on the older
infer / batch_infer IPC ops. The authoritative capability contract is
infer_tasks, which alone decides the command surface, plus FA’s
engine_versions entry, which is read only at dispatch.
Checking capabilities at runtime
curl http://localhost:8000/health | python3 -m json.tool
The capabilities field lists all advertised commands. If a command
you expect is missing, the corresponding infer task likely failed its
import probe, or the worker’s report was refused (see
worker_capability_admissions in the same response). Engine names never
decide whether a command is advertised.
See also
- INTERFACE_MAP.md , unified reference for all 9+ Python/Rust interface boundaries (file locations, schema definitions, responsibility splits).
- Per-command engine surfaces (request/response shapes per task, per-command server orchestration steps): on the Dispatch and Execution page.
- Cantonese and CJK, Architecture for the Cantonese-specific Python ↔ Rust seam.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Dispatch and Execution
Status: Current Last updated: 2026-09-15 19:40 EDT
How a job moves from the CLI to a running command: the four CLI dispatch targets, the workflow families that organize commands, the per-command lifecycle, and the recipe-driven execution kernel that’s gradually replacing per-command dispatch functions.
CLI Dispatch: Four Targets
The CLI never owns the ML runtime directly. It routes processing
commands to one of four targets: explicit remote server, managed local
daemon, already-running loopback server, or the in-process direct
host. Source: crates/batchalign/src/cli/dispatch/mod.rs.
flowchart TD
args["Parse CLI args"]
explicit{"--server\nflag?"}
prefer_local{"command prefers\nlocal daemon?"}
remote["Explicit server host\n(content mode:\nPOST jobs / poll results)"]
daemon["Managed local daemon\n(loopback HTTP +\nwarm worker reuse)"]
loopback["Existing loopback server\n(on configured port)"]
local["Direct local host\n(inline execution:\nshared engine, local paths)"]
args --> explicit
explicit -->|yes| prefer_local
prefer_local -->|no| remote
prefer_local -->|yes and auto_daemon| daemon
explicit -->|no| daemon
daemon -->|unavailable| loopback
loopback -->|unavailable| local
1. Explicit server (--server URL)
--server or BATCHALIGN_SERVER selects single-server HTTP dispatch.
CHAT commands use content mode (file text submitted over POST /jobs).
Media-only commands can submit media names when the remote server can
resolve them from media_roots or media_mappings. Multi-server
fan-out is not part of the documented release surface.
2. Managed local daemon
If auto_daemon is enabled, the CLI first tries to reuse or start a
managed loopback daemon. This keeps warm workers alive across
commands. HTTP over loopback only, no remote network. The important
performance path for repeated Apple CPU-only align / transcribe /
benchmark runs because it preserves loaded worker processes and
shared models.
3. Loopback server reuse
If daemon startup is unavailable but a loopback server is already listening on the configured port, the CLI reuses it before falling back to direct inline execution. Reuse is subject to the same build-identity check as submission: a server reporting another build, or no build at all, is refused rather than reused. See Server Mode.
4. Direct local host
If no usable remote or loopback server exists, the CLI prepares a
local paths-mode submission and runs it inline through DirectHost.
The CLI and direct host stay in one process: no HTTP hop, no queue,
no registry discovery, no persistent daemon. The same shared
execution engine still runs the command recipe and worker
orchestration.
Local-daemon-preferring commands
transcribe, transcribe_s, benchmark, and avqi need
client-local media discovery or local audio access. If auto_daemon
is enabled and --server is also passed for one of these commands,
the CLI tries the local daemon first and warns only when that reroute
succeeds. If the local daemon path is unavailable, the explicit remote
URL remains the fallback. benchmark follows the same rules even
though it’s a composite Rust-owned workflow.
Worker transport
CLI ↔ server is HTTP. Server ↔ worker is stdio JSON-lines IPC. The
Python worker entry point in batchalign/worker/_main.py owns the
process lifetime and read/write loop, but Rust owns the generic stdio
op validation and dispatch envelope through the batchalign_core PyO3
bridge. HTTP is not used between the Rust server and Python workers.
Workflow Families
Commands are organized by workflow family. Each family shares an internal stage sequence, but the families share the same end state, typed materialization plus validation.
flowchart TD
registry["recipe_runner/catalog.rs\nthe one CatalogEntry table"]
command["recipe_runner/recipes.rs\nordered stage recipes"]
perfile["PerFileWorkflow\ntranscribe / transcribe_s / align / morphotag / opensmile / avqi"]
batched["CrossFileBatchWorkflow\nutseg / translate / coref"]
projection["ReferenceProjectionWorkflow\ncompare"]
composite["CompositeWorkflow\nbenchmark = transcribe + compare"]
rust["Rust-owned orchestration\n(parse / cache / inject / validate)"]
registry --> command
command --> perfile & batched & projection & composite
perfile & batched & projection & composite --> rust
Command classification
| Class | Commands | Shape |
|---|---|---|
| Generation | transcribe, transcribe_s | Builds ChatFile from ASR output via build_chat() |
| Per-file processing | align, morphotag | Parse, mutate, serialize |
| Cross-file batch | utseg, translate, coref | Pool utterances across files in one GPU batch |
| Reference projection | compare | Main transcript + gold companion → projected views from typed compare bundle |
| Composite | benchmark (= transcribe + compare) | Chains existing workflows without reimplementing |
| Analysis | diarize, opensmile, avqi | Produces turns, metrics, or other non-CHAT output |
The low-level speaker infer task still exists for typed worker
execution but is not itself a CLI command. It is composed by both integrated
transcribe_s and standalone diarize; the latter writes anonymous turns JSON
without constructing or modifying CHAT.
Where to add new command semantics
Start in crates/batchalign/src/commands/ and
crates/batchalign/src/command_model/catalog.rs. Jump to
crates/batchalign/src/command_family.rs when you need
released-command family metadata, and to
crates/batchalign/src/text_batch.rs when you need shared text-family
helpers reused by the runner kernel.
The command module owns the public entrypoint, metadata, and
materialization choice. runner/ stays focused on job lifecycle,
queueing, and shared dispatch machinery.
Processing Lifecycle
Every CHAT-mutating command follows this pattern:
- Parse:
parse_lenient()produces aChatFileAST. - Pre-validate: check input quality against a command-specific
ValidityLevel(e.g.,MainTierValidformorphotag). - Collect payloads: extract per-utterance data from the AST (word lists, text, language metadata).
- Cache check: hash payloads with BLAKE3, partition into hits and misses.
- Infer: send misses to Python workers via typed worker IPC
(
execute_v2on the live infer surfaces). Workers return raw ML output. - Inject: insert results (cache hits + infer results) into the AST.
- Cache put: persist new results for future reuse.
- Post-validate: alignment checks + semantic validation.
- Serialize:
to_chat_string()produces final CHAT output.
Generation commands (transcribe) replace step 1 with ASR inference
followed by build_chat() to construct the initial AST.
ReferenceProjection (compare) intentionally diverges from this
generic loop:
- Pair each primary transcript with
FILE.gold.cha. - Morphotag the main transcript only, and carry morphotag’s own post-validation proof rather than its bytes.
- Parse the gold companion leniently into a
ChatFileAST. The main side is already the document that proof carries, and is never serialized and read back. - Build a
ComparisonBundlewith main/gold compare views, structural word matches, and metrics. - Materialize the released main output or an internal AST-first gold projection.
For per-command request/response JSON shapes and per-command server orchestration steps, see Command Lifecycles. For the boundary contract itself, see Python-Rust Boundary.
Pre-serialization validation
The server runs three validation gates before writing CHAT output:
- Pre-validation: rejects malformed input early based on the
command’s required
ValidityLevel. - Alignment validation: checks tier word counts (
%mor/%gra/%wormust match the main tier). ParseHealth-aware: utterances flagged as unparseable are excluded. - Semantic validation: full CHAT validation (E362 monotonicity, E701/E704 temporal, header correctness). Only blocks on errors, not warnings.
Validation failures trigger bug reports to
~/.batchalign3/bug-reports/ and self-correcting cache purges
(deleting entries that produced invalid output).
Batched Inference
Text-only commands (morphotag, utseg, translate, coref) use
dispatch_batched_infer() to pool utterances across multiple files
into a single worker execute_v2 request backed by one prepared-text
artifact. Improves throughput and model reuse compared to per-file
dispatch without re-expanding the Python control plane. compare is
separate now because it needs both a main transcript and a gold
companion per file.
The morphosyntax orchestrator uses three phases for cache interaction:
collect_payloads(): extract per-utterance payloads with positions.inject_from_cache(): inject cached%mor/%grastrings.inject_results(): inject freshly inferred results.
All cache logic is in Rust. Python workers receive only structured NLP payloads and return raw model output.
Multi-Step Pipelines
transcribe chains multiple steps:
ASR inference → post-processing → CHAT assembly → utseg → morphosyntax
Each step is a separate workflow call (process_transcribe →
process_utseg_with_evidence → process_morphosyntax). Between steps, CHAT text
is serialized and re-parsed, each step operates on a different
version of the file. benchmark follows the same composition style
at the workflow level by chaining transcribe then compare, while
compare itself remains a reference-projection workflow with gold-
and main-shaped materializers.
Command Model + Planning + Execution Kernel
Three modules centralize how commands are defined, planned, and
executed. They replace the old pattern where each command wired its
own dispatch function in runner/dispatch/ with per-command
constants scattered across macro-generated files.
flowchart TD
CLI["CLI input\n(batchalign3 compare ...)"]
Store["Job submitted\n(RunnerJobSnapshot)"]
CmdModel["command_model/\ncommand_spec(command)"]
Plan["planning/\nbuild_job_plan(snapshot)"]
Exec["execution/\nExecutionKernel + StageExecutor"]
Legacy["runner/dispatch/\n(legacy dispatch)"]
Output["CHAT output"]
CLI --> Store
Store --> Plan
Plan --> CmdModel
Plan -->|JobPlan| Exec
Plan -->|JobPlan| Legacy
Exec --> Output
Legacy --> Output
command_model/: authoritative command registry
crates/batchalign/src/command_model/ is the lookup surface over the one
catalog. The data lives in recipe_runner/catalog.rs; this module is how the
rest of the crate reaches it.
| API | Purpose |
|---|---|
command_spec(ReleasedCommand) -> &'static CatalogEntry | The entry for any released command. Total: never None |
command_specs() -> &'static [CatalogEntry] | Every entry, in capability-advertisement order |
released_command_uses_local_audio(command) | Does the server need shared-filesystem audio? |
released_command_supports_paths_mode(command) | May the CLI send paths instead of bodies? |
command_runner_dispatch_kind(command) | Which server-side execution path owns it |
pub(crate) struct CatalogEntry {
pub command: ReleasedCommand,
pub family: CommandFamily, // implies 8 runtime policies, via const fns
pub planner: PlannerKind,
pub capability_kind: CommandCapabilityKind,
pub io_profile: CommandIoProfile,
pub runner_dispatch_kind: RunnerDispatchKind,
pub capabilities: CapabilityPlan, // the one advertised infer task, and the surface
pub output_policy: OutputPolicy,
pub recipe: &'static Recipe,
}
Every field is declared per command. Three of them (capability_kind,
io_profile, runner_dispatch_kind) were derived by matching on the command
name with a catch-all default until 2026-07-29; two view types
(CommandDefinition, CommandWorkflowDescriptor) and a delegating
commands/catalog.rs were deleted in the same change.
The execution mode is NOT among them. It was an execution_mode field written
out beside the recipe that already carries it, with a catalog test asserting
the two stayed equal; the field and the test went on 2026-09-07 and the mode is
read from entry.recipe.mode.
Derived helpers in catalog.rs:
| Helper | Replaces |
|---|---|
io_profile_for(command) | Per-command CommandIoProfile constants |
execution_shape_for(family) | Per-family dispatch routing |
runner_dispatch_kind_for(command) | Runner dispatch shape selection |
planning/: immutable job plans
crates/batchalign/src/planning/. Builds typed, immutable execution
plans from runner snapshots. Centralizes work-unit planning, artifact
planning, and I/O mode resolution.
| Type | Purpose |
|---|---|
JobPlan | CommandSpec + Vec<PlannedWorkUnit> + Vec<PlannedArtifactSet> + IoMode |
IoMode | Paths (shared filesystem) or Content (staged under job directory) |
PlannedWorkUnit | One input file with its resolved paths |
PlannedArtifactSet | Output artifacts for one source file |
pub fn build_job_plan(snapshot: RunnerJobSnapshot) -> Result<JobPlan, PlanError>
Calls command_model::command_spec() internally and delegates
work-unit enumeration to recipe_runner::planner.
execution/: recipe-driven execution kernel
crates/batchalign/src/execution/. Replaces per-command dispatch
functions with a pluggable stage executor that walks recipe stages in
order.
pub struct ExecutionKernel<E: StageExecutor> { ... }
pub trait StageExecutor {
async fn run_stage(
&self,
stage: RecipeStageId,
state: &mut ExecutionState,
plan: &JobPlan,
ctx: &ExecutionContext,
) -> Result<(), ExecutionError>;
}
pub trait WorkerGateway {
async fn morphotag(...) -> Result<...>;
async fn utseg(...) -> Result<...>;
async fn translate(...) -> Result<...>;
// ... one method per NLP task
}
Module map:
| File | Purpose |
|---|---|
kernel.rs | ExecutionKernel: runs stages, manages state transitions |
morphotag/ | Morphotag execution: input prep, window policy, progress, writeback |
coref.rs | Coreference execution stage |
translate.rs | Translation execution stage |
utseg.rs | Utterance segmentation execution stage |
text_io.rs | CHAT file read/write for text-based commands |
worker_gateway.rs | WorkerGateway trait + live implementation over the worker pool |
Compare: first migrated command
pub fn dispatch_compare_job(job, plan: JobPlan) -> Result<...> {
let kernel = ExecutionKernel::new(CompareStageExecutor::new(...));
kernel.run(plan).await
}
CompareStageExecutor recipe stages: PlanWorkUnits →
ReadChatInputs → ReadReferenceInputs (resolve and parse
*.gold.cha companions) → Morphosyntax (morphotag the main
transcripts) → CompareAlign (gold-anchored comparison) →
MaterializeOutputs (write output CHAT + CSV).
Migration status
| Command | Dispatch model | Notes |
|---|---|---|
compare | execution/ kernel | First migration, fully recipe-driven |
| All others | Legacy runner/dispatch/ | Migrate incrementally |
New commands with multi-stage workflows (stages that depend on prior stage output) should use the execution kernel. Simple single-dispatch commands can continue using legacy dispatch until migration is complete.
Worker Concurrency
Worker parallelism is capped based on available memory, not scaled linearly. Each worker loads ~4-12 GB of ML models. The server combines:
HostExecutionPolicyfor tier-aware bootstrap mode and file-parallel clamps.- Host-memory admission planning for granted worker counts.
- Target-aware worker reuse keyed by actual
WorkerTarget.
On large hosts this favors profile reuse; on small hosts it favors task bootstrap so a laptop does not speculatively preload a whole profile. See Batchalign Workers for pool structure, pre-scaling behavior, and host-policy details.
Key Patterns
- Times throughout the pipeline are in milliseconds.
- Language codes use 3-letter ISO 639-3 (
"eng","spa","jpn"). - Files are sorted largest-first before dispatch to avoid stragglers.
- Heavy imports (
stanza,torch) are lazy, CLI startup must stay fast.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Command Lifecycles
Status: Current Last updated: 2026-09-15 19:40 EDT
End-to-end sequence diagrams showing how jobs flow through the system, from CLI invocation to output files. Every batchalign command now fits one of the explicit workflow families surfaced in the new contributor-facing architecture: per-file transform, cross-file batch transform, reference projection, composite workflow, or media analysis. For per-command option-driven flowcharts, see Command Flowcharts.
Contributor rule of thumb: if you are adding new command semantics, start in
crates/batchalign/src/commands/ and then jump to the owning module
(compare.rs, benchmark.rs, transcribe/, fa/, morphosyntax/, etc.).
runner/ owns lifecycle and queueing; dispatch/ should remain thin.
Workflow Families Overview
| Workflow Family | Commands | Parallelism | Key shape |
|---|---|---|---|
| Per-file transform | align, transcribe, transcribe_s, morphotag | Concurrent files (semaphore-bounded by num_workers) | One file in, one primary output out |
| Cross-file batch transform | utseg, translate, coref | Cross-file batching: pool utterances, group by language, dispatch languages concurrently, chunk large language groups across multiple workers | Two-level parallelism: cross-language × intra-language chunking (up to max_workers_per_key per language) |
| Reference projection | compare | Concurrent files, but with two primary CHAT inputs per file | Main+gold comparison bundle plus AST-first materializers |
| Composite workflow | benchmark | Concurrent files (semaphore-bounded by num_workers) | Transcribe first, then compare via typed command composition |
| Media analysis V2 | opensmile, avqi | Concurrent files (semaphore-bounded by num_workers) | Rust prepares audio, sends typed execute_v2 requests, Python returns raw analysis payloads |
All workflow families are server-side orchestrated or Rust-owned at the request boundary.
Parallelism Model
All per-file dispatch shapes (align, transcribe, benchmark,
morphotag, opensmile, avqi) process files concurrently using
supervised tokio::spawn tasks bounded by a
tokio::sync::Semaphore(num_workers). The number of workers is auto-tuned
based on available memory and CPU cores, or set explicitly with --workers N.
Each file opens its first durable attempt before setup work such as input
reads, media resolution, or conversion so early failures are visible in the
attempt history rather than only in terminal file state. Job-level media
prevalidation now uses an explicit file_setup attempt for the same reason.
Per-file dispatch code now routes the common processing/retry/completion
sequence through a shared FileRunTracker helper instead of open-coding those
store mutations in every pipeline. Supervised file tasks also report explicit
FileTaskOutcome values back to the runner so the runner does not need to
infer task success by rereading shared file state after the task exits. Media
preflight failures now use that same lifecycle boundary via an explicit setup
failure path instead of a one-off runner helper. The runner-owned lifecycle
labels are now also typed through FileStage rather than repeated as ad hoc
strings in each dispatch module. The same typed label vocabulary now flows
through the shared internal progress channel used by FA and transcribe
pipelines. The API now exposes that state in two parallel fields:
progress_stage for stable client logic and progress_label as the derived
operator-facing display string.
Batched text commands (utseg, translate, coref) take a
different approach: they pool all utterances from all files, group them by
per-item language, and dispatch with two levels of bounded parallelism. At
the outer level, language groups run concurrently but bounded by a semaphore
(max_total_workers / max_workers_per_key concurrent groups) to prevent
exceeding the global worker cap (morphosyntax/batch.rs). At the inner level,
each language group’s infer_batch call (morphosyntax/worker.rs) splits
large batches into chunks across up to max_workers_per_key workers of the
same language. When a language group finishes and its workers return to the
pool, the next queued group starts. compare
does not use this pooled-text shape any more; it is its own reference
projection workflow because it needs both a main transcript and a gold
companion per file. benchmark is a composite workflow that composes
transcribe and compare rather than inventing its own third orchestration style.
| Shape | File-level parallelism | Within-file parallelism |
|---|---|---|
| Per-file transform | Supervised tasks + Semaphore(N) | Single worker call or Rust composition per file |
| Reference projection | Supervised tasks + Semaphore(N) | Main+gold comparison bundle plus materializers per file |
| Composite workflow | Supervised tasks + Semaphore(N) | Transcribe then compare per file |
| Cross-file batch transform | N/A (single batch) | One GPU batch call covers all files |
| Media analysis V2 | Supervised tasks + Semaphore(N) | Single worker call per file |
Scenario 1: align: 3 files, 2 workers
Forced alignment is the most complex dispatch shape. Each file has its own audio, so files are processed sequentially. Within each file, utterances are grouped into time windows and batched to the worker.
sequenceDiagram
participant CLI
participant Server
participant JobStore
participant Pool as WorkerPool
participant W1 as Worker 1
participant W2 as Worker 2
participant Cache
CLI->>Server: POST /jobs (align, 3 files, lang=eng)
Server->>JobStore: Create job (Queued)
Server-->>CLI: 202 Accepted {job_id}
Note over Server: Runner picks up job
Server->>JobStore: Memory gate check
JobStore-->>Server: OK (idle worker bypass)
Server->>JobStore: Mark Running
Server->>Pool: pre_scale(num_workers=2)
Pool->>W1: spawn (command=align, lang=eng)
Pool->>W2: spawn (command=align, lang=eng)
loop For each file (concurrent, bounded by num_workers semaphore)
Server->>Server: resolve audio via media_mappings
Server->>Server: ensure_wav(): convert mp4→wav if needed (cached)
Server->>Server: parse_lenient()
alt Complete reusable %wor timing
Note over Server: Cheap rerun path:<br/>verify main↔%wor mapping,<br/>rehydrate main-tier word bullets,<br/>refresh utterance bullets / %wor
Server->>JobStore: Mark file Done
else Normal align path
Note over Server: UTR pre-pass (detect-and-skip)
Server->>Server: count_utterance_timing()
alt Untimed utterances exist AND utr_engine configured
Server->>Pool: checkout infer:asr worker
Server->>W1: execute_v2(task="asr", typed_input)
W1-->>Server: typed raw ASR result
Server->>Server: inject_utr_timing(): exact subsequence fast path, else global DP
Note over Server: Untimed utterances get<br/>utterance-level bullets from ASR
else All utterances timed OR no utr_engine
Note over Server: Skip UTR (use existing bullets<br/>or interpolation fallback)
end
Server->>Server: pre-validate (MainTierValid)
Server->>Server: group_utterances() → time windows
Server->>Cache: batch lookup (BLAKE3 keys: words + audio identity + window)
Cache-->>Server: hits[] + misses[]
alt Cache misses exist
Server->>Pool: checkout worker
Pool-->>Server: CheckedOutWorker (RAII)
Server->>W1: execute_v2(task="fa", prepared_audio + prepared_text)
Note over W1: Read prepared artifacts,<br/>run FA model
W1-->>Server: typed raw FA timings
Note over Server: Drop CheckedOutWorker → returns to pool
end
Server->>Server: parse_fa_response(): DP-align model output to transcript
Server->>Server: apply_fa_results(): inject timings + postprocess
Note over Server: Timing: chunk-relative → file-absolute ms<br/>Generate %wor tier<br/>Monotonicity check (E362)<br/>Same-speaker overlap (E704)
Server->>Cache: store new entries
Server->>Server: post-validate → serialize CHAT
Server->>JobStore: Mark file Done
end
end
Server->>JobStore: Mark job Completed
CLI->>Server: GET /jobs/{id}/results
Server-->>CLI: output files
Walkthrough
- CLI discovers
.chafiles in the input directory (sorted largest-first), submits them as a single job viaPOST /jobs. - Server creates the job in
Queuedstate and returns immediately. - The runner checks the memory gate, if an idle worker already exists
for
(align, eng), the memory check is bypassed entirely. - Pre-scaling spawns 2 worker processes to avoid sequential spawn overhead. Workers load the FA model (Whisper or Wave2Vec) at startup.
- Files are processed concurrently, bounded by
num_workersvia atokio::sync::Semaphore. For each file, the server resolves the audio file by walking the parent directory or using media mappings for matching.wav/.mp3/.mp4files. If the resolved file is MP4 (or another container format),ensure_wavconverts it to WAV via ffmpeg and caches the result at~/.batchalign3/media_cache/(see Media Conversion). 5b. Cheap rerun path: After parsing, the server first checks whether the file already has complete reusable%wortiming. If main↔%woralignment is clean and every mapped%worword is timed, the server copies that timing back to main-tier words, refreshes utterance bullets, resolves adjacent-utterance end overlap (--end-overlap-policy), and only THEN optionally regenerates%worfrom that resolved state; FA is skipped for the file entirely. 5c. UTR pre-pass (detect-and-skip): If the file is not already fully reusable, the server callscount_utterance_timing(). If untimed utterances exist and a UTR engine is configured (--utr, the default), it runs a Rust-owned UTR backend on the full audio, theninject_utr_timing()first tries a cheap exact-subsequence match and falls back to one global Hirschberg DP when the transcript/ASR match is missing or ambiguous. For--utr-engine rev, the server uses the shared Rust Rev.AI client directly. For worker-backed engines such as Whisper, the server still uses the worker ASR task. If all utterances are already timed, UTR is skipped entirely. If no UTR engine is configured (--no-utr), untimed utterances fall back to proportional interpolation. The updated CHAT text (with recovered bullets) is then used for FA grouping. 5c. The server groups utterances into time windows (max 20s for Whisper, 15s for Wave2Vec). - Cache lookup uses BLAKE3 hashes of (words + audio identity + time window
- gap-healing policy + engine). Cache hits skip worker IPC entirely.
- Cache misses are sent to a checked-out worker via typed
execute_v2requests. TheCheckedOutWorkerRAII guard returns the worker to the pool on drop. - The Rust server DP-aligns model timestamps to transcript words
(Hirschberg algorithm), converts chunk-relative times to file-absolute
milliseconds, generates
%wortiers, and runs monotonicity/overlap checks. - New results are cached for future reuse.
- The CLI polls for results and writes output files.
Scenario 2: morphotag: 2 multilingual files, per-file dispatch
Morphotag processes files concurrently, bounded by num_workers.
Within each file, utterances are analyzed independently (with optional
cache hits) and results are injected back into the AST.
sequenceDiagram
participant CLI
participant Server
participant Pool as WorkerPool
participant W as Worker
participant Cache
CLI->>Server: POST /jobs (morphotag, 2 files)
Server-->>CLI: 202 Accepted {job_id}
Note over Server: Runner: mark Running, select dispatch_morphotag_job
loop For each file (concurrent, bounded by num_workers semaphore)
Server->>Server: parse_lenient()
alt @Options: CA in header
Server->>Server: serialize parsed file as-is (no %mor/%gra added)
Server->>JobStore: Mark file Done
else
Server->>Server: clear existing %mor/%gra
Server->>Server: collect_payloads()
Server->>Cache: batch lookup all utterances
Cache-->>Server: hits[] + misses[]
Server->>Server: inject cache hits immediately
alt Misses exist
Server->>Pool: checkout worker
Pool-->>Server: CheckedOutWorker
Server->>W: execute_v2(task="morphosyntax", misses batch)
W-->>Server: UD results
Note over Server: Worker returned to pool
end
Server->>Server: inject_results() → insert %mor/%gra tiers
Server->>Server: validate alignment → serialize CHAT
Server->>JobStore: Mark file Done
end
end
Server->>JobStore: Mark job Completed
CLI->>Server: GET /jobs/{id}/results
Server-->>CLI: output files
Walkthrough
- CLI submits CHAT files for morphosyntactic enrichment.
- The runner selects the per-file dispatch path (
dispatch_morphotag_job). - Files are processed concurrently, bounded by
num_workers. This prevents the BA2 over-parallelism crash mode while maximizing throughput on multi-core hosts. - For each file, the server parses the transcript. If the parsed
header declares
@Options: CA, the file is serialized back as-is, no%mor/%gratiers are added or removed, and no provenance comment is injected (mirroringalign’s@Options: NoAlignpass-through). Otherwise the server clears any stale morphology and collects payloads (word lists + language metadata). - Cache lookup checks all utterances in the file at once. BLAKE3 keys include (words + language + terminator + special forms + engine version).
- Cache misses are sent to a checked-out worker in a single batch. The worker runs the Stanza NLP pipeline for the appropriate language(s).
- Results (both from cache and worker) are injected back into the file’s
AST, inserting new
%morand%gratiers. - The file is validated (ensuring morphology matches the main tier) and serialized back to CHAT.
- Each file’s result is written to disk immediately as it finishes, allowing for incremental progress visibility on large corpora.
- This per-file shape replaces the previous complex cross-file windowing logic, providing better reliability and simpler progress reporting.
Scenario 2b: compare: 1 main file + 1 gold companion
Compare is the reference-projection shape. It pairs each primary transcript with
a FILE.gold.cha companion, morphotags only the main side, and materializes one
or more outputs from a typed comparison bundle.
sequenceDiagram
participant CLI
participant Server
participant Pool as WorkerPool
participant W as Worker
participant Cmp as compare()
CLI->>Server: POST /jobs (compare, 1 main file)
Server-->>CLI: 202 Accepted {job_id}
Server->>Server: Resolve FILE.gold.cha companion
alt Missing gold companion
Server->>Server: Mark file Error
else Gold companion present
Server->>Pool: checkout worker
Pool-->>Server: CheckedOutWorker
Server->>W: execute_v2(task="morphosyntax", main transcript only)
W-->>Server: typed morphosyntax result
Note over Server: Worker returned to pool
Server->>Server: PostValidated::into_judged_document() -> AST_main
Server->>Server: parse_lenient(raw gold) -> AST_gold
Server->>Cmp: compare(AST_main, AST_gold)
Note over Cmp: conform -> per-gold window search -> local DP<br/>main view + gold view + structural word matches + metrics
Cmp-->>Server: ComparisonBundle
Server->>Server: project_gold_structurally() on gold AST
Note over Server: Exact matches copy %mor / %gra / %wor;<br/>unsafe partial projection stays conservative
Server->>Server: build typed %xsrep / %xsmor models\nlower once to gold AST tiers
opt Internal benchmark/main path
Server->>Server: MainAnnotatedCompareMaterializer<br/>reuse typed tier models on main AST
end
Server->>Server: CompareMetricsCsvTable -> csv crate -> .compare.csv
Server->>Server: validate -> serialize
Server-->>CLI: output .cha + .compare.csv
end
Walkthrough
- The CLI submits only primary
.chainputs; the gold companion is resolved by the compare planner/dispatch layer. - BA3 runs morphosyntax on the main transcript only. The gold transcript stays raw during artifact construction so deletions retain reference-side shape instead of picking up invented tags.
compare()performs BA2-style per-gold-utterance window selection and local DP, then returns aComparisonBundlecontaining main-anchored tokens, gold-anchored tokens, structural gold↔main word matches, and aggregate metrics.- The released materializer projects onto the gold/reference AST and injects
%xsrep/%xsmorthere. The main-annotated materializer still exists for internal benchmark-style flows, but it is no longer the compare command surface. %xsrep/%xsmorare emitted from typed compare-tier models, not from raw string hacking, and.compare.csvis rendered from the same bundle through a structured table model. That keeps transcript annotations and metric output in lockstep.
Scenario 3: transcribe: 1 file, audio to CHAT
Transcription creates CHAT from scratch rather than modifying existing files. It has the longest pipeline: ASR → post-processing → CHAT assembly → optional follow-up commands.
Speaker label handling: convert_asr_response() always uses speaker
labels from the ASR engine when present (matching BA2’s process_generation()
which unconditionally reads utterance["speaker"]). The --diarization flag
only controls whether a dedicated Pyannote/NeMo stage runs, it does not
suppress ASR-provided labels. This means batchalign3 transcribe (without
--diarization) still produces multi-speaker output when Rev.AI returns
speaker-labeled monologues. When --diarization enabled is explicitly
requested, BA3 runs the dedicated speaker stage even on top of Rev-labeled
output and applies its evidence before utterance segmentation.
Rev.AI skip_postprocessing: For English only,
skip_postprocessing=true is sent to Rev.AI (matching BA2), so BA3’s own
pre-CHAT utterance model handles segmentation from raw output. In --lang auto
mode, the Rust server first runs Rev.AI language ID. If that resolves to a
supported language such as English before submission, the request path becomes
the same as explicit --lang eng. If language ID fails or returns an unmapped
code, BA3 keeps a true Rev auto request instead; downstream processing may
still later resolve the output to English, but the provider request was not the
same as --lang eng.
sequenceDiagram
participant CLI
participant Server
participant Pool as WorkerPool
participant W as Worker
CLI->>Server: POST /jobs (transcribe, 1 audio file)
Server-->>CLI: 202 Accepted {job_id}
Note over Server: Runner: mark Running
Server->>Server: resolve audio path
Server->>Server: ensure_wav(): convert mp4→wav if needed (cached)
alt Rev.AI engine selected
Server->>Server: derive evidence key and acquire per-key lease
alt valid durable evidence hit
Server->>Server: replay provider-shaped transcript evidence
else typed cache miss
Server->>Server: authorize one Rev.AI request
Server->>Server: optional language ID, submit, poll, validate
Server->>Server: durably commit evidence before continuing
end
else worker-backed ASR engine
Server->>Pool: checkout worker
Pool-->>Server: CheckedOutWorker
Server->>W: execute_v2(task="asr", typed_input)
Note over W: Run local or provider-backed ASR model
W-->>Server: typed raw ASR result
Note over Server: Worker returned to pool
end
Note over Server: convert_asr_response(): ALWAYS groups<br/>tokens by speaker label (no flag gating)
opt --diarization enabled
Server->>Pool: checkout worker
Pool-->>Server: CheckedOutWorker
Server->>W: execute_v2(task="speaker", prepared_audio)
Note over W: Run diarization model<br/>(pyannoteAI default, local alternatives explicit)
W-->>Server: typed raw speaker result (speaker_result)
Note over Server: Worker returned to pool
opt --debug-dir configured
Server->>Server: Write exact same-job canonical turns<br/>with typed backend provenance
end
end
Note over Server: Rust ASR normalization over typed monologues
Server->>Server: 1. Compound merging (adjacent subword tokens)
Server->>Server: 2. Timed word extraction (seconds to ms)
Server->>Server: 2d. Cantonese normalization, once per monologue (lang=yue only)
Server->>Server: 3. Multi-word splitting (timestamp interpolation)
Server->>Server: 4. Number expansion (digits to words)
Server->>Server: 5. Long-turn splitting (chunk at >300 words)
Server->>Server: 5b. Long-pause fallback splitting
opt dedicated speaker segments present
Server->>Server: project_speakers_onto_chunks():<br/>assign timed words by summed overlap,<br/>split at speaker changes
end
opt language has BA2 utterance model (eng/zho/yue)
Server->>W: execute_v2(task="utseg", prepared word batch)
Note over W: BA2-style model returns typed boundary assignments
W-->>Server: typed assignments
Note over Server: Apply assignments before CHAT build
end
Server->>Server: 6. Retokenization (punctuation fallback / cleanup)
Server->>Server: 7. Disfluency cleanup + retrace detection
Server->>Server: build_chat(): ChatFile AST
Note over Server: Generate headers: @Languages, @Participants,<br/>@ID (PAR/INV/CHI/MOT...), @Media<br/>Build utterances with %wor tiers<br/>Speaker codes from ASR labels used directly
opt with_utseg=true (default)
Server->>Server: process_utseg_with_evidence(): re-segment utterance boundaries
end
opt with_morphosyntax=true (default: false)
Server->>Server: process_morphosyntax(): add %mor/%gra tiers
end
Server->>Server: validate, serialize, .cha output
Server-->>CLI: output .cha file
Walkthrough
- Rev.AI evidence resolution: For Rev.AI-backed transcription, the server
hashes the complete provider-visible inference media plus all
inference-affecting request settings, then acquires a per-key singleflight
lease. A valid hit replays provider-shaped transcript evidence without a
network request. A typed miss is the only state allowed to authorize
language ID and paid submission; validated evidence must be durably committed
before the pipeline continues. For English,
skip_postprocessing=trueis sent so BA3’s own pre-CHAT utterance model handles segmentation. In--lang automode there are two real branches:- Language ID succeeds and maps cleanly: BA3 collapses to a resolved
language before submission. If it resolves to
eng, the Rev request path is the same as explicit--lang eng. - Language ID fails or returns an unmapped code: BA3 submits a true Rev
auto request. Downstream code may still later resolve the transcript to
English for segmentation and CHAT headers, but provider-side options such
as
speakers_countandskip_postprocessingwere not the explicit-English ones. The former parallel pre-submission path is currently disabled because it submitted work before cache lookup and bypassed typed miss authorization. This protects correctness and billing at the cost of lower cold-cache batch throughput until a cache-aware typed parallel planner is implemented.
- Language ID succeeds and maps cleanly: BA3 collapses to a resolved
language before submission. If it resolves to
- The worker or Rust-owned Rev path returns a typed ASR response. BA3
preserves both a flattened token view and provider-shaped monologues so
later stages can keep punctuation and speaker boundaries instead of trying to
re-infer them from plain text. The inference boundary has zero CHAT
awareness.
2b. Speaker label handling:
convert_asr_response()always groups tokens by their speaker labels when present. There is nouse_speaker_labelsparameter, this matches BA2’s unconditional speaker reading. The--diarizationflag only gates the dedicated speaker stage (step 2c), not the use of ASR-provided labels. 2c. Dedicated diarization (optional): If--diarization enabledis set, the server dispatchesexecute_v2(task="speaker"). The typed backend is pyannoteAI Precision-2 by default, with local Pyannote and NeMo alternatives. ASR-provided labels are read first, but the explicit dedicated result is authoritative. 2d. Same-job turn retention (optional): If--debug-diris configured, BA3 writes the exact dedicated segments before they can be discarded. The typed label-coordinate map is shared with CHAT projection, provenance is derived fromSpeakerBackendV2, and an enabled write failure fails the file. This is an interim debug/research artifact, not the final durable evidence sidecar. - All post-processing happens in Rust (
batchalign), not Python. The normalization stages inprepare_asr_chunks()are:- Compound merging, joins adjacent subword tokens
- Timed word extraction, seconds to milliseconds, filter pauses
2d. Cantonese normalization (lang=yue only), simplified to traditional via
ferrous-opencc+ domain replacements (pure Rust), run once over the whole monologue before anything splits it - Multi-word splitting, split space-separated tokens, interpolate timestamps
- Number expansion, digits to spelled-out words (language-aware)
- Long-turn splitting, chunk monologues at >300 words 5b. Long-pause fallback splitting, split strongly separated runs when provider punctuation is missing
- Speaker projection and pre-CHAT utterance segmentation: Dedicated
segments are first projected onto timed ASR words by greatest summed overlap,
and prepared chunks are split at label changes. For supported languages (
eng,zho,yue), BA3 now calls the BA2 utterance model at this seam through the V2utsegworker task. Python returns typed word-group assignments, Rust applies them to the prepared ASR chunks, and only then does punctuation retokenization run as fallback/cleanup. This seam runs on the effective resolved language seen by post-processing, so both Revauto -> resolved engand Revauto -> true auto request -> later resolved engcan eventually reach the English utterance model. Only the first branch is provider-request equivalent to explicit--lang eng. - CHAT assembly (
build_chat) creates a completeChatFileAST with proper headers (participant codes derived from speaker indices: PAR, INV, CHI, MOT, etc.) and utterances with%wortiming tiers. Speaker-safe chunks already carry the labels from the dedicated projection when it ran. - Optional follow-up commands (utseg defaults on, morphotag defaults off) are chained automatically, reusing the same worker pool.
Scenario 4: Server Startup & Lazy Capability Detection
At startup the server recovers persisted state and begins accepting jobs. Capability detection is lazy: there is no probe worker at startup. Instead, capabilities are detected from the first real worker spawn for each profile.
sequenceDiagram
participant Server
participant Pool as WorkerPool
participant W as First Worker
participant DB as SQLite
Server->>DB: Mark queued/running jobs interrupted
Server->>DB: Prune expired entries
Server->>DB: Load jobs and reconcile runtime state
Note over Server,DB: Requeue resumable work; promote all-terminal jobs to final state; persist canonical status and cleared leases
Server->>DB: Load persisted jobs, init utterance cache
Note over Server: Server ready, accepting requests<br/>(capabilities not yet known)
Note over Server: First job arrives (e.g., morphotag)
Server->>Pool: checkout worker (morphotag, eng)
Pool->>W: python -m batchalign.worker --task morphosyntax --lang eng
W-->>Pool: {"ready": true, "pid": N}
Server->>W: capabilities()
Note over W: Import-probe each InferTask:<br/>stanza → Morphosyntax, Utseg, Coref ✓<br/>googletrans → Translate ✓<br/>torch+torchaudio → FA ✓<br/>whisper or Rev key → ASR ✓<br/>parselmouth+torchaudio → AVQI ✓<br/>(no opensmile) → OpenSMILE ✗
W-->>Server: CapabilitiesResponse {infer_tasks, engine_versions, commands=[]}
Server->>Pool: record_capabilities(): admit the report once, store it per worker key
Note over Server: One availability rule (command_supported, primary infer task only):<br/>morphotag needs Morphosyntax ✓<br/>utseg needs Utseg ✓<br/>translate needs Translate ✓<br/>coref needs Coref ✓<br/>align needs FA ✓ (FA engine name read at dispatch, after FA loads)<br/>opensmile needs OpenSMILE ✗ → excluded
Server->>Server: Build final capabilities list
Note over Server: /health now advertises:<br/>commands: [morphotag, utseg, translate, coref, align, transcribe, ...]<br/>infer_tasks: [Morphosyntax, Utseg, Translate, Coref, FA, ASR, ...]
Note over Server: Worker stays in pool for actual job work
Walkthrough
- DB recovery: Any jobs left in
QueuedorRunningstate from a previous crash are first markedInterrupted, and expired entries are pruned. - Runtime reconciliation:
JobStore::load_from_db()rebuilds each job and then uses theJobrecovery transition to choose a canonical state: resumable files are re-queued, while all-terminal jobs are promoted toCompletedorFailed. The reconciled status and cleared lease metadata are written back to SQLite so memory and persistence agree. - The server begins accepting requests immediately. Capabilities are not yet known, they are populated lazily.
- When the first job arrives, the server spawns a real worker for the
requested command. During this first worker’s startup, the server calls
capabilities()which import-probes eachInferTask: for each task, the worker tries to import the required Python packages (e.g.,stanzafor Morphosyntax,torch+torchaudiofor FA). If imports succeed, the task is reported as available. Itsengine_versionsentry isnull, except forced alignment’s, which names the FA engine once an FA model has loaded. - The worker stays in the pool for actual job work, it is not shut down after capability detection.
WorkerPool::record_capabilities()admits the report once (WorkerEngineReports::admit: every advertised task needs an entry, only forced alignment’s may be a name, and no entry may name an unadvertised task) and stores the outcome per worker key: the admitted report, or the refusal, which/healthreturns inworker_capability_admissions.WorkerCapabilitySnapshot::detected()then derives the released command surface withcapability::command_supported: a command is advertised when the worker supports its primary infer task, whether or not the model behind it has loaded. Engine names are not consulted. At dispatch the same step runs again on a post-load report, and the forced-alignment dispatch arm (onlyalign, whose cache rows are namespaced by the FA engine) additionally reads the FA engine from that report withFaCacheNamespace::from_loaded, refusing a job whose worker still names no FA engine after the load.- The
/healthendpoint advertises the validated capability set once it is known. The CLI checks this before submitting jobs, if a required command is missing, it errors immediately rather than queueing a job that will fail.
Cross-Cutting Concerns
CHAT Ownership Boundary
In all scenarios above, the Rust server owns the full CHAT lifecycle: parsing, AST manipulation, validation, caching, and serialization. Python workers receive extracted data (word lists, audio paths) and return raw ML output. No CHAT text crosses the IPC boundary.
Cache Behavior
Cache checks happen before any worker IPC. A fully-cached file (e.g., re-running morphotag on unchanged input) completes without touching a Python worker at all. Cache keys include the engine version, so model upgrades automatically invalidate stale entries.
Error Boundaries
- Worker crash: Pool detects exit, decrements worker count, spawns replacement on next checkout.
- Retryable errors (FA timeout, Rev.AI throttle): Exponential backoff
with configurable
max_attempts. - Terminal errors (parse failure, validation rejection): File marked
Error, job continues processing remaining files. - Memory pressure: Job re-queued with backoff rather than OOM-killed.
Worker Pool Mechanics
Workers are keyed by (CommandName, LanguageCode3). The pool uses
Mutex<VecDeque> for the idle queue and tokio::sync::Semaphore for
availability. CheckedOutWorker is an RAII guard that returns the worker
to the pool on drop, no manual checkin needed.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Command Flowcharts
Status: Current Last updated: 2026-09-16 03:36 EDT
Option-driven flowcharts for every batchalign processing command. Each diagram shows how CLI flags route through different code paths at runtime. For the higher-level dispatch sequence diagrams, see Command Lifecycles.
align
The most complex command. CLI flags control FA engine selection, timing mode, UTR pre-pass behavior, and incremental processing.
flowchart TD
start([align invoked]) --> read[Read CHAT file]
read --> resolve_audio[Resolve audio file]
resolve_audio --> ensure_wav[ensure_wav: convert mp4→wav if needed]
ensure_wav --> parse[parse_lenient → ChatFile]
parse --> reuse_check{Complete reusable\n%wor timing?}
reuse_check -->|Yes| reuse[Refresh main-tier bullets from %wor\nmechanically, resolve overlap, THEN\noptionally regenerate %wor from the\nresolved state]
reuse_check -->|No| count[count_utterance_timing → timed, untimed]
reuse --> done([Output .cha file])
count --> utr_check{untimed > 0?}
utr_check -->|No| skip_utr[Skip UTR, all timed]
utr_check -->|Yes| utr_engine_check{--utr engine\nconfigured?}
utr_engine_check -->|Yes: --utr| run_utr_pass["run_utr_pass()"]
utr_engine_check -->|No: --no-utr| warn_interp[Log warning\nFall back to interpolation]
run_utr_pass --> utr_done[Re-serialize CHAT\nwith recovered timing]
utr_done --> group
warn_interp --> group
skip_utr --> group
group[group_utterances → time windows]
group --> before_check{--before path\nprovided?}
before_check -->|Yes| incremental[process_fa_incremental\nDiff old vs new, copy stable %wor,\nreuse preserved groups]
before_check -->|No| full[process_fa\nProcess all groups]
incremental --> engine_select
full --> engine_select
engine_select{--fa-engine?}
engine_select -->|whisper| whisper_fa[Whisper engine\nonset times only\nmax_group_ms from the engine = 20000]
engine_select -->|wav2vec / cantonese| wav2vec_fa[Wave2Vec engines\nword start+end\nmax_group_ms from the engine = 15000]
whisper_fa --> pause_check
wav2vec_fa --> pause_check
pause_check{--pauses?}
pause_check -->|Yes| preserve[WordGapHealing::PreserveMeasured\nkeep each word's own end]
pause_check -->|No| heal[WordGapHealing::Heal\nextend a word to the next word's start\nwhen the gap is small and plausible]
preserve --> cache_check
heal --> cache_check
cache_check[Cache lookup: BLAKE3 keys]
cache_check --> worker_infer[execute_v2(task="fa") misses → Python FA worker\nprepared audio + prepared text]
worker_infer --> dp_align_fa[DP-align model output → transcript words]
dp_align_fa --> inject_fa[Inject word-level timings into AST]
inject_fa --> retry_check{FA\nsucceeded?}
retry_check -->|Yes| overlap_policy
retry_check -->|No + retryable| fallback_check{Untimed utts\nnot recovered?}
fallback_check -->|Yes + not tried| fallback_utr["Fallback: run_utr_pass()\n(at most once)"]
fallback_utr --> retry_loop[Retry FA with\nrecovered timing]
retry_loop --> cache_check
fallback_check -->|No or already tried| backoff[Backoff + retry]
backoff --> cache_check
overlap_policy{"--end-overlap-policy?\n(default: preserve-cross-speaker)"}
overlap_policy -->|"preserve-cross-speaker (default)"| resolve_same[Resolve same-speaker overlap from\nmeasured word hulls; cross-speaker untouched]
overlap_policy -->|clamp-all-adjacent| resolve_all[Same resolution for every\nadjacent pair, any speakers]
resolve_same --> wor_check
resolve_all --> wor_check
wor_check{--wor / --nowor?}
wor_check -->|--wor| gen_wor["Generate %wor tier\n(from the RESOLVED state)"]
wor_check -->|--nowor| skip_wor[Omit %wor tier]
gen_wor --> merge_check
skip_wor --> merge_check
merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations transform]
merge_check -->|No| validate
merge --> validate[Post-validate → serialize CHAT output]
validate --> done([Output .cha file])
UTR Detail: Strategy Selection (Auto disabled)
When --utr-strategy auto (the default), the strategy is currently always
GlobalUtr regardless of file content or language. Two-pass overlap-aware
recovery is reachable only via the explicit --utr-strategy two-pass
override.
flowchart TD
auto(["--utr-strategy auto\n(default)"]) --> always_global["GlobalUtr\n(monotonic single-pass)"]
explicit_global(["--utr-strategy global"]) --> force_global["GlobalUtr\n(explicit override)"]
explicit_two(["--utr-strategy two-pass"]) --> force_two["TwoPassOverlapUtr\n(explicit override)"]
Why Auto is currently disabled (2026-03-30): the previous content/
language-aware gate auto-picked TwoPassOverlapUtr for English files
containing +< or CA overlap markers. It was disabled after an operator
reported alignment regressions on real files; investigation found that
enforce_monotonicity() only checks start times, not end times, so
overlapping utterance bullets go uncorrected. The two-pass tuning was also
based on four corpora and never broadly validated. The previously-measured
gains under that mechanism (English: +4.3pp SBCSAE, +3.8pp Jefferson;
non-English on Hakka/Welsh/German/Serbian: GlobalUtr matched or beat
TwoPassOverlapUtr) are retained here as historical context for the
benchmark numbers that motivated the original gate, not as a description
of current behavior.
Implementation: ResolvedUtrStrategy::from_options() in
crates/batchalign/src/runner/dispatch/options.rs resolves Auto to global
and retains explicit two-pass tuning. The language-agnostic
overlap-detection helper select_strategy() in
crates/batchalign/src/chat_ops/fa/utr.rs (library) remains, but is no
longer called from the Auto path.
UTR Detail: run_utr_pass() internals
The UTR pre-pass and fallback share the same run_utr_pass() helper,
which chooses between full-file and partial-window ASR:
flowchart TD
entry(["run_utr_pass()"]) --> parse[Parse CHAT\ncount timed vs untimed]
parse --> zero{untimed == 0?}
zero -->|Yes| noop([Return: nothing to do])
zero -->|No| ratio{untimed < 50%\nAND audio > 60s?}
ratio -->|Yes| partial_mode
subgraph partial_mode [Partial-Window ASR]
direction TB
pw_find[find_untimed_windows\nPadding: 500ms, merge overlaps]
pw_find --> pw_loop["For each window (start, end):"]
pw_loop --> pw_seg_cache{Segment\ncache hit?}
pw_seg_cache -->|Hit| pw_use[Use cached segment ASR]
pw_seg_cache -->|Miss| pw_extract["extract_audio_segment()\nffmpeg -ss/-to → cached WAV"]
pw_extract --> pw_infer[infer_asr on segment]
pw_infer --> pw_store[Cache segment result]
pw_store --> pw_use
pw_use --> pw_offset[Offset token times\nby window start_ms]
pw_offset --> pw_loop
end
ratio -->|No| full_mode
subgraph full_mode [Full-File ASR]
direction TB
ff_cache{Full-file\ncache hit?}
ff_cache -->|Hit| ff_use[Use cached ASR]
ff_cache -->|Miss, non-Rev| ff_infer[infer_asr on full audio]
ff_cache -->|Miss, Rev| ff_rev{Validated raw Rev\nevidence?}
ff_rev -->|Hit| ff_project[Project retained timed words]
ff_rev -->|Typed miss| ff_rev_call[Authorized Rev request]
ff_rev_call --> ff_rev_commit[Validate + required durable commit]
ff_rev_commit --> ff_project
ff_project --> ff_store
ff_infer --> ff_store[Cache full result]
ff_store --> ff_use
end
partial_mode --> inject
full_mode --> inject
inject["inject_utr_timing()\nExact-subsequence fast path,\nelse global DP"]
inject --> result([Return updated CHAT + UtrResult])
UTR Detail: Zero-Duration Bullet Prevention
Zero-duration utterance bullets (•T_T•, where start_ms == end_ms) fail E362
validation and perpetuate across every subsequent align re-run: the FA
postprocess clamps all word timings to the utterance range [T, T], drops
every word bullet (nothing survives the start >= end check), and
update_utterance_bullet then has nothing to work from, so the zero-duration
bullet is preserved unchanged.
The root cause is Whisper’s 20ms DTW grid, which can return the same timestamp for multiple adjacent short words (“mhm”, “yeah”). This affects short backchannels in dense-dialogue corpora (first seen in OCSC).
BA3 applies a three-layer defence in crates/batchalign/src/chat_ops/fa/utr.rs
and crates/batchalign/src/chat_ops/fa/orchestrate.rs:
flowchart TD
asr["ASR token stream\n(Whisper DTW output)"] --> filter
subgraph L1 ["Layer 1: Token filter (dispatch/utr.rs)"]
filter{"token.end_ms\n<= token.start_ms?"}
filter -->|Yes| drop1[Drop token before UTR\nsees it at all]
filter -->|No| utr_in[Pass to UTR]
end
utr_in --> dp["Global DP alignment\nCHAT words ↔ ASR tokens"]
dp --> assign["Plan: per-utterance word matches\nand a typed UtrTimingProposal\n(Positive | NonPositive), decided once"]
subgraph L2 ["Layer 2: UTR span guard (utr.rs project_global_utr_plan)"]
assign --> zdcheck{"proposal is\nNonPositive?"}
zdcheck -->|Yes| drop2[Leave utterance untimed\ncount as unmatched]
zdcheck -->|No| mono_check
end
subgraph L3 ["Layer 3: UTR monotonicity pass (utr.rs project_global_utr_plan)"]
mono_check{"utt.start_ms\n< prev_non_overlap.end_ms?"}
mono_check -->|Yes: DTW collision| advance["Advance start_ms = prev.end_ms\nExtend end_ms if needed"]
mono_check -->|No| assign_bullet[Assign utterance bullet]
advance --> assign_bullet
end
assign_bullet --> fa_in["FA postprocess\n(orchestrate.rs)"]
subgraph L4 ["Safety net: monotonicity enforcement (orchestrate.rs)"]
fa_in --> mono_enforce{"prev.end_ms\n> next.start_ms?"}
mono_enforce -->|end_clamp safe| clamp_end[Clamp prev.end_ms\nto next.start_ms]
mono_enforce -->|would produce\nzero-duration| strip[Strip bullet entirely\nbetter untimed than •T_T•]
end
Why three layers instead of one?
Each layer catches a different failure mode:
| Layer | Where | What it catches |
|---|---|---|
| 1 | dispatch/utr.rs asr_response_to_utr_tokens | Whisper returning start==end for a single-frame token |
| 2 | utr.rs project_global_utr_plan reading the plan’s UtrTimingProposal::NonPositive | DP aligning an utterance to an ASR token range whose span is zero or negative. The verdict is decided once, where the plan is built (UtrTimingProposal::spanning); projection takes no token stream and cannot re-derive it |
| 3 | utr.rs project_global_utr_plan monotonicity post-pass | Two adjacent non-overlap utterances assigned to tokens with the same start_ms (DTW collision at a shared boundary) |
| Safety net | orchestrate.rs enforce_monotonicity | Residual overlaps from any source; when clamping would produce zero-duration, strip entirely |
Layer 3 is the root-cause fix. Layers 1 and 2 handle degenerate single-token
cases. The safety net handles anything that slips through (e.g. cross-speaker
overlap that is not marked with +<).
Why not just rely on the safety net? Because stripping a bullet destroys
timing, the utterance goes back to untimed and must be recovered by FA. The
UTR-level fixes preserve timing: advancing start_ms to prev.end_ms keeps
both utterances timed and valid.
BulletSource Provenance: The Self-Healing Design
The three layers above prevent UTR from producing bad bullets. A complementary mechanism ensures that even if UTR set a slightly imprecise window, FA word timings are authoritative after alignment.
Every Bullet carries a non-serialized source: BulletSource field (in
talkbank-model/src/model/content/bullet.rs):
BulletSource | Who sets it | update_utterance_bullet behavior |
|---|---|---|
Utr | UTR pre-pass via Bullet::utr_hint() | Overwrite with FA word span |
Authoritative | Parser (hand-linked), Bullet::new(), or FA-derived | Union (never shrink) |
BulletSource is #[serde(skip)]: it never appears in CHAT output and
doesn’t change the file format.
flowchart TD
utr_bullet["UTR sets\nBullet::utr_hint(800, 3000)\nBulletSource::Utr"]
hand_bullet["Parser reads hand-linked bullet\nBullet::new(37397, 42983)\nBulletSource::Authoritative"]
utr_bullet --> fa_inject["FA injects per-word timings\n1000_1500, 1500_2000"]
hand_bullet --> fa_inject
fa_inject --> update["update_utterance_bullet()"]
update --> check{"source?"}
check -->|"Utr"| overwrite["Overwrite: bullet = 1000_2000\nFA span is authoritative"]
check -->|"Authoritative"| union_op["Union: bullet = 37397_42983\npreserves filler/gesture coverage"]
check -->|"None"| set["Set: bullet = word span"]
overwrite --> auth["Mark result as Authoritative"]
union_op --> auth
set --> auth
Why union for authoritative bullets? Hand-linked utterances may start
before the first FA-alignable word (e.g., &-uh filler that FA returns None
for) or end after the last word (e.g., a trailing &=laughs gesture). Without
union, re-running FA on these utterances would silently shrink their timing,
losing the hand-annotated context coverage. This was a real bug
encountered in the ACWT corpus. The BulletSource design preserves
the correct
behavior for authoritative bullets while enabling the self-healing property
for UTR hints.
Contrast with batchalign2 (jan9 baseline, commit 84ad500b):
BA2 uses the same conceptual approach, DP alignment of ASR tokens against the reference transcript, utterance-level timing derived from word-level timing, but its implementation differs in two important ways:
-
Utterance timing is derived dynamically, not stored.
Utterance.alignmentindocument.py:182is a computed property: it scans forward/backward throughword.timeto find the first and last timed word. There is no explicit utterance bullet; the CHAT serializer writes it on demand. This means a zero-duration utterance bullet can only arise if the first and last timed words in an utterance share a timestamp, which BA2 prevents at the word level insidewhisper_fa.py:183-224: each word’send_msis set to the start of the next word, and any word wherestart >= endis dropped (word.time = None). -
No cross-utterance monotonicity enforcement. BA2 performs no check that
utt_n.alignment[0] >= utt_{n-1}.alignment[1]. Adjacent utterances can and do share start timestamps from DTW collisions; BA2 tolerates this because it never runs anenforce_monotonicitypass that would turn the shared start into a zero-duration span.
Why BA3 needs explicit monotonicity enforcement: BA3 separates UTR (bullet
assignment) from FA (word-level timing), whereas BA2 derives utterance timing
from word timing. This means BA3 can produce utterance bullets that are valid
on their own but conflict with each other (same start_ms), and the
enforce_monotonicity end-clamp pass, which BA2 does not have, converts
those into zero-duration spans. The Layer 3 fix eliminates the conflict at the
source before enforce_monotonicity ever sees it.
transcribe
Creates CHAT from audio. The longest pipeline, with optional follow-up commands chained automatically.
When do you need --diarize?
- Rev.AI (the default engine): Rev.AI returns multi-speaker labels
natively as part of its ASR response. Those labels are always applied
to the transcript, you get multi-speaker output without
--diarize. - Rev.AI with explicit
--diarize: BA3 runs a dedicated speaker stage on top of Rev output and treats its segments as authoritative before utterance segmentation. - Whisper-based engines (
whisper,whisper_hub,whisper_rs): these engines do not return speaker labels. Passing--diarize(or--diarization enabled) runs a dedicated speaker model as an additional stage. - Default:
--diarization auto= disabled. Identical to batchalign2’s--diarize/--nodiarize default=False. The old BA2 help text claiming Rev ignored--diarizewas stale; the pipeline wiring did not ignore it.
Rev.AI skip_postprocessing: For English (en) and French (fr),
Rev.AI is called with skip_postprocessing=true, matching BA2. This lets
BA3’s own BERT utterance segmentation model handle sentence boundaries from
raw ASR output, rather than relying on Rev.AI’s built-in punctuation which
produces giant monologue blobs. For all other languages, Rev.AI applies its
own post-processing.
flowchart TD
start([transcribe invoked]) --> resolve[Resolve audio file]
resolve --> ensure_wav[ensure_wav: convert if needed]
ensure_wav --> diarize_check{--diarization?}
diarize_check -->|"enabled"| transcribe_s["Command: transcribe_s\nASR + dedicated speaker relabeling\nRev or Whisper"]
diarize_check -->|"auto/disabled\n(default)"| transcribe_m["Command: transcribe\nDefault path\nRev labels used directly when present"]
transcribe_s --> engine_check
transcribe_m --> engine_check
engine_check{--asr-engine?}
engine_check -->|whisper| whisper[Whisper local ASR]
engine_check -->|rev| rev_cache["Rev.AI evidence resolution\ncontent-addressed lookup + per-key lease"]
engine_check -->|"whisperx, whisper_oai"| refused["Refused: engine not implemented"]
rev_cache --> rev_hit{valid evidence hit?}
rev_hit -->|Yes| rev_replay[Replay provider-shaped transcript]
rev_hit -->|No| rev_infer["Typed miss authorizes Rev.AI\nlanguage ID + submit + poll"]
rev_infer --> rev_commit[Validate and durably commit evidence]
rev_replay --> asr_tokens
rev_commit --> asr_tokens
whisper --> asr_tokens
asr_tokens["Raw ASR tokens\nword + start_s + end_s + optional speaker + confidence"]
asr_tokens --> convert["convert_asr_response()\nALWAYS groups tokens by speaker label\nNo use_speaker_labels parameter"]
convert --> dedicated_check{"--diarization enabled?"}
dedicated_check -->|No| postprocess
dedicated_check -->|Yes| speaker_v2["execute_v2(task=speaker)\nprepared audio → raw diarization segments\npyannoteAI Precision-2 by default"]
speaker_v2 --> postprocess
subgraph postprocess ["Rust post-processing: process_raw_asr()"]
direction TB
p1[1. Compound merging] --> p2[2. Timed word extraction\nseconds → milliseconds]
p2 --> p2check{lang=yue?}
p2check -->|Yes| p2d["2d. Cantonese normalization\nonce per monologue\nOpenCC + domain replacements"]
p2check -->|No| p3
p2d --> p3
p3[3. Multi-word splitting\ntimestamp interpolation]
p3 --> p4[4. Number expansion\ndigits → word form]
p4 --> p5[5. Long-turn splitting\nchunk at >300 words]
p5 --> p6[6. Retokenization\npunctuation-based utterance splitting]
end
postprocess --> speaker_apply{Dedicated speaker\nsegments present?}
speaker_apply -->|Yes| project["project_speakers_onto_chunks()\nAssign timed words by summed overlap\nSplit at speaker changes"]
speaker_apply -->|No| utseg_check{"with_utseg?\ndefault: true"}
project --> utseg_check
utseg_check -->|Yes| run_utseg[process_utseg_with_evidence\nBERT-based re-segmentation]
utseg_check -->|No| build_chat
run_utseg --> build_chat["build_chat → ChatFile AST\nHeaders, participants, %wor tiers"]
build_chat --> mor_check{"with_morphosyntax?\ndefault: false"}
mor_check -->|Yes| run_mor[process_morphosyntax\nPOS + lemma + depparse]
mor_check -->|No| merge_check
run_mor --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| output
merge --> output[Serialize → .cha output]
output --> done([Output .cha file])
diarize
Produces anonymous acoustic speaker turns without ASR or CHAT mutation. The
standalone command defaults to local Pyannote and can explicitly select
pyannoteAI Precision-2 or local NeMo. Integrated
transcribe --diarization enabled defaults to pyannoteAI Precision-2 and is
shown separately in the transcribe diagram above. Both use the same typed
raw/derived speaker-evidence cache.
flowchart TD
start([diarize invoked]) --> resolve[Resolve media inputs]
resolve --> source[Admit exact inference-source bytes\n+ versioned PCM-preparation recipe]
source --> key[Content + semantic request identity]
key --> derived{Validated derived turns?}
derived -->|hit| tracks
derived -->|miss| raw{Validated raw evidence?}
raw -->|hit| normalize[Versioned local normalization]
raw -->|miss / refresh| prepare[Rust materializes canonical\nmono 16 kHz float32 PCM]
prepare --> select{--speaker-engine}
select -->|pyannote default| local[Local TalkBank Pyannote]
select -->|pyannote-ai| cloud[Paid pyannoteAI Precision-2]
select -->|nemo| nemo[Local NeMo]
local --> validate[Validate + durably commit raw evidence]
cloud --> validate
nemo --> validate
validate --> normalize
normalize --> tracks[Map native labels deterministically\nto anonymous PAR0..PARn tracks]
tracks --> turns[Write one .turns.json artifact\nper input file]
turns -. optional later step .-> chatter["chatter rediarize\nproject tracks onto existing CHAT"]
chatter --> roles["chatter speaker-id + external evidence\nor adjudication assigns semantic CHAT roles"]
morphotag
Adds %mor and %gra tiers. Files are processed independently and
concurrently (bounded by num_workers).
flowchart TD
start([morphotag invoked]) --> parse[Parse file → AST]
parse --> ca_check{"@Options: CA\nin header?"}
ca_check -->|Yes| ca_passthrough[Serialize parsed file as-is\nNo %mor/%gra added\nNo provenance injected]
ca_passthrough --> done
ca_check -->|No| clear[Clear existing %mor/%gra tiers]
clear --> collect[collect_payloads\nPer-utterance word lists with language metadata]
collect --> retok_check{--retokenize?}
retok_check -->|Yes: --retokenize| stanza_retok[TokenizationMode::StanzaRetokenize\nStanza may split/merge words]
retok_check -->|No: --keeptokens| preserve[TokenizationMode::Preserve\nKeep original tokenization]
stanza_retok --> lang_check
preserve --> lang_check
lang_check{--skipmultilang?}
lang_check -->|Yes| skip_non_primary[MultilingualPolicy::SkipNonPrimary\nSkip utterances in non-primary language]
lang_check -->|No: --multilang| process_all[MultilingualPolicy::ProcessAll\nProcess all utterances regardless of language]
skip_non_primary --> cache
process_all --> cache
cache[Cache lookup: BLAKE3 keys\nwords + lang + terminator + special forms + engine version]
cache --> inject_hits[Inject cache hits immediately]
inject_hits --> worker[execute_v2(task="morphosyntax") misses\nprepared_text batch → Stanza NLP pipeline]
worker --> inject_results[inject_results → insert %mor/%gra tiers]
inject_results --> before_check{--before path?}
before_check -->|Yes| incremental[process_morphosyntax_incremental\nSkip NLP for unchanged utterances]
before_check -->|No| full_inject[Process all utterances]
incremental --> merge_check
full_inject --> merge_check
merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| validate
merge --> validate[Alignment validation\n%mor word count must match main tier]
validate --> done([Output .cha file])
@Options: CA pass-through: When the file’s header declares
@Options: CA, the pipeline skips morphotagging entirely and serializes
the parsed file unchanged (mirroring @Options: NoAlign for align).
The decision is made once per file from the option header; no
per-utterance content scan is involved.
utseg
Utterance segmentation. Pools all utterances across files into a single GPU batch.
flowchart TD
start([utseg invoked]) --> parse[Parse all files → ASTs]
parse --> collect[collect_payloads\nExtract word sequences per utterance]
collect --> cache[Cache lookup: BLAKE3 keys\nwords + lang]
cache --> worker[execute_v2(task="utseg") misses\nprepared_text batch → raw parse trees]
worker --> apply[Apply segmentation\nSplit/merge utterances at predicted boundaries]
apply --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| serialize
merge --> serialize[Serialize → .cha output]
serialize --> done([Output .cha files])
translate
Translates utterances and injects %xtra tiers.
flowchart TD
start([translate invoked]) --> parse[Parse all files → ASTs]
parse --> collect[collect_payloads\nExtract utterance text + source/target language]
collect --> cache[Cache lookup: BLAKE3 keys\ntext + src_lang + tgt_lang]
cache --> worker[execute_v2(task="translate") misses\nprepared_text batch → raw translations]
worker --> inject[inject %xtra tiers with translated text]
inject --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| serialize
merge --> serialize[Serialize → .cha output]
serialize --> done([Output .cha files])
coref
Coreference resolution. Document-level, sparse output, English-only.
flowchart TD
start([coref invoked]) --> parse[Parse all files → ASTs]
parse --> collect[collect_payloads\nExtract sentences: full document context]
collect --> worker[execute_v2(task="coref")\nprepared_text batch → structured chain refs]
worker --> inject[inject %xcoref tiers, sparse\nOnly utterances with coreferent mentions]
inject --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| serialize
merge --> serialize[Serialize → .cha output]
serialize --> done([Output .cha files])
style collect fill:#ffd,stroke:#aa0
note1[No caching: full-document context\nmakes per-utterance keys meaningless]
collect --- note1
compare
Reference-projection workflow. The released command now emits the projected reference transcript, and the benchmark/internal main-shaped path is a separate materializer rather than the command contract.
flowchart TD
start([compare invoked]) --> discover[Discover primary .cha files\nskip *.gold.cha companions]
discover --> pair[Pair FILE.cha with FILE.gold.cha]
pair --> found{Gold companion found?}
found -->|No| fail[Report file error]
found -->|Yes| morph[process_morphosyntax\nmain transcript only]
pair --> parse_gold[parse_lenient raw gold\n→ gold AST]
morph --> parse_main[MorphotaggedMain::from_proof\n→ the judged main document\n(never re-parsed from text)]
parse_main --> bundle[compare()\nconform + local window search + local DP\nComparisonBundle: main view, gold view,\nstructural word matches, metrics]
parse_gold --> bundle
bundle --> released[GoldProjectedCompareMaterializer\nproject_gold_structurally()]
bundle --> internal_main[MainAnnotatedCompareMaterializer (internal/benchmark)\ninject %xsrep / %xsmor on main]
released --> safe{Exact structural match?}
safe -->|Yes| copy[Copy %mor / %gra / %wor]
safe -->|No, full gold coverage| mor_only[Project %mor only]
safe -->|No, partial or unsafe| keep[Keep gold dependent tiers unchanged]
copy --> goldannot[Inject %xsrep / %xsmor on gold]
mor_only --> goldannot
keep --> goldannot
goldannot --> merge_check
internal_main --> internal_done([Internal main-annotated view])
merge_check -->|Yes| merge[merge_abbreviations]
merge_check -->|No| metrics[Write .compare.csv]
merge --> metrics
metrics --> done([Output .cha + .compare.csv])
The public command now uses the projected-reference branch. The main-annotated branch remains available only for internal consumers such as benchmark.
opensmile
Acoustic feature extraction. Rust resolves media, prepares typed audio, and sends a live V2 request to the Python worker.
flowchart TD
start([opensmile invoked]) --> resolve[Resolve audio files]
resolve --> prep[Rust audio prep\nprepare mono PCM artifact]
prep --> feature_check{--feature-set?}
feature_check -->|eGeMAPSv02| egemaps[eGeMAPSv02 features\n88 acoustic descriptors]
feature_check -->|ComParE_2016| compare[ComParE_2016 features\n6,373 acoustic descriptors]
feature_check -->|Custom| custom[Custom feature set name]
egemaps --> worker
compare --> worker
custom --> worker
worker[execute_v2(task=\"opensmile\") → Python worker\nExtracts acoustic features from prepared audio]
worker --> output[Write CSV output\nContent-type: csv]
output --> done([Output .csv files])
avqi
Acoustic Voice Quality Index. Rust resolves paired audio, prepares typed PCM
artifacts, and sends a live V2 request. Requires paired continuous speech
(.cs.wav) and sustained vowel (.sv.wav) audio.
flowchart TD
start([avqi invoked]) --> resolve[Resolve paired audio files\n.cs.wav + .sv.wav per speaker]
resolve --> prep[Rust audio prep\nprepare CS + SV PCM artifacts]
prep --> worker[execute_v2(task=\"avqi\") → Python worker\nparselmouth + torchaudio analysis]
worker --> output[Write AVQI results\nHarmonics-to-noise ratio, jitter, shimmer, etc.]
output --> done([Output results])
benchmark
Composite workflow that runs transcribe, then compare, and materializes both the hypothesis CHAT and the CSV metrics.
flowchart TD
start([benchmark invoked]) --> resolve[Resolve audio file + companion gold .cha]
resolve --> transcribe[Rust transcribe workflow\nProduce hypothesis CHAT]
transcribe --> compare[Rust compare workflow\nDP alignment + WER metrics]
compare --> merge_check{--merge-abbrev?}
merge_check -->|Yes| merge[Merge abbreviations in hypothesis CHAT output]
merge_check -->|No| output
merge --> output[Write hypothesis .cha + .compare.csv]
output --> done([Output results])
There is no CLI command literally named speaker; that name belongs to the
low-level worker task. User-facing diarization is available both inside
transcribe --diarization enabled and through standalone diarize.
Cross-Cutting: Cache Behavior
All processing commands (except coref) follow this cache interaction
pattern. The cache policy is controlled by --override-media-cache.
flowchart TD
start([Cache check]) --> policy{--override-media-cache?}
policy -->|No| lookup[BLAKE3 hash → cache lookup\nHot: moka in-memory\nCold: SQLite]
policy -->|Yes: --override-media-cache| skip_cache[Skip cache, force recompute]
lookup --> hit{Cache hit?}
hit -->|Yes| inject_cached[Inject cached result\nNo worker IPC needed]
hit -->|No| miss[Send to Python worker]
skip_cache --> miss
miss --> infer[Worker returns raw ML output]
infer --> cache_put[Store result in cache\nRow written under the task's namespace]
cache_put --> inject_fresh[Inject fresh result into AST]
inject_cached --> done([Continue pipeline])
inject_fresh --> done
Cross-Cutting: Incremental Processing (–before)
Supported by morphotag and align. Compares old vs new CHAT to skip
unchanged content.
flowchart TD
start([--before provided]) --> read_before[Read before file]
read_before --> diff[diff_chat: classify utterances\nAdded / Removed / Modified / Unchanged]
diff --> preserve[Preserve stable dependent tiers\nand refresh reusable timing]
preserve --> filter[Filter: only reprocess\nAdded + Modified content that still needs work]
filter --> process[Run NLP only where reuse and cache\ncannot satisfy the request]
process --> merge_results[Merge: preserved results from before\n+ fresh results for changed]
merge_results --> done([Full output with minimal recomputation])
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Progress Reporting
Status: Current Last updated: 2026-07-30 18:21 EDT
The server reports per-file progress to all connected clients (CLI, TUI, React dashboard) in real time. This chapter covers the data model, data flow, and how to add progress reporting to new commands.
Scope. This page covers per-file progress, the stage codes and
sub-file counters emitted by each file’s orchestrator. Batch-level
progress (per-language-group utterance counters for cross-file batched
commands like morphotag, utseg, translate, coref) is documented separately
in Observability Architecture,
including the per-language tagger (morphosyntax/worker.rs::infer_batch)
that rewrites worker progress events before the drain loop keys them. The
two surfaces co-exist: each file shows its own stage here, and the batch
as a whole shows language-group counters there.
Data Model
Four fields on FileStatus carry progress information. Three are ephemeral
in-memory fields, and one is the derived display label exposed at the API
edge:
| Field | Type | Purpose |
|---|---|---|
progress_stage | Option<FileProgressStage> | Stable machine-readable stage code |
progress_label | Option<String> | Human-readable label derived from progress_stage |
progress_current | Option<i64> | Current counter (e.g. group 3) |
progress_total | Option<i64> | Total items (e.g. 7 groups) |
The typed stage and numeric counters are never persisted to SQLite: they
exist only in the in-memory JobStore and are broadcast via WebSocket. They
are cleared automatically when a file reaches a terminal state (Done or Error).
progress_label is not stored independently; the server derives it from the
typed stage when projecting the API response.
Data Flow
Orchestrator (fa.rs, transcribe pipeline, etc.)
→ ProgressSender (unbounded channel)
→ Forwarder task (spawned per file)
→ set_file_progress(): updates FileStatus with `progress_stage` + calls notify_file()
→ WebSocket broadcast → all connected clients
The CLI TUI consumes the same progress stream through a reducer boundary instead of shared UI state:
flowchart LR
poll["CLI poll loop"] --> sink["TuiProgress"]
sink --> queue["Unbounded TuiUpdate queue"]
queue --> runtime["TuiRuntime"]
runtime --> state["AppState reducer"]
state --> draw["ratatui draw"]
Two Tiers of Progress
Tier 1: Stage Codes (dispatch layer)
The dispatch layer sets a typed FileStage at lifecycle transitions. Every
processing file shows at least a stage name (“Reading”, “Resolving audio”,
“Aligning”, “Writing”), but the label is derived later from the stage code.
No orchestrator changes needed.
set_file_progress() at
crates/batchalign/src/runner/util/file_status/tracker.rs:126 is the helper:
set_file_progress(store, job_id, filename, FileStage::Aligning, None, None).await;
Tier 2: Sub-file Numeric Progress (orchestrator)
Orchestrators report fine-grained progress via a ProgressSender channel.
The dispatch layer creates the channel with spawn_progress_forwarder() and
passes the sender to the orchestrator.
let progress_tx = spawn_progress_forwarder(store.clone(), job_id, filename);
process_fa(..., Some(&progress_tx)).await;
Inside the orchestrator:
if let Some(tx) = progress {
let _ = tx.send(ProgressUpdate::new(
FileStage::Aligning,
Some(3),
Some(7),
));
}
Per-Command Progress Stages
align (forced alignment)
| Stage | Label | current/total |
|---|---|---|
| Mark processing | “Reading” | , |
| Read CHAT | “Resolving audio” | , |
| UTR pre-pass (partial) | “Recovering utterance timing” | 1/W, 2/W, … W/W windows |
| UTR pre-pass (full-file) | “Recovering utterance timing” | 0/1 |
| Audio resolved | “Aligning” | , |
| Cache check | “Checking cache” | 0/N groups |
| Cache partition | “Aligning” | hits/N groups |
| Each group done | “Aligning” | done/N groups |
| Apply results | “Applying results” | N/N |
| Write output | “Writing” | , |
transcribe
| Stage | Label | current/total |
|---|---|---|
| Mark processing | “Resolving audio” | , |
| Audio resolved | “Transcribing” | , |
| ASR inference | “Transcribing” | 0/total_stages |
| Post-processing | “Post-processing” | 1/total_stages |
| Build CHAT | “Building CHAT” | 2/total_stages |
| Optional utseg | “Segmenting utterances” | 3/total_stages |
| Optional morphosyntax | “Analyzing morphosyntax” | 4/total_stages |
| Finalize | “Finalizing” | N/total_stages |
| Write output | “Writing” | , |
morphotag / utseg / translate / coref (batched)
| Stage | Label | current/total |
|---|---|---|
| Mark processing | Command-specific label | , |
| Read each file | “Reading” | , |
| Pre-batch count | Command-specific label | 0/N files |
| Orchestrator running | (same label) | 0/N (frozen) |
| Write each result | “Writing” | 1/N, 2/N, … N/N |
Labels by command: morphotag → “Analyzing”, utseg → “Segmenting”, translate → “Translating”, coref → “Resolving coreference”, compare → “Comparing”.
The batch total is published before inference starts so the frontend can show how many files are in the batch, even though individual files don’t advance during the inference call. After inference, each file transitions to “Writing” with a per-file counter as results are saved to disk.
opensmile / avqi (media-analysis V2)
| Stage | Label |
|---|---|
| Audio prep and conversion | “Resolving audio” |
| Worker request running | “Processing” |
| Writing output artifact | “Writing” |
benchmark (Rust-owned benchmark pipeline)
| Stage | Label |
|---|---|
| Mark processing | “Resolving audio” |
| Rust benchmark orchestrator running | “Benchmarking” |
| Writing output artifacts | “Writing” |
What Users See
CLI (indicatif)
[=====> ] 3/50 files [00:42]
⠋ align: Aligning 5/12
TUI (ratatui)
morphotag: 3/50 files 3✓ 2⠋ 1✗ 44· [00:42] ~03:15
Workers: infer:asr:eng · infer:morphosyntax:eng
Memory: [████████████░░░░░░░░] 148/256 GB Gate: 2 GB ● safe
⠋ corpus001.cha ●●●○○ Aligning 5/12 1:23
⠋ corpus002.cha ●○○○○ Resolving audio 0:05
✓ corpus003.cha 2.1s
· corpus004.cha
▼ 42 more below
The TUI render thread owns the full AppState, grouped into progress,
directory-view, error-panel, metrics, and interaction sub-state. Polling code
only sends typed TuiUpdate messages, so rendering and navigation state are
not shared behind a mutex.
Header: Status breakdown (3✓ 2⠋ 1✗ 44·), elapsed time, and ETA
(throughput-based ~MM:SS). On completion, shows “Done!” or “Done, N failed”.
Pipeline phase dots: processing file rows show a 5-dot indicator
(●●○○○) using the same phase mapping as the React PipelineStageBar.
Completed phases are green, the active phase is cyan, and future phases are
gray. Dots only appear when the server reports a typed progress_stage.
Per-file elapsed: processing files show a running M:SS timer from
started_at, helping spot stuck files.
Scroll indicators: ▲ N more above / ▼ N more below at group edges.
Auto-collapse: non-focused all-terminal groups show condensed titles.
Error codes: error panel entries include structured codes from poll data.
Gate warning: memory gauge warns when near or below gate threshold.
Health metrics: the TUI polls GET /health every ~5 seconds (slower
than the job status poll) and renders two rows between the header gauge and
the directory groups:
- Worker line: lists active
live_worker_keys. - Memory gauge: 20-character bar with used/total GB and gate proximity coloring (green >4×, yellow 2-4×, red <2× headroom above gate threshold).
The m key toggles the metrics rows. The ProgressSink trait has an
update_health() method (default no-op) that TuiProgress implements to
forward HealthResponse into the reducer as a TuiUpdate::HealthSnapshot.
React Dashboard
The dashboard (frontend/) consumes progress data via both WebSocket push
(real-time file_update events) and REST polling (health endpoint for system
panels). It renders several distinct progress surfaces:
File-Level Progress (FileTable)
In frontend/src/components/FileTable.tsx, each processing file row shows:
- Pipeline phase indicator (
PipelineStageBar), 5 compact segments mapping the 23FileProgressStagevariants to visual phases: Read → Transcribe → Align → Analyze → Finalize. The active segment pulses using the existingstatus-dot-pulseCSS animation. Completed phases are filled; future phases are gray. Component:frontend/src/components/PipelineStageBar.tsx. - Label-only stages: italic text next to the status dot
- Label + counter: inline blue mini-bar with counter (e.g., “Aligning 3/7”)
- Indeterminate shimmer: shown for batched commands while no files have completed, proving the app is alive during the frozen inference window
- Stage-specific hints: subtle italic text explaining why a stage is slow
(e.g., “Rev.AI runs roughly in real-time”). Defined in
stageHint()inProcessingProgress.tsx. - Elapsed timer: always visible while running, ticks every second
Dashboard System Panels
The main dashboard page (/dashboard) uses a two-column layout. The right
column stacks three system-health panels:
-
WorkerProfilePanel (
frontend/src/components/WorkerProfilePanel.tsx), parseslive_worker_keysstrings from the health endpoint into profile summaries (GPU/Stanza/IO). Shows active/idle counts, languages, engine overrides, and a model-sharing callout for the GPU profile. -
MemoryPanel (
frontend/src/components/MemoryPanel.tsx), displays system RAM usage from the health endpoint fieldssystem_memory_total_mb,system_memory_available_mb,system_memory_used_mb. Shows a segmented gauge bar with thememory_gate_threshold_mbmarked as a vertical line. Color-codes proximity to the gate threshold (green/amber/red) and shows cumulative gate rejection count. -
VitalsRow (
frontend/src/components/VitalsRow.tsx), compact badges for operational counters:worker_crashes,forced_terminal_errors,memory_gate_aborts,attempts_started,attempts_retried,deferred_work_units. Only nonzero counters render. Error counters are red, warnings amber, throughput counters gray.
Health Endpoint Memory Fields
The HealthResponse struct exposes system memory data for the dashboard:
pub system_memory_total_mb: u64, // sysinfo::total_memory()
pub system_memory_available_mb: u64, // sysinfo::available_memory()
pub system_memory_used_mb: u64, // total - available
pub memory_gate_threshold_mb: u64, // from ServerConfig
These are queried fresh on each GET /health call via sysinfo::System. On
macOS, available_memory() returns only free + purgeable (not inactive), which
can undercount effective availability. The dashboard shows the raw values
without correction.
Stage Type Contract
The dashboard should treat progress_stage as the stable contract field.
progress_label exists so the UI can render operator-facing text without
copying label-generation logic into every client, but client branching should
key off the typed stage whenever possible.
Per-Command Progress Expectations (Developer Reference)
When adding progress to a new command, consider:
-
Batched commands (text NLP): Reading → pre-batch 0/N → inference (frozen) → Writing 1/N..N/N. The pre-batch count lets the frontend show the batch size. Individual files appear frozen during inference because the model processes them all at once.
-
Per-file commands (align, transcribe): Each file progresses independently through its own stages. Use
spawn_progress_forwarder()for sub-file counters. Report meaningful milestones (group completion, window completion) rather than every small step. -
Long sub-stages (UTR, transcription): If a sub-stage takes more than a few seconds, pass a
ProgressSenderso it can report sub-progress. Even 0/1 for a single-unit operation is better than nothing, it tells the frontend which stage is active and enables stage-specific hint text. -
Stage hints: The React dashboard shows contextual hints (e.g., “Rev.AI runs roughly in real-time”) for known slow stages. When adding a new slow stage, add a corresponding hint in
stageHint()inProcessingProgress.tsx.
Adding Progress to a New Command
-
Tier 1: Add
set_file_progress()calls in the dispatch function at stage transitions. -
Tier 2 (if the command has long-running per-file work):
- Add
progress: Option<&ProgressSender>to the orchestrator signature - Call
spawn_progress_forwarder()in the dispatch layer - Send
ProgressUpdateat meaningful points inside the orchestrator
- Add
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
Replacements in the batchalign3 Pipeline
Status: Current Last updated: 2026-05-19 20:22 EDT
This page documents how batchalign3 handles CHAT replacement
annotations ([: ...]) end-to-end. For the canonical CHAT-format
definition of what a replacement is (syntax, scope, AST shape,
per-domain alignment rule), see
the CHAT-format replacements reference in the chatter project.
That reference is the source of truth; this doc is the pipeline-specific
companion.
TL;DR
batchalign3never emits[: ...]annotations. It preserves and consumes replacements that the user’s existing corpus contains. Domain-aware extraction routes the replacement form to%mor/%gra, and the original form to%wor/%pho/%sin/ FA, per the CHAT-format rule. Four-site invariants in FA enforce that the two sides stay in sync; an analogous (currently undocumented) three- site invariant exists for%mor.
What batchalign3 Does and Doesn’t Do
| Operation | Status | Where |
|---|---|---|
| Parse an existing replacement from input CHAT | ✅ Yes | talkbank-parser (delegated; this repo doesn’t reimplement) |
| Preserve a replacement through extract→modify→inject round trips | ✅ Yes | batchalign content walkers |
Route the right side to %mor/%gra extraction | ✅ Yes | extract.rs::collect_replaced_word |
Route the left side to %wor / FA / %pho / %sin | ✅ Yes | fa/extraction.rs, extract.rs (per-domain branches) |
| Re-serialize to valid CHAT including the replacement | ✅ Yes | model-layer WriteChat |
Emit a new [: ...] annotation programmatically | ❌ No | Nothing in this codebase constructs a ReplacedWord |
Sanitize an ASR token by wrapping it in [: ...] | ❌ No (and would not work, replacement words are validated; see talkbank-tools doc §“Each Replacement Word Is Validated”) | |
| Normalize a token via direct text mutation | ✅ Yes, separate mechanism | asr_postprocess::cleanup (e.g. um → &-um is a text edit, not a replacement annotation) |
This distinction is the most common source of confusion: the
batchalign3 pipeline produces “cleaned” word text by mutating
AsrWord.text in place during ASR post-processing, NOT by emitting
replacement annotations. A reader skimming the pipeline for
“replacement” will find consume-side code only.
Per-Domain Extraction Policy
Domain-aware word extraction in
../chatter/crates/talkbank-transform/src/extract.rs::collect_replaced_word
(line 129) is the central seam. When the
walker encounters a ReplacedWord leaf, it picks one side of the pair
based on the requested TierDomain:
| Domain (caller) | Side extracted | Use |
|---|---|---|
TierDomain::Mor | replacement words (replaced.replacement.words) | Stanza receives the corrected/intended form for UD analysis |
TierDomain::Wor | original word (replaced.word) | %wor (timing) sees what was actually spoken |
TierDomain::Pho | original word | %pho describes phonology of what was spoken |
TierDomain::Sin | original word | %sin records spelling-in-actual |
None | both, recursing into all leaves | for transforms that traverse all content |
flowchart TD
asr["AsrWord stream\n(raw ASR tokens)"] -->|"asr_postprocess pipeline"| chat["ChatFile AST\n(includes user-supplied ReplacedWord nodes\nwhen reading existing CHAT)"]
chat --> extract{"extract.rs::collect_replaced_word\nbranches on TierDomain"}
extract -->|"Mor"| mor_words["Replacement words\n→ Stanza"]
extract -->|"Wor / FA"| orig_w["Original word\n→ %wor / FA"]
extract -->|"Pho"| orig_p["Original word\n→ %pho"]
extract -->|"Sin"| orig_s["Original word\n→ %sin"]
mor_words --> mor_inject["inject.rs::inject_morphosyntax\n(1:1 against replacement words)"]
orig_w --> wor_inject["model-layer\nWorTier::from_words\n(1:1 against original)"]
mor_inject --> chat
wor_inject --> chat
chat --> serialize["WriteChat → output .cha"]
This flow is the read-and-respect direction. The write-and-emit direction (a hypothetical “produce a new replacement from ASR output”) is not implemented anywhere in the pipeline.
The Four-Site Invariant (Forced Alignment)
For forced alignment specifically, the policy “a ReplacedWord
contributes exactly one word to FA, the original spoken word, not the
replacement words” is enforced at four code sites that must stay in
sync. If any one site uses the wrong side, alignment desyncs by the
delta in word count, and every subsequent timing in the same FA group
shifts.
| Site | File:line | What it does |
|---|---|---|
| Extraction | crates/batchalign/src/chat_ops/fa/extraction.rs | Sends the original word to the FA worker |
| Count | crates/batchalign/src/chat_ops/fa/mod.rs | Counts 1 for the ReplacedWord (regardless of replacement word count) |
| Injection | crates/batchalign/src/chat_ops/fa/injection.rs | Consumes 1 cursor slot, sets replaced.word.inline_bullet |
| Preservation | crates/batchalign/src/chat_ops/fa/mod.rs (collect_existing_fa_word_timings) | Reads replaced.word.inline_bullet |
The 2026-04-08 Bug This Invariant Prevents
Before this invariant was codified, an extraction site sent the
replacement words while the count site still counted 1 for the
ReplacedWord. For dis [: this], FA received 1 token (this) but
the count expected 1 (dis), the symptom was correct for that
word. But for <dis [: this] is> style content, the count and
extraction disagreed, and every subsequent word in the FA group got
the wrong timing. The invariant was named after that incident.
The Read-Only Test That Validates It
crates/batchalign/src/chat_ops/fa/tests/grouping_and_wor.rs::test_wor_policy_replacements_use_original_surface()
constructs a fixture with what's is dis [: this] ? and asserts the
extracted FA word list is ["what's", "is", "dis"]: note this is
absent. If extraction drifts to using the replacement, the test fails
loudly.
The Three-Site Invariant (%mor)
By symmetry with FA, an analogous invariant exists for %mor,
extract, count, inject, that has historically been implicit. Naming
it here so future readers can find it:
| Site | File:line | What it does |
|---|---|---|
| Extraction | ../chatter/crates/talkbank-transform/src/extract.rs:129 (collect_replaced_word) | Sends the replacement words to Stanza |
| Count | model.utterance.mor_alignable_word_count() (delegated to talkbank-model) | Counts replacement-word count, not 1 |
| Injection | crates/batchalign-transform/src/morphosyntax/injection.rs::inject_results | Asserts injected %mor item count == count, then injects |
The reason this invariant has stayed implicit: extract/count/inject
all delegate to talkbank-model’s domain-aware rules
(TierDomain::Mor), so as long as the model’s per-domain rule is
correct, the three sites stay synchronized for free. They are not
independently coded against each other the way FA’s four sites are.
Test Coverage Gap
No test in this repo currently exercises %mor injection where the
main-tier word is a ReplacedWord. The %mor injection code is
correct by construction (it reuses TierDomain::Mor extraction), but
a regression test would catch any future drift. Recommended fixture:
*CHI: wanna [: want to] go .
%mor: v|want inf|to v|go .
%gra: 1|3|AUX 2|3|MARK 3|0|ROOT 4|3|PUNCT
The test would assert:
- Extraction under
TierDomain::Moryields["want", "to", "go"](not["wanna", "go"]). mor_alignable_word_count()returns 3 (not 2).- Injection succeeds when given a 3-item
%morline.
This is logged as an action item for follow-up; the analysis lives in the maintainers’ working notes.
What the Pipeline Does NOT Do
These are operations the pipeline deliberately does not perform. Listing them here so a contributor doesn’t reach for the wrong tool.
- Emit replacements from ASR normalization. ASR cleanup mutates
AsrWord.textdirectly (e.g.um→&-um,'cause→(be)cause). The result is a single token whose surface form is CHAT-legal, there is no[: ...]wrapper. - Use replacements to carry CHAT-illegal text. Each replacement
word goes through the standard
Wordvalidator.[: C-3PO]fails E220 the same wayC-3POon the main tier does: each replacement word is validated in its own right. - Generate replacements during retokenization. When Stanza
re-tokenizes a word, the retokenize module rebuilds the AST in place
(
crates/batchalign-transform/src/retokenize/rebuild.rs). It preserves existingReplacedWordnodes during reconstruction but does not create new ones. - Generate replacements during ASR retrace detection. Detected
retraces produce
WordKind::Retraceplus structural retrace nodes (Retrace,<...> [/]), which are a different mechanism. Seecrates/batchalign-transform/src/build_chat/utterances.rs::build_word_utterancefor how retraces are emitted;WordKind::Replacementdoes not exist.
When to Reach for [%], [=], or [*] Instead
A common failure mode in this codebase has been reaching for [: ...]
when what’s actually wanted is a free-form annotation that does not
participate in word validation. The talkbank-model offers four such
forms via ContentAnnotation (in
talkbank-model/src/model/annotation/scoped/types.rs):
| Form | When to use |
|---|---|
[% text] | General comment about the word/utterance. Carries SmolStr (no word grammar applied). Right home for “ASR-original was X”. |
[= text] | Explanation of unclear speech. Idiomatic alongside xxx/yyy placeholders. |
[+ text] | Researcher note / context addition. |
[* code] | Error coding (with optional code). |
These all attach as scoped_annotations and do not require their
contents to satisfy CHAT word grammar. For ASR-introduced preservation
use cases, [%] is the working candidate, not [:].
Source Citations
| Concern | File:line |
|---|---|
| Replacement extraction (per-domain branch) | ../chatter/crates/talkbank-transform/src/extract.rs:129 (collect_replaced_word) |
| FA extraction (uses original) | crates/batchalign/src/chat_ops/fa/extraction.rs |
| FA count | crates/batchalign/src/chat_ops/fa/mod.rs |
| FA injection | crates/batchalign/src/chat_ops/fa/injection.rs |
| FA preservation | crates/batchalign/src/chat_ops/fa/mod.rs |
%mor injection (count check) | crates/batchalign-transform/src/morphosyntax/injection.rs::inject_results |
Retokenize preserves ReplacedWord | crates/batchalign-transform/src/retokenize/rebuild.rs |
| Read-only consumption test | crates/batchalign/src/chat_ops/fa/tests/grouping_and_wor.rs::test_wor_policy_replacements_use_original_surface |
| CHAT-format canonical reference | the CHAT-format replacements reference in the chatter project |
See Also
- CHAT Data Model (in the
chatterproject): howUtteranceContentvariants (includingReplacedWord) flow through the pipeline;walk_words,WordItem, and per-domain extraction primitives. - ASR Token Pipeline, the disfluency /
normalization rules that mutate
AsrWord.text(a different mechanism from replacements). - The %mor Tier (in the
chatterproject): for what%morrepresents and how it aligns.
This page last changed: 2026-07-30 (commit 1b974ba0). The whole book last changed: 2026-09-16 (commit 34d249d8).
Preprocessing and Postprocessing for Model Inference
Status: Current Last updated: 2026-05-19 20:22 EDT
All domain logic, text normalization, alignment, result injection, and error recovery, lives in Rust. Python workers are stateless ML inference endpoints. This chapter documents the preprocessing that prepares data for inference and the postprocessing that incorporates results back into the CHAT AST.
The Boundary Principle
Python receives structured payloads (lists of words, audio paths, language codes) and returns structured results (POS tags, timestamps, parse trees). It never sees CHAT text, never parses tiers, and never makes alignment decisions.
Rust: CHAT AST → extract words → clean text → build payload
│
Python: load model → run inference → return structured output
│
Rust: validate response → align with AST → inject results → serialize CHAT
Preprocessing by Task
Morphosyntax
Extract (talkbank-transform/morphosyntax/payload.rs::collect_payloads):
- Walk content with
walk_words(domain=Mor) - Collect
cleaned_text()for each alignable word - Replace special forms (
@c→"xbxxx",@s→ language marker), Stanza can’t handle CHAT-specific markers - Build payload:
Vec<String>of words per utterance
Payload → Python:
{"words": ["I", "want", "cookie"], "lang": "eng"}
Python returns: Raw Stanza to_dict() output, POS tags, lemmas, dependency parse, features.
Postprocess (talkbank-transform/morphosyntax/injection.rs,
talkbank-transform/retokenize/, talkbank-transform/morphosyntax/sentence_mapping.rs):
Two injection paths diverge based on TokenizationMode:
- Preserve (default):
map_ud_sentence()merges MWT Range tokens into clitic MOR items (1 MOR per CHAT word).inject_morphosyntax()adds %mor/%gra tiers without modifying the main tier. - StanzaRetokenize (
--retokenize):map_ud_sentence_expanded()produces per-component MOR items. Range parent tokens are filtered from the token vector.retokenize_utterance()rewrites the main tier with Stanza’s expanded tokens and injects per-component %mor/%gra.
Both paths share GRA generation via build_gra_and_validate().
Steps:
- Range token filtering (Retokenize only): exclude
UdId::Rangeparent entries from the token vector, only component words appear. - Grammatical-invariant rewrites (
apply_grammatical_invariantsattalkbank-transform/morphosyntax/invariants.rs:14,talkbank-transform/morphosyntax/invariants/for the per-rule modules): operate on the typedUdSentenceBEFOREmap_ud_sentenceruns. English primary only today, dispatched vialang2(&ctx.lang)intalkbank-transform/morphosyntax/sentence_mapping.rs. The only rule shipped so far isfinite_verb_main_clause::rescue_english_copula_progressive(atinvariants/finite_verb_main_clause.rs:9), detects<noun>'s <-ing>patterns that Stanza mis-parses as possessive-gerund and rewrites them into a coherent copula-progressive tree (PART → AUXbe, root NOUN → VERB VerbForm=Part, governor deprel → nsubj). See Stanza Limitations, Defect 1 for the defect description and re-evaluation procedure. - UD → CHAT mapping: Convert Universal Dependencies POS/features to TalkBank %mor format (category mappings, stem extraction, feature translation).
- MWT handling: In Preserve mode, multi-word tokens produce one clitic MOR (
pron|it~aux|be). In Retokenize mode, each component gets its own MOR. - %gra construction: Build dependency graph with chunk-based indexing (GRA indices are %mor chunk positions, not surface word positions).
- L2 splice (default; opt out with
--no-l2-morphotag): after primary injection, @s words withL2|xxxare routed to secondary Stanza models and spliced back with real morphology. L2 extracts itsl2_deferredpositions from the ORIGINALud_responsescaptured beforeapply_grammatical_invariantsran (crates/batchalign/src/pipeline/morphosyntax.rs:352-356, plus the L2 dispatch incrates/batchalign/src/morphosyntax/batch.rs), so the English rewrite cannot corrupt L2 position mapping. - Validation: Check word count alignment, GRA cycle detection, chunk count consistency.
- Injection: Replace or add %mor and %gra dependent tiers on the utterance.
Forced Alignment
FA preprocessing has two stages, UTR (Utterance Timing Recovery) and FA proper. See Forced Alignment for the complete pipeline.
UTR: Injects utterance-level timing from ASR tokens before FA runs. Supports global single-pass and two-pass overlap-aware strategies. See Overlapping Speech for the two-pass algorithm and CA marker-aware windowing.
FA: Groups utterances into time-windowed clusters, sends each group’s words + audio window to Python for word-level timestamp inference, then injects timing back into the AST.
Python receives: Audio window (start_ms, end_ms) + word list.
Python returns: Per-word timestamps.
Rust postprocessing: Word end-time chaining, conditional word timing clamping
(only on re-alignment runs where %wor already exists, see
Word timing clamping policy),
monotonicity enforcement, pause assignment, %wor tier generation.
ASR (Automatic Speech Recognition)
ASR preprocessing is the most complex because raw ASR output needs extensive normalization before it becomes CHAT:
ASR postprocessing pipeline (crates/batchalign-transform/src/asr_postprocess/):
| Stage | Module | What it does |
|---|---|---|
| 1. Compound merging | compounds.rs | Join split compounds: ice + cream → ice+cream (3,584 pairs, O(1) HashSet) |
| 2. Timed word extraction | mod.rs | Convert seconds → milliseconds, extract ASR tokens, strip MOR_PUNCT, lowercase |
| 2d. Cantonese normalization | cantonese.rs | Simplified → traditional + domain replacements (31 entries, Aho-Corasick), applied once to the whole monologue through AlignedNormalization, which proves the character count did not change |
| 3. Multi-word splitting | mod.rs | Split space-separated tokens with timestamp interpolation |
| 4. Number expansion | num2text.rs + ordinal_year_eng.rs | Single Rust per-word pass: cardinals via per-language NUM2LANG (47 langs), CJK via num2chinese, currency via try_expand_currency, English ordinals/years/decades via ordinal_year_eng. No Python num2words IPC. See Number Expansion. |
| 5. Long turn splitting | mod.rs | Break turns > 300 words into separate utterances |
| 5b. Pause-based splitting | mod.rs | Long pauses in unpunctuated runs create utterance boundaries |
| 6. Retokenization | mod.rs | Split into utterances by punctuation boundaries |
All module filenames in the table above are under
crates/batchalign-transform/src/asr_postprocess/.
Retokenization (step 6) is particularly important: ASR produces one long stream of text, but CHAT needs it segmented into utterances. The retokenizer uses punctuation (., ?, !) as utterance boundaries and assigns timing from the ASR tokens.
English transcribe corrections
Three English orthographic corrections are woven into the pipeline at
specific points, gated on lang == "eng". Each hook sits exactly where
the surrounding stage either produces or preserves the surface the rule
must see. See
English Transcribe Corrections
for the full rule set and probe-verdict citations.
flowchart TD
Raw["AsrOutput\n(raw provider tokens)"]
TPS["strip_english_title_periods_on_elements\n(talkbank-transform/asr_postprocess/cleanup.rs)\n⚠ BEFORE stage 3 split"]
S3["stage 3: split_multiword_tokens\n(. treated as separator)"]
S4["stages 4-5b:\nnumber expansion,\nlong-turn/pause splits"]
ICap["apply_english_transcribe_rules_pre_retokenize\n(I-cap on words)"]
S6["stage 6: retokenize by punctuation\n→ Vec<Utterance>"]
UCap["apply_english_transcribe_rules_post_retokenize\n(utterance-initial cap, skips retrace/markers)"]
Out["Vec<Utterance>\nready for CHAT assembly"]
Raw --> TPS --> S3 --> S4 --> ICap --> S6 --> UCap --> Out
Why each hook lives where it does:
- Title-period strip runs on raw
AsrElements, before stage 3. Stage 3’snormalized_split_separatortreats.as a word separator, soDr.would fragment intoDr+.before the allowlist could match. Stripping on the raw element keepsDra single token through every subsequent stage. - I-cap runs on per-word chunks, before retokenize. At this point numbers are already expanded and compounds merged, but utterances have not yet been carved out of the stream, the rewrite is a local surface fix.
- Utterance-initial cap runs after retokenize and after retrace detection.
The “real” first word of an utterance is only knowable once
WordKindtags are assigned; the rule walks pastxxx/yyy/www,&-prefixed tokens, andWordKind::Retracecopies to find it.
Translation
Extract: Full utterance text (all words concatenated).
Python: Google Translate or SeamlessM4T → translated text.
Inject: Add %xtra dependent tier with the translated text.
Utterance Segmentation
Extract: Words per utterance (same as morphosyntax). Python: Stanza constituency parser → parse tree with boundary predictions. Postprocess: Assign boundary codes (utterance break, clause break, continuation) based on constituency structure. Apply boundaries to merge/split utterances.
Coreference
Extract: All sentences in the document (document-level, not per-utterance).
Python: Stanza coref → coreference chains.
Inject: Sparse %xcoref tiers on utterances that contain coreferent mentions.
Retokenization: The Character-Level Bridge
When Stanza tokenizes differently than CHAT, the retokenizer (retokenize/) bridges the gap:
CHAT words: ["don't", "wanna"]
Stanza tokens: ["do", "n't", "wan", "na"]
The retokenizer:
- Concatenates both word lists into character strings
- Runs character-level DP alignment
- Builds a deterministic mapping from Stanza token indices back to CHAT word indices
- Uses this mapping to assign Stanza annotations (POS, lemma, depparse) to the correct CHAT words
This handles splits (don't → do + n't), merges, and even reorderings across languages. The mapping uses a length-aware fallback for ambiguous cases.
Cache Keys
Each task computes a cache key from its input payload, so identical inputs skip inference:
| Task | Cache key formula |
|---|---|
| Morphosyntax | `BLAKE3(“{words} |
| Utseg | `BLAKE3(“{words} |
| Translation | `BLAKE3(“{text} |
| FA | `BLAKE3(“{audio_identity} |
| UTR ASR | `BLAKE3(“utr_asr |
| Coref | No caching (document-level context) |
Cache keys are 64-char hex BLAKE3 hashes via the shared
CacheKey::from_content newtype (crates/batchalign/src/chat_ops/cache_key.rs:23).
Cache is tiered: moka in-memory (hot) → SQLite on-disk (cold).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
ASR Token Pipeline
Status: Current Last updated: 2026-09-16 04:34 EDT
This page documents the complete lifecycle of text tokens as they flow from ASR providers through post-processing into the CHAT AST. Each stage has a dedicated newtype that encodes what transformations the text has undergone.
Note on “Stage 6: retokenization” naming. Stage 6 below is ASR-stream retokenization, splitting a raw provider token stream into utterances by punctuation. It is unrelated to the morphosyntax retokenization that runs at morphotag time to reshape CHAT words against Stanza’s tokenization. For the distinction, route map, and per-language gap analysis, see Retokenization, Overview.
Type Progression
flowchart TD
Provider["ASR Provider\n(Rev.AI, Whisper, Cantonese engines)"]
AE["AsrElement\n• value: AsrRawText\n• ts/end_ts: AsrTimestampSecs\n• kind: AsrElementKind"]
EnTitlePeriod["strip_english_title_periods_on_elements()\n⚠ English transcribe rule\n(must precede Stage 3 split)"]
Pre["prepare_words_pre_expansion()\nStages 1-3b, including\nCantonese normalization (2d)"]
AW_pre["Vec<AsrWord>\n(digits still raw,\n% tokens already split)"]
Expand["expand_number()\nper-word Rust pass\n(NUM2LANG / num2chinese /\ncurrency / ordinal_year_eng)"]
Split["split_words_with_whitespace()\npost-expansion re-split"]
AW["AsrWord\n• text: AsrNormalizedText\n• start_ms/end_ms: Option i64\n• kind: WordKind"]
Fin["finalize_words_to_chunks()\nStages 5-5b"]
EnICap["apply_english_transcribe_rules_pre_retokenize()\n(I-cap: i→I, i'll→I'll, …)"]
Retok["utterances_from_prepared_chunks()\nStage 6: ASR-stream retokenization\n(split by punctuation)"]
Final["finalize_utterances()\nStages 7-9:\ndisfluency + retrace +\nCHAT-illegal sanitization"]
EnUttCap["apply_english_transcribe_rules_post_retokenize()\n(utterance-initial cap)"]
TryFrom{"ChatWordText::try_from_lang()\nconstruction-time guard"}
Reject["Err(Vec<ParseError>)\nfail loud with structured\nprovenance"]
WD["WordDesc → Word (CHAT AST)"]
Provider -->|"convert_asr_response()\ncantonese_asr_bridge"| AE
AE --> EnTitlePeriod --> Pre --> AW_pre
AW_pre --> Expand
Expand --> Split --> AW --> Fin --> EnICap --> Retok --> Final --> EnUttCap --> TryFrom
TryFrom -->|"Ok"| WD
TryFrom -->|"Err"| Reject
The three yellow-tinged English transcribe-rule hooks
(strip_english_title_periods_on_elements,
apply_english_transcribe_rules_pre_retokenize,
apply_english_transcribe_rules_post_retokenize) each fire at a
specific stage because of pipeline-interaction concerns:
- Title-period strip BEFORE stage 3. Stage 3’s
normalized_split_separatortreats.as a word separator and would fragmentDr.intoDr+.before the allowlist could match. Stripping early keepsDras a single element. - I-cap in
finalize_words_to_chunks. Per-word rewrite; no stage-interaction concern. - Utterance-initial cap AFTER stage 7 retrace detection. The rule needs to skip retrace-marked copies to land on the “real” utterance-initial word at the end of a retrace chain.
All three are English-gated. See English Transcribe Corrections for the rule contracts and probe-verdict citations.
The TryFrom gate at the end is the boundary where the pipeline’s
AsrNormalizedText becomes the CHAT domain’s ChatWordText.
Construction runs the word-fragment parser (plus the language-aware
Word::validate) and returns structured ParseErrors on failure, the
pipeline surfaces them verbatim to the user instead of producing an
invalid CHAT file. See Construction-Time Validation
below.
Provider Adapters: FunASR Unit Admission
Worker-hosted engines hand the server tokens that a provider adapter has
already shaped. For FunASR (funaudio, paraformer) that adapter is
crates/batchalign-pyo3/src/cantonese_asr_bridge/funasr_projection.rs, and its one job is to pair
each recognized unit with the timestamp FunASR reported for THAT unit.
FunASR returns a flat timestamp array with one [start_ms, end_ms] entry
per unit, but the units live in a checkpoint-specific list:
| Checkpoint | Unit list parallel to timestamp | Why text cannot be used |
|---|---|---|
Paraformer (paraformer-zh with the ct-punc-c punctuation model) | raw_text, requested with return_raw_text=True: one whitespace token per timestamp (a Han character or a Latin word) | text is punctuated and contains no spaces at all |
| SenseVoice | words, built by FunASR in lockstep with timestamp, punctuation units included | text carries <|...|> markup and ITN punctuation |
flowchart TD
Wire["FunasrSegmentWire\ntext, timestamp, words?, raw_text?"]
Source{"UnitSource::select\nwords, raw_text or display text"}
Admit{"AdmittedSegment::admit"}
Refuse["FunasrAdmissionError\nAmbiguousUnits, MissingUnits,\nCountMismatch, MalformedTimestamp"]
Timed["Timed(Units of UnitSpan)\none span per unit"]
Untimed["Untimed(Units of NoTiming)\nno timestamps at all"]
Lexical["LexicalUnits\npunctuation and markup units\nremoved WHOLE"]
Out["monologue elements\nand timed words\n(provider surfaces, unchanged)"]
Wire --> Source
Source -->|"both lists"| Refuse
Source --> Admit
Admit -->|"timing but only display text,\ncounts disagree, bad pair"| Refuse
Admit -->|"timestamps present"| Timed
Admit -->|"no timestamps"| Untimed
Timed -->|"into_lexical"| Lexical
Untimed -->|"into_lexical"| Lexical
Lexical --> Out
Three properties are worth knowing before touching this code:
- Display text is only ever an untimed source. When FunASR sent no unit
list, the display text is split the way FunASR itself tokenizes (each Han
character a unit, other runs whole, punctuation and
<|...|>markup as separators), and admission refuses it if timing arrived as well. Timed and untimed segments then travel the same lexical and normalization stages. - Positional pairing over re-tokenized text is not representable.
Unitshas a private field and one constructor,admit, which checks that the unit and timestamp counts agree before zipping them. Until 2026-09-15 the adapter splittextitself and paired the i-th token with the i-th timestamp. For Paraformer that paired punctuation-delimited clauses with per-character timestamps (a 437 s recording came out with every bullet inside its first 50 s); for SenseVoice, dropping punctuation from the text but not from the timestamps shifted every later character by one entry per punctuation mark. - Surfaces leave this adapter exactly as FunASR wrote them. Cantonese
normalization used to run here too, over the joined segment. That made a
transcript’s text depend on which engine produced it, because Qwen and Whisper
never pass through this module, and it put a second owner on a transformation
that is not idempotent. Normalization now happens once per monologue in the
server (stage 2d below), through
AlignedNormalization, which proves the character count did not change before handing each word back its own characters.
A refusal fails the file rather than emitting partially mistimed CHAT.
Provider Adapters: absent fields, and the one interval owner
FunASR’s admission above answers “which unit does this timestamp belong to”. The cloud providers raise a different question: what it means when a field is not there at all.
Tencent documents every property of SentenceDetail and SentenceWords as
nullable, and its Python SDK initializes each attribute to None before
deserializing a response, so an absent field is the SDK’s resting state rather
than an anomaly. Aliyun’s per-word entries are the same. Until 2026-09-16 the
bridge read them with .ok().and_then(...).unwrap_or(0), so three different
facts, “the provider did not send this”, “the provider sent the wrong type”,
and “the provider sent zero”, arrived as one number. Zero is a legal time and a
legal speaker, so the fabrication was invisible downstream.
| Provider field | Absent used to mean | Absent now means |
|---|---|---|
Tencent StartMs | the segment starts at the beginning of the recording | every word in the segment is untimed (SegmentStartAbsent) |
Tencent OffsetStartMs / OffsetEndMs | the word starts at its segment’s start; a missing end copied the start | the word is untimed, with the missing bound named |
Tencent Word | the empty string, which the blank filter then dropped | a refusal naming the segment and word |
Tencent SpeakerId | speaker 0 | undiarized when separation was not requested; a REFUSAL when it was (see below) |
Aliyun startTime / endTime | a span beginning at 0 ms | the word is untimed, with the missing bound named |
Every field is read through FieldRead<T> (Absent, Present, WrongType),
which has no default and no unwrap_or, so each caller must say what an absence
means where it knows. An absent time becomes an untimed word; a value of the
wrong type refuses the file with a message naming the provider, the position
(segment 3 word 7) and the fault.
One owner for rounding and range admission.
AdmittedInterval (crates/batchalign-transform/src/asr_postprocess/timing.rs)
is the only way to turn provider numbers into milliseconds. Its fields are
private and every constructor is fallible, so holding one is a proof that the
value was rounded once, by it, and admitted:
- non-finite bounds are refused rather than saturated by an
as i64cast; - negative bounds are refused;
- inverted intervals are refused rather than silently reordered;
- bounds beyond
MAX_MS(10^12 ms, about 31.7 years) are refused, which is what catches a provider sending an absolute epoch timestamp where a media offset belongs, while admitting any real recording; - a segment start plus a word offset is added with CHECKED arithmetic, because a wrapped sum is a wrong time that looks exactly like a right one.
Rounding is half away from zero, the same rule the scattered
(seconds * 1000.0).round() expressions used, so every input those handled
correctly produces identical numbers.
WordTiming is the other half: a word is Timed(AdmittedInterval) or
Untimed(cause), where the cause is a closed set (ProviderReportedNoTiming,
ProviderReportedNoStart, ProviderReportedNoEnd, SegmentStartAbsent,
ZeroLengthSpan, RefusedByAdmission). A zero-width span is admissible as an
interval but is never a timed word, because it locates nothing. Deep inside the
post-processing pipeline, which is a total transform with no error channel, an
inadmissible pair becomes RefusedByAdmission rather than a fabricated number.
Speaker attribution is a tagged value, end to end. AsrMonologueV2.speaker
is SpeakerAttributionV2: either {"kind": "attributed", "label": "..."}
carrying the provider’s OWN label, or {"kind": "undiarized"}. It was a bare
string, so an engine that separates nobody had to write something, and what it
wrote was "0", which no reader could tell from a provider’s real first
speaker.
Aliyun performs no speaker separation, and FunASR’s recognizer passes
spk_model="cam++" to generate while funasr builds its speaker model in
AutoModel.__init__ and only consults self.spk_model, so that argument is
inert and no sentence_info speaker labels are produced (verified against the
installed funasr 1.4.12). Those monologues report undiarized, which is a
claim about the ENGINE rather than a guess about the recording.
The request says whether separation was asked for. ProviderMediaInputV2
carries ProviderDiarizationV2, not_requested or integrated with a count,
derived once from the job’s speaker count by
ProviderDiarizationV2::for_expected_speakers, which is the only route from a
raw count into the type (one speaker is not a separation request; two or more is
a request for exactly that many, and the count is a SeparatedSpeakersV2, which
cannot hold less than two). The same value reaches the Python provider adapters
as AsrBatchItem.diarization. That is what lets the bridge tell the two
absences apart:
| Provider said | Request asked | Result |
|---|---|---|
| a label | either | Attributed, with the label admitted non-empty |
| no speaker | not_requested | Undiarized |
| no speaker | integrated | refused: the provider was asked to separate and named nobody |
Downstream, worker::asr_result_v2 maps an attributed label to its speaker
number and Undiarized to the single track of an unseparated recording. That
is the ONE place track zero is chosen, and it is chosen because the engine said
it separates nobody, never because a label was missing. The legacy admission in
transcribe::asr_output (Rev’s own projection and replayed
_asr_response.json evidence, both numeric) accepts a speaker number including
zero and no longer reads a suffix out of labels with rsplit('_'), which used
to merge distinct labels such as A_0 and B_0 onto one track.
What crosses the boundary at all. py_to_json_value
(crates/batchalign-pyo3/src/py_json_bridge.rs) dispatches on EXACT Python
type, in this order: None, bool (before int, since Python’s bool is an
int subclass), str, anything with model_dump, dict, list/tuple,
exact int (refused outside the 64-bit range rather than wrapped), exact
float (a non-finite value is refused NAMING ITS PATH, for example
$.monologues[0].elements[1].start_s). Anything else is refused by type name.
It previously tried numeric extraction first, and PyO3’s numeric extraction
honours __int__/__float__/__index__, so any number-like object became a
JSON number: the conversion decided what a value meant rather than reading what
it was.
Pipeline Stages
The transcribe pipeline runs entirely in Rust. Stages 1-3 produce raw
AsrWords, the per-word expansion pass (expand_number) handles
every numeric token via the per-language registry, English ordinal/
decade composer, CJK converter, currency table, and dash splitter.
Stages 5-8 run after expansion. The Python num2words IPC was
removed (see Number Expansion).
The functions involved are:
prepare_words_pre_expansion() (stages 1-3, Cantonese normalization included),
expand_number() plus split_words_with_whitespace() (stage 4 + 4.5), and
finalize_words_to_chunks() (stages 5-5b). The monolithic process_raw_asr()
is a sync fallback that follows the same shape. Both return a Result: their
one failure is a Cantonese normalization that changed a monologue’s character
count.
| # | Stage | Function | What changes |
|---|---|---|---|
| 1 | Compound merging | prepare_words_pre_expansion() | Adjacent compound pairs joined (“air”+“plane” → “airplane”) |
| 2 | Timed word extraction + separator strip | prepare_words_pre_expansion() | Seconds → ms, pause markers filtered, MOR_PUNCT (, „ ‡) and RTL separators trimmed from word boundaries. Case is preserved: see “Casing” below. |
| 2d | Cantonese normalization (lang=yue only) | prepare_words_pre_expansion() | The monologue’s words are normalized as ONE run through AlignedNormalization (simplified → traditional plus the 31-entry domain table), and each word gets back exactly its own characters. It runs before stage 3 because that stage interpolates timestamps across a token’s characters and must see final text, and because normalizing afterwards would normalize one character at a time and lose every multi-character replacement. A conversion that changed the character count refuses the file (NormalizationChangedLength, carrying both counts) rather than re-cutting words away from their timings. |
| 3 | Multi-word splitting | prepare_words_pre_expansion() | Space-containing tokens split, timestamps interpolated, hyphens joined |
| 3b | Percent-suffix split | split_percent_suffix_words() | "80%" → "80" + per-language percent word (“percent” for eng, 11 languages covered) with proportional timing. % is the CHAT dep-tier sigil and structurally illegal on the main tier in any language. Dormant for en/es (see below); fires for languages where Rev.AI applies ITN. |
| 4 | Number expansion | expand_number() per word | Single Rust pass: cardinals via per-language NUM2LANG (47 langs); CJK via num2chinese; English ordinals/decades via ordinal_year_eng; currency via try_expand_currency; percent via per-lang table; dash-ranges split and recurse; digit-leading hyphen compounds ("17-year-old" → "seventeen-year-old" in digit-rejecting languages) via try_expand_digit_leading_hyphen. |
| 4.5 | Post-expansion re-split | split_words_with_whitespace() | Expansion can produce multi-word text ("100" → "one hundred", "$80" → "eighty dollars"). A ChatWordText holds one main-tier token, so whitespace-bearing entries are split into separate AsrWords with proportionally distributed timing. |
Rev.AI and the stage-3b/4/4.5 defense-in-depth
For Rev.AI in English and Spanish, BA3 sends
skip_postprocessing=true (see
batchalign/src/revai/preflight.rs::skip_postprocessing_hint).
Per Rev.AI’s docs this tells the service to skip Inverse Text
Normalization (ITN): the response comes back in spoken form,
"eighty", "percent", "seventeen", "year", "old": rather
than written form with digits and %. Stage 3b and the digit-leading
hyphen branch of stage 4 therefore see no input to normalize on the
en/es production path; the raw tokens they were built to handle don’t
appear.
These stages remain in the pipeline for two reasons:
- Other languages. Per the Rev.AI API docs,
skip_postprocessingis available only for English and Spanish. For every other language Rev.AI applies ITN by default and the request body omits the flag (the helper returnsNone). The normalizer stages still fire for those responses. - Defense in depth. A future Rev.AI behavior change, a swap to an ASR provider that also applies ITN, or a regression in the flag-setting policy would reintroduce the input class these stages handle. They cost nothing to keep and prevent silent regressions.
Two layers protect the downstream CHAT build. Stage 9 (oracle-driven
sanitization) is the first line, it rebuilds a CHAT-legal prefix for
any token whose interior contains characters the grammar rejects
(Whisper’s bare : leaks, Tencent’s ~, exotic Unicode glued to ASCII
letters) and drops the token entirely only when no legal prefix
survives. The final enforcement is the ChatWordText::try_from_lang
gate at the end of the pipeline (see below), language-agnostic,
engine-agnostic, and failure-loud, which fires only for tokens that
sanitization cannot recover. Before stage 9 landed, a single
grammar-illegal char anywhere in the transcript would fail the entire
file at this gate; the v2 Cantonese ASR benchmark lost 6 of 18
fixtures this way.
| 5 | Long turn splitting | finalize_words_to_chunks() | Chunks > 300 words split |
| 5b | Pause-based splitting | finalize_words_to_chunks() | A gap of at least 800 ms creates a boundary only when the next word is an English sentence starter (LONG_PAUSE_SENTENCE_STARTERS); it never fires for non-English text |
| 6 | Retokenization | utterances_from_prepared_chunks() | Split into utterances by punctuation boundaries |
| 7 | Disfluency replacement | finalize_utterances() | Filled pauses marked (“um” → “&-um”), orthographic replacements |
| 8 | N-gram retrace detection | finalize_utterances() | Repeated n-grams marked with WordKind::Retrace. Fillers (&- prefix) participate in matching but are never marked Retrace: see Retrace Detection. |
| 9 | CHAT-illegal char sanitization | finalize_utterances() → sanitize_chat_illegal_chars_in_utterances() (asr_postprocess/cleanup.rs) | For each word whose interior fails ChatWordText::try_from, greedily rebuild a CHAT-legal prefix character by character (push, check via the oracle, pop on reject). Drop the word when the rebuilt string is empty. Engine-emitted noise (: from Whisper, ~ from Tencent, exotic glyphs) no longer destroys whole utterances. Runs after number expansion so monetary / numeric expansions are already in word form when the oracle sees them. |
Text Newtypes at Each Stage
| Type | On struct | Contains | Constructor |
|---|---|---|---|
AsrRawText | AsrElement.value | Raw provider output: digits, spaces, provider markers | AsrRawText::new(s): infallible |
AsrNormalizedText | AsrWord.text | Compound-merged, number-expanded, disfluency-marked text | AsrNormalizedText::new(s): infallible |
ChatWordText | WordDesc.text | A runtime-checked proof that s is either a closed-set CHAT terminator / MOR_PUNCT separator (., ?, !, +..., ,, ‡, „), or text the tree-sitter word-fragment parser accepts as a legal main-tier word, and (for the _lang variants) satisfies every word-level rule talkbank_model::Validate for Word applies under the declared language including E220 digit policy. | Fallible only: ChatWordText::try_from, try_from_with_parser, try_from_lang, try_from_lang_with_parser. No infallible new. |
The progression is asymmetric on purpose: AsrRawText and
AsrNormalizedText are pipeline-internal and carry whatever the
provider returned; ChatWordText is the handoff boundary into CHAT
assembly, and its constructor is the enforcement point for the
invariant that every word in a ChatFile is CHAT-legal. Attempting
to construct a ChatWordText from text the CHAT grammar rejects
fails loudly at the boundary with a typed error naming the offending
utterance, speaker, language, and token, rather than producing a
ChatFile that fails silently at a downstream parse gate.
All three types use #[serde(transparent)], as_str(), Display,
AsRef<str>. AsrNormalizedText additionally provides map() for
pipeline stage transformations and push_str() for hyphen-joining.
Construction-Time Validation
flowchart TD
Call["ChatWordText::try_from_lang(s, lang)"]
Term{"Terminator::is_chat_terminator(s)?"}
MorP{"MOR_PUNCT contains s?\n(',' '‡' '„')"}
Parse["TreeSitterParser::parse_word_fragment(s)"]
Parsed{"ParseOutcome::Parsed\n& no errors?"}
Validate["Word::validate(ctx, errors)\nctx = ValidationContext\n(default, declared, tier)\nunder `lang`"]
LangClean{"validate errors\nempty?"}
Ok["Ok(ChatWordText(s))"]
Err["Err(Vec<ParseError>)\ncode + span + excerpt"]
Call --> Term
Term -->|"yes"| Ok
Term -->|"no"| MorP
MorP -->|"yes"| Ok
MorP -->|"no"| Parse
Parse --> Parsed
Parsed -->|"no"| Err
Parsed -->|"yes"| Validate
Validate --> LangClean
LangClean -->|"yes"| Ok
LangClean -->|"no"| Err
The two short-circuits (terminator, MOR_PUNCT) exist because the ASR
pipeline emits each utterance’s terminator as a regular AsrWord entry,
and separator tokens (,, ‡, „) appear as standalone AsrWords
after stage 2b boundary stripping. These are main-tier-legal but not
words, parse_word_fragment correctly rejects them; the short-circuit
lets them through.
The try_from_with_parser variant skips the language-validation branch
and is for callers that don’t know the language. The _with_parser
variants take a caller-supplied TreeSitterParser handle; the bare
try_from and try_from_lang use a thread-local parser (the underlying
TreeSitterParser is !Send + !Sync).
Fallible construction in isolation isn’t enough, the pipeline’s
upstream normalizer stages must actually produce CHAT-legal text.
That responsibility is shared with stage 3b (percent split), stage 4
(number expansion + digit-hyphen rewrite), and stage 4.5
(post-expansion re-split). The policy is “fail loud on unknown
shapes”: each new class of ASR token that trips the TryFrom gate
gets a normalizer rule added upstream so legitimate inputs don’t
reach the gate only to fail.
Casing
The pipeline preserves the case that the ASR provider returned. The English
pronoun "I", its contractions ("I'm", "I'd", "I'll", "I've"), and
proper nouns ("Mike", "Cincinnati", "Sarah") all flow unchanged from
AsrRawText through every stage into the final ChatWordText on the main
tier. Stage 2 strips separator punctuation from word boundaries but does
not change letter case.
Two downstream stages need to compare words without regard to case. They lowercase only their comparison key; the stored word text is never rewritten to lowercase:
- Disfluency replacement (
apply_disfluency_replacements,asr_postprocess/cleanup.rs) uses a lowercased lookup key to find entries like"um"/"Um"/"UM"in the per-language filled-pause table. A hit replaces the text with the CHAT form (&-um); a miss leaves the original text alone. - Retrace detection (
apply_retrace_detection,asr_postprocess/cleanup.rs) builds a lowercasedcontent_keysvector and uses it for n-gram equality; the stored word text is left in its original case so CHAT output still shows"I [/] I"rather than"i [/] i".
flowchart LR
Raw["AsrRawText\n(provider casing:\n"I", "Sarah",\n"Cincinnati")"]
Stripped["AsrNormalizedText\n(case preserved,\nseparators trimmed)"]
DR["apply_disfluency_replacements()\ncleanup.rs"]
RD["apply_retrace_detection()\ncleanup.rs"]
Chat["ChatWordText\n(case preserved,\nexcept disfluency\nrewrites e.g. "&-um")"]
Raw -->|"strip_separator_words()\nasr_postprocess/mod.rs"| Stripped
Stripped -->|"lowercased\nlookup key"| DR
Stripped -->|"lowercased\ncontent_keys"| RD
DR -->|"text rewritten\non match only"| Chat
RD -->|"WordKind::Retrace;\ntext untouched"| Chat
Verified against source: strip_separator_words in
crates/batchalign-transform/src/asr_postprocess/mod.rs;
apply_disfluency_replacements and apply_retrace_detection in
crates/batchalign-transform/src/asr_postprocess/cleanup.rs.
Timing Flow
flowchart LR
Raw["AsrTimestampSecs\n(Observed(f64 seconds)\nor Absent)"]
Internal["Option i64\n(milliseconds)"]
Output["Option u64\n(milliseconds)"]
Bullet["Bullet\n(u64 ms)"]
Raw -->|"AdmittedInterval::admit_seconds()\nvia normalized_timing_range()"| Internal
Internal -->|"as u64 cast\nin transcript_from_asr_utterances()"| Output
Output -->|"build_word_utterance()"| Bullet
AsrTimestampSecs is the provider’s endpoint on AsrElement, and it has two
variants rather than a raw number: Observed(f64) for an endpoint the provider
reported, including a real zero, and Absent for one it never sent. It
serializes untagged, so an observed endpoint is a number and an absent one is
null, never a numeric sentinel, and an absent endpoint yields an untimed word
instead of a word at time zero. The internal AsrWord timing (Option i64) is
deliberately NOT wrapped, these are pipeline-internal values that never cross a
module boundary.
The seconds-to-milliseconds step is no longer done here. normalized_timing_range
delegates to AdmittedInterval, the one owner described above, and records an
inadmissible pair as an untimed word with a named cause instead of converting it.
Absent, zero-width and inverted spans behave exactly as they did (the word
carries no timing); what changed is that a negative bound with a later end used
to reach a word as a negative millisecond time, and a value beyond i64
saturated into a plausible one.
Speaker Flow
flowchart LR
Provider["Provider speaker attribution\n(typed SpeakerAttributionV2 on the\nworker path; a numbered label on\nthe Rev and replay paths)"]
SI["SpeakerIndex(usize)\non AsrMonologue, Utterance"]
Named["NamedAsrUtterances\n(each utterance bound to\nthe code it will carry)"]
Code["Participant code\n(PAR0, PAR1, PAR2, ...)"]
Provider -->|"worker::asr_result_v2, or\nadmit_token_speaker()\nfor a numbered label"| SI
SI -->|"NamedAsrUtterances::numbered\nor ::with_participant_ids"| Named
Named -->|"into_transcript()"| Code
SpeakerIndex is a zero-based index into the recording’s speaker list.
It lives on both AsrMonologue (raw) and Utterance (post-processed).
NamedAsrUtterances is the only route from those utterances to a transcript.
It binds each utterance to the speaker code that utterance will carry and keeps
the source list beside the naming, so a transcript is built from exactly the
utterances that were named. Both refusals are typed: explicit codes must cover
every observed speaker, and one they miss is
TranscriptBuildError::MissingParticipantCode carrying that speaker’s index
rather than a code invented for it, while a build with no primary language is
TranscriptBuildError::MissingPrimaryLanguage. transcript_from_asr_utterances
remains as the one-call spelling of “name these utterances with these codes,
then build”; the assembly itself lives in build_chat.rs.
WordKind Lifecycle
WordKind is set during stage 8 (retrace detection) and consumed during
CHAT assembly:
flowchart TD
S8["Stage 8: apply_retrace_detection()\nmatches repeated n-grams over content words"]
Gate{"matched word\nis filler ("&-" prefix)?"}
Keep["leave as WordKind::Regular\n(filler stays a filler,\nno [/] emitted)"]
SetR["set WordKind::Retrace"]
WD2["WordDesc carries kind through\ntranscript_from_asr_utterances()"]
BW["build_word_utterance() reads kind"]
AW2["WordKind::Regular → UtteranceContent::Word"]
AG["WordKind::Retrace → AnnotatedWord or AnnotatedGroup\nwrapped in scoped annotation PartialRetracing"]
S8 --> Gate
Gate -->|"yes"| Keep
Gate -->|"no"| SetR
Keep --> WD2
SetR --> WD2
WD2 --> BW
BW --> AW2
BW --> AG
The filler gate mirrors BA2’s if j.type != TokenType.FP check in
NgramRetraceEngine.process(). Fillers are included in the n-gram
match window (so &-um I &-um I went still detects the bigram repeat
and marks the first I as Retrace), but filler tokens themselves
never carry [/].
Both single-word (word [/]) and multi-word (<word word> [/]) retraces
produce UtteranceContent::Retrace. The Retrace type carries the retrace
kind (Partial, Full, Multiple, Reformulation, Uncertain) and a
flag for whether the original was a group.
AsrElementKind Enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AsrElementKind {
#[default]
Text,
Punctuation,
}
Replaces the former r#type: String field. Serializes as "text" / "punctuation"
for JSON compatibility. The field is currently not read by the pipeline
(extract_timed_words uses content heuristics), but it preserves provider metadata
for debugging and potential future use.
Reverse Direction: CHAT to NLP
The reverse flow (extracting words from CHAT for NLP processing) uses separate
provenance types defined in text_types.rs:
| Type | Source | Used for |
|---|---|---|
ChatRawText | Word::raw_text() | AST preservation, display |
ChatCleanedText | Word::cleaned_text() | NLP models, alignment, cache keys |
SpeakerCode | Utterance.speaker | Per-speaker analysis keying |
These types are documented in Type-Driven Design.
Code References
| Component | File |
|---|---|
| ASR types and newtypes | crates/batchalign-transform/src/asr_postprocess/asr_types.rs |
| Pipeline orchestrator | crates/batchalign-transform/src/asr_postprocess/mod.rs |
| Compound merging | crates/batchalign-transform/src/asr_postprocess/compounds.rs |
| Disfluency and retrace | crates/batchalign-transform/src/asr_postprocess/cleanup.rs |
| Number expansion | crates/batchalign-transform/src/asr_postprocess/num2text.rs |
| Cantonese normalization | crates/batchalign-transform/src/asr_postprocess/cantonese.rs |
| FunASR unit admission | crates/batchalign-pyo3/src/cantonese_asr_bridge/funasr_projection.rs |
| CHAT assembly | crates/batchalign-transform/src/build_chat/ (directory) |
| CHAT-direction newtypes | ../chatter/crates/talkbank-model/src/text_types.rs |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Number Expansion in ASR Post-Processing
Status: Current Last updated: 2026-09-15 09:35 EDT
This page is the single source of truth for how batchalign3 turns
ASR-emitted number tokens ("3", "$5", "1950s", "3rd",
"80%", "3-star") into CHAT-legal main-tier text. Both the current
implementation and the planned architectural rework live here. When
the implementation changes, this page must be updated in the same
patch, there is no other authoritative description.
Why number expansion exists
CHAT was designed for human transcribers. Most languages forbid bare
Arabic digits in main-tier word position
(talkbank-tools/../chatter/crates/talkbank-model/src/validation/word/language/digits.rs
permits digits only for zho, cym, vie, tha, nan, yue,
min, hak); the validator emits E220 when it sees them
elsewhere. Human transcribers know to write "three" instead of
"3".
ASR engines did not get the memo. They emit number-bearing tokens in several shapes:
| Shape | Example | Why ASR returns it |
|---|---|---|
| Bare digits | "3", "100" | Whisper, Whisper-Hub fine-tunes (esp. Indic/Malayalam), Rev.AI for large numbers |
| Spelled words | "three" | Most ASR for small English numbers |
| Decade | "1950s" | Year-context heuristic |
| Ordinal | "3rd", "21st" | English-specific suffixes |
| Indicator ordinal | "54ª", "1.º", "54.ºs" | Printed Portuguese ordinal abbreviation |
| Currency | "$5", "€3" | Symbol-prefixed, locale-driven |
| Percent | "80%" | Symbol-suffixed |
| Digit-leading hyphen | "3-star", "17-year-old" | Compound modifiers |
| CJK numerals | "三", "百" | Tencent / FunASR / CJK-tuned Whisper |
| Dash range | "5-7", "5-6" | Reading numeric ranges |
Number expansion bridges these to CHAT-legal forms per the target
language. It is deterministic text rewriting: no ML, no audio
context, no speaker inference. Pure function from (token, lang)
to String.
Current architecture
Stage placement
Number expansion lives in stage_asr_postprocess
(crates/batchalign/src/pipeline/transcribe.rs), which is
gated by always_enabled: it runs for every language on
every transcribe / transcribe_s job. It is not gated on Stanza
availability.
Dispatch
Round 2 of the rework landed: the Python num2words IPC is gone.
Number expansion is now a single per-word Rust pass with no boundary
crossings:
flowchart TD
Word(["AsrWord<br/>(text, start, end)"]) --> ExpandNum["expand_number(text, lang)<br/>(num2text.rs)"]
ExpandNum --> Por{"lang == por and whole token<br/>is an indicator ordinal?"}
Por -->|"rank 1..=1000"| PorRust["ordinal_por: gendered ordinal words"]
Por -->|"rank out of range"| PorPass["return unchanged<br/>(E220 catches at validate)"]
Por -->|"no"| Eng{"lang == eng?"}
Eng -->|"yes"| OrdEng{"ends in 'st'/'nd'/'rd'/'th'?"}
OrdEng -->|"yes"| OrdinalRust["ordinal_year_eng::expand_ordinal_eng"]
OrdEng -->|"no"| DecEng{"ends in 's' with digit stem?"}
DecEng -->|"yes"| DecadeRust["ordinal_year_eng::expand_decade_eng"]
DecEng -->|"no"| DigitLead
Eng -->|"no"| DigitLead{"digit-leading hyphen<br/>(e.g. 3-star)?"}
DigitLead -->|"yes"| HyphenRust["try_expand_digit_leading_hyphen"]
DigitLead -->|"no"| Currency{"currency prefix/suffix?"}
Currency -->|"yes"| CurrencyRust["try_expand_currency"]
Currency -->|"no"| Percent{"percent suffix?"}
Percent -->|"yes"| PercentRust["try_expand_percent"]
Percent -->|"no"| Dash{"contains dash/em-dash?"}
Dash -->|"yes"| DashRust["dash-split + recurse"]
Dash -->|"no"| CJK{"CJK lang?<br/>(zho/cmn/jpn/yue)"}
CJK -->|"yes"| Num2Chinese["num2chinese(script)"]
CJK -->|"no"| Table{"NUM2LANG[lang]<br/>has entry?"}
Table -->|"yes"| TableLookup["NUM2LANG decompose<br/>(43 codegenned langs<br/>+ 4 hand-curated)"]
Table -->|"no"| Passthrough["return unchanged<br/>(E220 catches at validate)"]
After per-word expansion, split_words_with_whitespace widens
multi-token expansions ("100" → "one hundred") into separate
AsrWords so each fits in a single ChatWordText.
For Malayalam (the motivating case for the rework), the path is
expand_number("3", "mal") → NUM2LANG["mal"]["3"] → "മൂന്ന്".
For English ordinals ("13th") and decades ("1950s"), the
ordinal_year_eng module produces "thirteenth" and "nineteen fifties" via deterministic composition rules cross-validated against
num2words at build time (fixture data/eng_ordinal_year_fixtures.json).
Portuguese indicator ordinals
Portuguese ASR output writes ordinals as printed abbreviations: digits,
an optional abbreviation period, the masculine º or feminine ª
indicator, and an optional plural s (54ª, 1.º, 54.ºs).
ordinal_por.rs owns the form end to end through a small graph of types:
PortugueseOrdinalSyntaxis the written shape (digit run, gender, number). One lexer builds it, and only when a token boundary follows (end of text, whitespace, or a character the tokenizer splits on), so54ªabc,54.ª-feiraand the degree-sign lookalike54°are not ordinals.PortugueseOrdinalis a syntax whose rank is in 1..=1000, built only byTryFrom<PortugueseOrdinalSyntax>.PortugueseOrdinal::to_wordscomposes hundreds, tens and units ordinal stems and inflects every component for gender and number:54ªbecomesquinquagésima quarta,54.ºsbecomesquinquagésimos quartos,1000ªbecomesmilésima.
It has two call sites:
- Tokenizer (
prepare.rs::split_chunk_word). At each token start,protected_ordinal_prefixruns before the generic separator split, so the abbreviation period in54.ªstays inside the token instead of becoming a sentence terminator. A period after the ordinal (54.ª. então) is outside the match and still ends the utterance. - Expansion (
expand_number, first check). A whole-token ordinal in range expands to words, and the token’s timing is then split across those words proportionally, like any multi-word expansion.
Recognition and range are separate on purpose. An out-of-range ordinal
(0ª, 1001.º) is still protected as one token (its period is not a
sentence end) and is returned unchanged, the policy for every expansion
this module cannot perform, so E220 reports the digits instead of a
guessed word.
Other ordinal and decade conventions pass through unchanged: suffix
ordinals outside English (13th in spa), indicator ordinals outside
Portuguese (3º in spa), and non-English decades. Add a per-language
expander if a real corpus surfaces the need.
Routing lives in expand_number
There is no separate per-language routing table. expand_number is the
router: the checks in the diagram above run in order and the first that
applies decides the output. (A NumberExpander registry module was once
added as a proposed single source of routing truth, but nothing ever
consulted it, so it was deleted as dead code.) What each language actually
does today is pinned by the frozen baseline, not by a
declaration.
Languages whose CHAT validator permits digits are not special-cased:
cym, vie and tha have NUM2LANG tables and are expanded like any
table language, while nan, min and hak have no table and keep their
digits, which their validator accepts.
Codegen
The pre-Layer-1 data/num2lang.json was hand-curated and contained
several typos ("ninety-sx" for English 96, "diecineuve" for
Spanish 19, "จ็ด" for Thai 7, etc.). The current
crates/batchalign-transform/data/num2lang.json was generated by
invoking Python num2words for every ISO 639-3 → num2words mapping
and writing the result to that path. 43 languages × 0-99 + decades +
100/1000 anchors = ~4,700 entries.
Four languages stay hand-curated (the regeneration workflow must not overwrite them):
mal(Malayalam), num2words has nomlbackendell(Greek), num2words has noelbackend; hand-curated entries preserved verbatim from pre-codegen file (known typos flagged for follow-up native-speaker review)eus(Basque), num2words has noeubackendhrv(Croatian), num2words falls back to Serbian, lower quality than the existing hand-curated table
The codegen tool itself is not currently committed to the repo. When
the table needs regeneration, the workflow is: map each ISO 639-3
code to its num2words language code, drive num2words externally,
and write the result back to
crates/batchalign-transform/data/num2lang.json, taking care to
preserve the four hand-curated entries.
Frozen baseline
crates/batchalign-transform/data/number_expansion_baseline.json
records 50 languages (every NUM2LANG table language, the CJK numeral
languages and one unknown code) crossed with 63 representative tokens
(cardinals, large and overflowing numbers, English ordinals and decades,
currency, percent, dash ranges, digit-leading hyphen compounds,
Portuguese indicator ordinals, and lookalikes). Each row holds both the
expand_number output and the word texts prepare_asr_chunks produces
for the token as one timed element, so tokenizer changes are pinned too.
num2text_baseline.rs::number_expansion_matches_frozen_baseline fails
if any row changes, or if a table language is added without rows. The
fixture owns its languages and inputs lists. To accept a deliberate
change, edit those lists if needed, run
BATCHALIGN_REGENERATE_NUMBER_BASELINE=1 cargo test -p batchalign-transform --lib regenerate_number_expansion_baseline -- --ignored
then review the one-row-per-line diff and name every changed row, with its reason, in the commit message.
Module map
| File | Purpose |
|---|---|
crates/batchalign/src/pipeline/transcribe.rs | stage_asr_postprocess, prepare_asr_chunks (per-word Rust expansion + whitespace split + finalize) |
crates/batchalign-transform/src/asr_postprocess/num2text.rs | expand_number(word, lang): top-level Rust entry; detect_expansion; currency/percent/dash helpers; NUM2LANG static map |
crates/batchalign-transform/src/asr_postprocess/ordinal_year_eng.rs | expand_ordinal_eng, expand_year_eng, expand_decade_eng (English-only deterministic composition; cross-validated against num2words via data/eng_ordinal_year_fixtures.json) |
crates/batchalign-transform/src/asr_postprocess/num2chinese.rs | num2chinese(n, script) for CJK |
crates/batchalign-transform/src/asr_postprocess/ordinal_por.rs | Portuguese indicator ordinals: typed recognition (PortugueseOrdinalSyntax, PortugueseOrdinal), gendered rendering, tokenizer protection |
crates/batchalign-transform/src/asr_postprocess/prepare.rs | Tokenizer (split_chunk_word); protects indicator ordinals before separator splitting |
crates/batchalign-transform/src/asr_postprocess/num2text_baseline.rs | Frozen-baseline check and its opt-in regeneration test |
crates/batchalign-transform/data/num2lang.json | Per-language Rust tables (43 codegenned + 4 hand-curated) |
crates/batchalign-transform/data/number_expansion_baseline.json | Frozen (language, token) outputs of expand_number and the prepare pipeline |
crates/batchalign-transform/data/eng_ordinal_year_fixtures.json | Cross-validation fixtures for ordinal_year_eng |
Per-language coverage matrix
This matrix is load-bearing: it determines which expander any given token routes to. Update in lock-step with code changes.
| Lang | Wire token | Expander | Source |
|---|---|---|---|
eng | "3" | Rust NUM2LANG | data/num2lang.json:eng |
eng | "3rd" | Rust expand_ordinal_eng | ordinal_year_eng.rs |
eng | "1950s" | Rust expand_decade_eng | ordinal_year_eng.rs |
eng | "1950" (year context) | Rust NUM2LANG cardinal, year-form expansion only fires for the decade-suffixed shape; bare 4-digit numbers route as cardinals | num2text.rs |
por | "54ª", "1.º", "54.ºs" (rank 1..=1000) | Rust ordinal_por (gendered ordinal words) | ordinal_por.rs |
por | "0ª", "1001.º" (out of range) | Passthrough as one token, E220 fires | ordinal_por.rs |
| any | "$5" | Rust try_expand_currency | num2text.rs |
| any | "80%" | Rust currency-style + PERCENT_WORD_BY_LANG | num2text.rs |
| 43 codegenned langs (eng/fra/deu/spa/por/ita/nld/…) | "3" | Rust NUM2LANG | data/num2lang.json |
mal, ell, eus, hrv | "3" | Rust NUM2LANG (hand-curated overlay) | scripts/codegen_num2lang.py::HAND_CURATED |
zho / cmn | "3" | Rust num2chinese(simplified) | num2text.rs |
yue / jpn | "3" | Rust num2chinese(traditional) | num2text.rs |
Digit-permitting languages without a table (nan, min, hak) | "3" | Passthrough (the validator accepts digits) | num2text.rs |
Non-eng "3rd" / "1950s", indicator ordinals outside por | passthrough | None, accepted limitation, no observed production traffic | (gap) |
hin, tam, mar, guj, pan, ori, most African langs | "3" | Nothing: digit reaches CHAT, E220 fires | (gap) |
To add a num2words-supported language: add the ISO 639-3 → 2-char
mapping to ISO3_TO_NUM2WORDS in scripts/codegen_num2lang.py and
re-run the script. To add a hand-curated language: add to
HAND_CURATED in the same script (the codegen never overwrites those).
Detection algorithm (detect_expansion)
detect_expansion classifies a token’s expansion mode (Cardinal /
Ordinal / Decade / Year) for callers that need the decision
without doing the expansion. After Round 2 the dispatcher does not
use it directly, expand_number runs the full per-word pipeline
unconditionally, but it is kept as a public helper for testing and
future per-mode dispatch.
flowchart TD
Start(["word, lang"]) --> Empty{"word.is_empty()?"}
Empty -->|"yes"| ReturnNone1["return None"]
Empty -->|"no"| CJKCheck{"lang in<br/>{zho, cmn, jpn, yue}?"}
CJKCheck -->|"yes"| ReturnNone2["return None<br/>(handled by num2chinese in Rust)"]
CJKCheck -->|"no"| CurrencyCheck{"starts with currency<br/>prefix or ends with<br/>currency suffix?"}
CurrencyCheck -->|"yes"| ReturnNone3["return None<br/>(handled by try_expand_currency in Rust)"]
CurrencyCheck -->|"no"| AllDigits{"word.chars().all(is_ascii_digit)?"}
AllDigits -->|"yes"| ReturnCardinal["return Some(n, Cardinal)"]
AllDigits -->|"no"| DecadeCheck{"word ends 's' AND<br/>stem all digits?"}
DecadeCheck -->|"yes"| ReturnDecade["return Some(n, Decade)"]
DecadeCheck -->|"no"| OrdinalCheck{"word ends 'st'/'nd'/'rd'/'th'<br/>AND stem all digits?"}
OrdinalCheck -->|"yes"| ReturnOrdinal["return Some(n, Ordinal)"]
OrdinalCheck -->|"no"| DashCheck{"word contains<br/>dash or em-dash?"}
DashCheck -->|"yes"| ReturnNone4["return None<br/>(handled in expand_number<br/>via dash-split path)"]
DashCheck -->|"no"| ReturnNone5["return None"]
A Some result means the token will be sent to Python. None means
either the Rust safety pass handles it (CJK, currency, dash) or it
isn’t a number at all (passes through unchanged).
Decompose strategy for higher numbers
decompose_with_table at num2text.rs:274 greedily subtracts the
largest table entry that fits. So "234" for German becomes
"zweihundert" + "vierunddreißig" if the table has 200 and 34,
or "zweihundert" + "dreißig" + "vier" if it has 200, 30, 4.
Recurses for hundreds-and-up multipliers (1234 → decompose(1) +
"thousand" + decompose(234)).
If the table can’t fully decompose (e.g., a 5-digit number when the
table only goes to 1000), expand_number returns the original
digit string unchanged. This is a silent fallthrough: the
validator E220 will catch it later, but at the call site there’s no
typed signal that expansion failed.
Currency and percent
CURRENCY_PREFIXESandCURRENCY_SUFFIXES(num2text.rs:53,63) recognize$ € £ ¥ ₹ ₩ ₽and append the English word for the currency regardless of target language. Rationale (per inline comment): morphosyntax can re-tag in-language later; CHAT just needs a non-digit word here.PERCENT_WORD_BY_LANG(num2text.rs:77) lists per-language percent words for the languages we actively transcribe. Anything not listed falls back to"percent".
These two tables are independent of the main NUM2LANG table, they
exist because currency/percent symbols are language-orthogonal but
the words attached to them are language-specific.
Known limitations (post-Round-2)
Round 1 collapsed the dual-pass dispatch into a single Rust pass
with codegenned cardinal tables. Round 2
landed deterministic Rust ordinal/year/decade expansion for English
and removed the Python num2words IPC entirely. The CLAUDE.md
“Python is a pure ML model server” rule no longer has an exception
for number expansion. Remaining issues:
- Most non-English ordinals/decades pass through. English
suffix ordinals and decades (
ordinal_year_eng) and Portuguese indicator ordinals (ordinal_por) are covered. Spanish"3º", German"3.", French"1950s"(rare) leave the digit in place; add a per-language ordinal/decade module if a real corpus surfaces the need. - Scattered token detection. Currency, percent, ordinals,
decades, dash-ranges, digit-leading hyphens are each detected by
their own ad-hoc function. No unified parse phase. Adding a new
token shape (e.g., phone numbers
555-1234, time3:30) means touching every dispatch site. Layer 2 of the rework addresses this. - Indic + African coverage gaps. Hindi, Tamil, Marathi,
Gujarati, Punjabi, Oriya, and most African languages have no
expander, so the digit reaches CHAT,
triggering E220. Add to
HAND_CURATEDinscripts/codegen_num2lang.pyas the languages come online. - Hand-curated quality not native-reviewed. Greek (
ell), Basque (eus), and Croatian (hrv) tables were preserved verbatim from the pre-codegen file and contain known typos (e.g., Greek"96": "ενενήντα-sx"with English-suffix bleed). Native-speaker review needed; flagged in the script’s HAND_CURATED block. - No fail-loud signal at submission. A language with no expander only surfaces at validation time as E220. A submission-time check could reject the request with a clearer error (“no number expansion configured for language X, see book/src/batchalign/architecture/number-expansion.md”).
Future architecture
Round 2 (English ordinal/decade in Rust + Python IPC removal) and the cardinal codegen landed earlier: the relevant content moved into “Current architecture” above. The proposed Layer 1 typed registry was built but never wired into dispatch, and was deleted as dead code; a routing table only earns its place once dispatch consumes it, which is what Layer 2’s typed parser would provide. Layers 2 and 3 are still proposed.
Layer 2: Typed NumberToken parser
Replace the scattered detect/try/try cascade with a single parse function returning a typed enum:
pub enum NumberToken<'a> {
BareDigits(i64),
Decade(i64), // "1950s"
Ordinal(i64, OrdinalStyle), // "3rd"
DigitLeadingHyphen(i64, &'a str), // "3-star", "17-year-old"
Currency(CurrencySymbol, i64), // "$5", "€3"
Percent(i64), // "80%"
DashRange(i64, i64, DashKind), // "5-7", "5-6"
PassThrough(&'a str), // not a number
}
pub fn parse_number_token(s: &str) -> NumberToken<'_>;
Each NumberExpander then exposes a method per token variant:
trait Expand {
fn cardinal(&self, n: i64) -> Cow<'_, str>;
fn decade(&self, n: i64) -> Cow<'_, str>;
fn ordinal(&self, n: i64, style: OrdinalStyle) -> Cow<'_, str>;
fn currency(&self, sym: CurrencySymbol, n: i64) -> Cow<'_, str>;
fn percent(&self, n: i64) -> Cow<'_, str>;
fn dash_range(&self, lo: i64, hi: i64) -> Cow<'_, str>;
}
Default trait methods can fall back to cardinal + appended word
for currency/percent/etc., so simple languages only need to
implement cardinal. Languages with richer conventions (Indian
English number grouping, Japanese kanji counters, German
year-as-compound) override.
This collapses six ad-hoc detection functions into one parse and makes the input space testable as a closed enum. Adding a new token shape (phone numbers, time, etc.) means one new variant + one new trait method with a sensible default, not surgery across the file.
Estimated scope: parser ~200 LOC, trait + 13 impls ~600 LOC, plus test coverage. ~2-3 days with TDD.
Layer 3: LinguisticNormalizer per language
Number expansion is one of several language-specific text transforms ASR post-processing needs. Today they’re scattered:
PERCENT_WORD_BY_LANG(per-lang percent word)CURRENCY_PREFIXES/SUFFIXES(currency words, but English-only output)- Compound word handling (
compounds.rs) - Cantonese normalization (
cantonese.rs, separate module) - Per-lang reconciler logic in
nlp/lang_<code>.rsmodules
The principled architecture is one LinguisticNormalizer per
language that owns every per-lang text rule. Number expansion is
one method; currency words, percent words, ordinal forms, year
conventions, decade forms, language-specific punctuation are
sibling methods.
pub trait LinguisticNormalizer: Send + Sync {
fn lang(&self) -> LanguageCode3;
fn expand_number(&self, token: NumberToken<'_>) -> Cow<'_, str>;
fn currency_word(&self, sym: CurrencySymbol) -> &'static str;
fn percent_word(&self) -> &'static str;
fn normalize_punctuation(&self, s: &str) -> Cow<'_, str>;
// ... extension points as needs arise
}
static NORMALIZERS: LazyLock<HashMap<LanguageCode3, Box<dyn LinguisticNormalizer>>> = ...;
Per-language routing collapses into one method on this trait. Layer 2’s parser becomes the input pipe. The whole post-processing path becomes “parse token → resolve normalizer → dispatch.”
Estimated scope: significant, touches every per-language code path
in asr_postprocess/. Right size for a multi-week project tied to
the broader summer Malayalam expansion (rupee handling, Indian-style
year forms, ordinal suffixes like ആം). Not standalone.
Migration order (remaining)
- Layer 2 (typed parser). Now-or-never refactor of detection.
Best done before Layer 3 because Layer 3’s normalizer methods
consume
NumberToken. - Layer 3 (full normalizer). Tied to the broader summer Malayalam expansion; do this when the additional per-lang transforms (rupee, ordinals, year forms) are also being added.
Each layer is independently shippable. Don’t bundle.
Out of scope for this rearch
- Probabilistic expansion (LLM-based for ambiguous cases like
"1950"→"nineteen fifty"vs"one thousand nine hundred fifty"). Discussed because it’s interesting, but determinism is a hard project value. If/when we want LLM-assisted disambiguation, it goes in a separate adjudication layer perfeedback_feedback_adjudication_long_term. - CLAN compatibility checks. CLAN doesn’t do ASR; the rework doesn’t change CHAT semantics, only how we get there from ASR.
- Validator changes. The E220 allowlist is correct as designed; the rework doesn’t widen it. The principled fix is per-language expansion, not loosening validation.
Maintenance protocol
When the implementation changes (any patch touching number expansion code), update:
- The “Current architecture” section above to reflect the new reality. If you migrated something out of the proposal, move it from “Future architecture” up.
- The per-language coverage matrix.
- The module map if file paths or line numbers shifted.
- The frozen baseline: regenerate it and name every changed row in the commit message.
- The
Last updatedheader at the top. - Cross-references:
book/src/reference/languages/<lang>.mdfor any per-language pages that mention numbers; thebook/src/batchalign/developer/adding-language-support.mdchecklist (“Number expansion” section) if the procedure changes.
When adding a new language (transcribe support for a language not already on the matrix):
- Determine which expander applies (per the Adding Language Support checklist’s number-expansion section).
- Either:
- add the ISO 639-3 → 2-char mapping to
ISO3_TO_NUM2WORDSinscripts/codegen_num2lang.pyand re-run the script, OR - add a
HAND_CURATEDentry in the same script (one-shot codegen never overwrites the overlay).
- add the ISO 639-3 → 2-char mapping to
- Add the row to this page’s coverage matrix.
- Add a test in
num2text.rs::tests. - If neither path covers the language, document it explicitly: add a row with “Nothing, digit reaches CHAT, E220 fires” so the gap is visible to future contributors.
When updating num2words (Python lib version bump):
The library is no longer a runtime dependency, but
scripts/codegen_num2lang.py invokes it to regenerate
num2lang.json and eng_ordinal_year_fixtures.json. After bumping:
- Re-run
uv run python scripts/codegen_num2lang.py --output crates/batchalign/data/num2lang.json. - Re-run the English ordinal/year fixture generator (see comments in
scripts/codegen_num2lang.py). - Diff the generated files; any value change in a covered language is a behaviour change worth a callout in the commit.
- Run
cargo test -p batchalign --lib; theordinal_year_engcross-validation tests catch divergence.
When changing the CHAT digit-allowlist (rare, requires CHAT-spec maintainer sign-off):
- Update
talkbank-tools/.../digits.rs::DIGIT_ALLOWED_LANGS. - Update the matrix’s last row (“Lang allows digits”) to reflect the new set.
- Re-check each newly allowed language’s coverage matrix row against its frozen baseline rows.
Cross-references
- Adding Language Support , checklist for new-language work; has a “Number expansion” section that points here.
- Malayalam Language Support
, concrete example of a language using the Rust
NUM2LANGpath after the fix. crates/batchalign/CLAUDE.md:asr_postprocess/module map; referencesnum2text.rsfor number expansion specifically.crates/batchalign/CLAUDE.md: Python boundary policy (“Locked de-Pythonization target”). After Round 2, number expansion no longer violates this rule.talkbank-tools/../chatter/crates/talkbank-model/src/validation/word/language/digits.rs, the E220 validator and theDIGIT_ALLOWED_LANGSallowlist.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Language Routing
Status: Current Last updated: 2026-05-21 14:55 EDT
How language information flows from CHAT headers through the entire
batchalign3 pipeline. Covers resolution (precode → header → CLI),
auto-detection (--lang auto), per-utterance routing, per-word
routing limits, and improvement over batchalign2.
For the Stanza capability surface that drives per-language model selection, see Stanza Capability Registry. For per-language defects in the deployed Stanza pipeline, see Stanza Defect Mitigation Map. For the user-facing language reference, see Language Handling.
Three Levels of Language in CHAT
| Level | Syntax | Example | Scope |
|---|---|---|---|
| File | @Languages: eng, spa | Primary + secondary languages | All utterances |
| Utterance | [- spa] precode | Override language for one utterance | One utterance |
| Word | @s:spa marker | Mark a single word’s language | One word |
End-to-End Flow
flowchart TD
chat["CHAT File"]
langs["@Languages: eng, spa"]
precode["[- spa] utterance precode"]
atS["@s:spa word marker"]
chat --> langs & precode & atS
subgraph Rust["Rust Server (orchestration)"]
resolve["Language Resolution\nprecode > @Languages > CLI --lang"]
payload["Per-utterance payload\nlang: LanguageCode3"]
end
langs & precode --> resolve --> payload
subgraph ASR["ASR (Whisper)"]
model_sel["Model Selection\neng→CHATWhisper-en\nyue→whisper-small-cantonese\nheb→whisper-large-v3\n*→whisper-large-v3"]
gen_kwargs["generate_kwargs\nlanguage: lang\ntask: transcribe"]
whisper["Whisper Pipeline"]
end
payload -->|lang| model_sel -->|model| whisper
payload -->|lang| gen_kwargs -->|generate_kwargs| whisper
subgraph Morphosyntax["Morphosyntax (Stanza)"]
group_by["Group utterances\nby language"]
stanza["Stanza NLP\nper-language pipeline"]
end
payload -->|lang per utterance| group_by --> stanza
subgraph FA["Forced Alignment"]
fa_engine["Engine Selection\nyue→wav2vec_canto\n*→wav2vec/whisper"]
end
payload -->|lang| fa_engine
subgraph PostProc["ASR Post-Processing"]
num_exp["Number Expansion\n12 language tables"]
canto["Cantonese Normalization\nyue only"]
end
payload -->|lang| num_exp & canto
subgraph Utseg["Utterance Segmentation"]
utseg_model["Model Selection\neng→CHATUtterance-en\ncmn→CHATUtterance-zh_CN\nyue→Cantonese model\n*→punctuation fallback"]
end
payload -->|lang| utseg_model
atS -->|"parsed but NOT routed\nreplaced with L2 xxx"| stanza
Language flows to all engines: ASR (generate_kwargs), Stanza
(per-utterance grouping), FA (engine selection), post-processing
(conditional gates per lang), utseg (model selection).
Resolution Order
Language is determined per utterance in this priority:
[- lang]precode on the utterance (e.g.,[- fra]), highest priority.@Languagesheader: first declared language used as fallback.--langCLI flag: used when no file-level language is declared.
Implementation: declared_languages() in
crates/talkbank-transform/src/morphosyntax/payload.rs. The job-level
lang parameter
serves only as a fallback when a file has no @Languages header.
In a bilingual English/French file:
@Languages: eng, fra
*INV: how are you today ? 0_3000
*PAR: [- fra] je suis bien merci . 3000_6000
The investigator’s utterance is processed with the English Stanza
pipeline. The participant’s utterance (marked [- fra]) is processed
with the French Stanza pipeline.
Per-Utterance Routing into Stanza
Stanza pipelines are loaded on demand. The worker starts with the primary language model, then loads additional language models as it encounters utterances in new languages.
- No upfront cost for monolingual files (only one model loaded).
- First utterance in a new language may take a few seconds (model download + load).
- Subsequent utterances in the same language reuse the loaded pipeline.
This is a real improvement over batchalign2, which parsed [- lang]
precodes but did not use them for routing. All utterances were
processed with the primary language’s Stanza pipeline regardless of
their language directive. Non-primary utterances either got
wrong-language morphosyntax or were silently dropped.
Per-Word Routing: Currently Limited
Batchalign parses per-word language markers (@s:lang), but current
runtime behavior does not do full per-word language routing into
separate NLP pipelines.
Practical current behavior:
- Per-word language-marked forms are recognized structurally.
- The current morphosyntax path does not send full per-word language codes through as a routing key for Python NLP inference.
- Code-switched words are handled conservatively rather than analyzed as if high-confidence per-word language routing were already implemented.
For current %mor handling, language-marked code-switched words are
treated as special forms (L2|xxx) rather than fully language-routed
lexical items. This is the safe current boundary: preserve that a
word is foreign, do not overclaim morphology from the wrong language
model.
If a transcript contains multiple code-switched words from different languages inside one utterance, the current runtime does not route each word to a different language-specific model and then merge the result back at word granularity. State the boundary clearly rather than imply richer routing than the release provides.
Auto-Detection: --lang auto
When the user passes --lang auto, the pipeline auto-detects the
spoken language(s) from audio content, generates correct CHAT
language headers, and inserts [- lang] code-switching precodes on
utterances in secondary languages.
flowchart TD
cli["CLI: --lang auto"]
spec["LanguageSpec::Auto"]
cli --> spec
subgraph RevAI["Rev.AI Path (production)"]
direction TB
langid["Language ID API\nPOST /languageid/v1/jobs\n~5-30s, audio-based"]
langid_result["top_language: es\nconfidence: 0.907"]
langid --> langid_result
langid_result -->|revai_code_to_iso639_3| resolved_revai["LanguageSpec::Resolved(spa)"]
resolved_revai --> submit_concrete["Submit transcription\nwith language: es"]
end
subgraph Whisper["Whisper Path (local)"]
direction TB
whisper_auto["gen_kwargs omits language key\nWhisper auto-detects per chunk"]
whisper_echo["Response echoes lang=auto"]
whisper_auto --> whisper_echo
end
spec --> RevAI & Whisper
subgraph Fallback["whatlang Fallback (Whisper only)"]
vote["Majority vote across utterances\nwhatlang trigram per-utterance"]
primary["Primary language resolved"]
vote --> primary
end
whisper_echo -->|"lang == auto"| vote
resolved["resolved_lang: LanguageCode3"]
submit_concrete --> resolved
primary --> resolved
subgraph PerUtt["Per-Utterance Detection"]
for_each["For each utterance text"]
detect["whatlang::detect\nconfidence >= 0.5\nmin 40 alpha chars"]
tag["Set utt.lang if differs\nfrom primary"]
end
resolved --> for_each --> detect --> tag
subgraph Build["CHAT Assembly"]
collect["Collect languages with\n>= 3 utterances"]
headers["@Languages: spa, eng"]
precodes["[- eng] on English utterances"]
end
tag --> collect --> headers & precodes
Two-stage resolution
Stage 1, primary language (whole-file). Determines the dominant
language for @Languages header and @ID lines.
| ASR engine | Primary determination |
|---|---|
| Rev.AI (auto) | Rev.AI Language Identification API: audio-based pre-pass (~5-30s). Returns top_language with confidence. Far more accurate than text trigrams for code-switched audio. |
| Whisper (auto) | whatlang majority vote across utterances (fallback to eng if undetectable). |
| Any (explicit) | User-specified --lang spa used directly. |
The Rev.AI Language ID pre-pass also enables the transcription job to
be submitted with a concrete language code instead of "auto", which
improves ASR quality (Rev.AI can optimize for the known language) and
enables language-specific settings like speakers_count and
skip_postprocessing.
Stage 2, per-utterance language (code-switching). Only runs
when --lang auto. For each post-processed utterance:
- Concatenate all word texts into a single string.
- Run
whatlang::detect(): returns(Lang, confidence)orNone. - If confidence ≥ 0.5 and text has ≥ 40 alpha characters, set
utt.lang = Some(iso639_3_code). - If
utt.langdiffers from the primary language, a[- lang]precode is emitted.
Detection algorithms
Rev.AI Language Identification API. Audio-based phonetic
classifier. POST /languageid/v1/jobs → poll → result. Handles
code-switching correctly because it hears the dominant phonetic
patterns. ~5-30 s, ~$0.01-0.05 per file (negligible vs. transcription
cost). Coverage: all Rev.AI-supported languages (~60+).
Fallback chain: if Language ID fails (network error, unsupported
format), submit transcription with language: "auto" and use
whatlang on the transcript text.
whatlang trigram detection. Used for per-utterance code-switching tagging (all backends) and as primary fallback when Rev.AI Language ID is unavailable. O(n) in text length, no ML model, no network call , typically < 1 ms per utterance, < 50 ms for 200 utterances. Reliable for monolingual utterances > 40 characters; unreliable for code-switched utterances. Coverage: 69 languages with ISO 639-3 mappings.
| Threshold | Value | Rationale |
|---|---|---|
MIN_CHARS_FOR_DETECTION | 40 | Below this, trigrams are too sparse. Raised from 20 to reduce false positives on short bilingual utterances. |
UTTERANCE_CONFIDENCE_THRESHOLD | 0.5 | Moderate bar to avoid false code-switch markers. |
MIN_UTTERANCES_FOR_SECONDARY | 3 | A language must appear in ≥ 3 utterances to be listed in @Languages. Prevents false positives from trigram confusion. |
Known limitation. whatlang struggles with code-switched utterances (e.g., “Me dice que trabaja en furniture I mean…”). Such utterances may be classified as either language depending on which trigrams dominate. Inherent to character n-gram classifiers, Rev.AI Language ID (audio-based) is preferred for primary detection.
CHAT output
For a Spanish-primary bilingual file:
@Languages: spa, eng
@Participants: PAR Participant Participant, INV Investigator Investigator
@ID: spa|corpus_name|PAR|||||Participant|||
@ID: spa|corpus_name|INV|||||Participant|||
@Media: herring03, audio
*PAR: sí porque ella no quería . 12500_14200
*INV: [- eng] six to eight weeks yeah . 41672_42727
*PAR: bueno ya le dije que no . 43000_45100
In build_chat.rs, the UtteranceDesc.lang field is checked against
langs[0] (primary). If different, TierContent.language_code is
set to LanguageCode::new(utt_lang), which the talkbank-model
serializer renders as [- lang] before the first word.
The @Languages header lists all detected languages ordered by
frequency: primary first, secondaries in descending order, no
minimum threshold for header inclusion. The
collect_detected_languages() function tallies per-utterance
detections and produces the ordered list.
Known Limitations
- Per-word routing not implemented.
@s:langmarkers are parsed but not routed. Code-switched words becomeL2|xxxspecial forms rather than language-routed lexical items. - whatlang on code-switched text is unreliable. Use Rev.AI Language ID for primary detection when the audio is bilingual.
- First utterance in a new language pays model-load cost. Subsequent utterances in the same language reuse the pipeline.
- Malayalam (
mal) digit expansion (E220) is unimplemented. The num2words library has nomlbackend, so digit expansion in Malayalam ASR / morphotag emits E220 rather than expanding numerics to their orthographic form. Whisper Hub is used as the deliberate ASR engine formal(Rev.AI broken). The fix path is upstream num2words coverage, not in this codebase.
See Also
- Stanza Capability Registry , which Stanza models are loaded for which languages.
- Stanza Defect Mitigation Map , per-language defects in deployed Stanza models.
- Cantonese and CJK, Architecture,
Cantonese-specific routing detail (POS override,
--retokenize). - Language Handling , user-facing language reference.
- Language Code Resolution , user-facing detail on resolution priority and edge cases.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
CHAT Parsing (Rust)
Status: Current Last updated: 2026-09-07 07:04 EDT
All CHAT parsing and serialization is handled by Rust. The CHAT lifecycle
(parsing, word extraction, result injection, validation, serialization)
runs in Rust both on the server side (batchalign crate) and
through the PyO3 bridge (batchalign_core). Python handles only ML
inference, Whisper, Stanza, wav2vec, translation models.
Status: Current Last verified: 2026-03-11
Architecture
CHAT processing follows the same pattern on both runtime paths:
CHAT text (.cha file)
│
▼
parse_lenient() → ChatFile AST
│
├── extract words/payloads → structured data for ML inference
├── inject %mor/%gra → from morphosyntax results
├── inject word timings → from forced alignment results
├── inject %xtra → from translation results
├── split utterances → from utseg results
│
▼
to_chat_string() / handle.serialize()
│
▼
CHAT text (valid, correctly formatted)
These operations happen in the Rust server crate (batchalign) using
functions from batchalign. Python workers provide only raw ML
inference results; the Rust server handles all CHAT parsing, mutation, and
serialization.
What Rust Does
Parsing
Two parse modes:
parse(text)– strict mode. Rejects files with parse errors.parse_lenient(text)– error-recovery mode. Marks unparseable utterances withParseHealthflags but continues. Used by the server orchestrators, which must handle messy real-world CHAT files.
Both parsers use a tree-sitter grammar from the
talkbank-tools workspace (grammar/grammar.js)
to produce a concrete syntax tree, which is then walked into typed Rust model
structures (ChatFile, MainTier, WorTier, Terminator, etc.).
NLP Word Extraction
extract_words()
(../chatter/crates/talkbank-transform/src/extract.rs:48) walks the AST and
produces an ordered list of “NLP-clean” words with node indices. It
skips retraces, events, CA markers, overlap points, and other
non-lexical content based on the requested TierDomain. The node
indices let downstream code map NLP output (Stanza tokens, FA word
timings) back to AST positions in O(1) per token, no DP re-alignment
needed.
This replaced the old Python annotation_clean function (60+ lines of
.replace() calls) and eliminated O(n*m) DP alignment in the morphosyntax
and forced alignment engines.
Tier Construction
For all NLP commands (morphosyntax, FA, translation, utseg), the pattern is:
- Rust collects payloads from the AST (word lists, utterance texts, etc.).
- ML inference runs (via worker IPC on the server path, or via Python callback on the Python API path).
- Rust injects results back into the AST, constructing the appropriate dependent tiers (%mor, %gra, %wor, %xtra, etc.).
Payload collection and result injection use functions from
batchalign (e.g., collect_payloads() / inject_results() for
morphosyntax).
Serialization
The WriteChat trait
(../chatter/crates/talkbank-model/src/model/write_chat.rs:41) produces valid
CHAT text from the AST. It handles all formatting concerns:
continuation lines, escaping, bullet timestamp encoding, tier
alignment, and header ordering. Rust callers invoke chat_file.to_chat_string();
there is no PyO3 handle.serialize() surface today (the ParsedChat
binding was retired in the 2026-03-21 pyo3 slimdown).
Validation
Server-side validation runs through validate_to_level and
validate_output in ../chatter/crates/talkbank-transform/src/validate.rs,
covering the full suite of CHAT validation checks (E362 monotonicity,
E701/E704 temporal, tier alignment, header correctness). These return
typed error lists used by the pre-serialization validation gate. On
the Python side, structured validation results reach callers as
CHATValidationException.errors (a list[ValidationErrorEntry]),
see Errors, Batchalign Runtime.
What Stays in Python
| Operation | Module | Python Library |
|---|---|---|
| ASR transcription | inference/asr.py | transformers |
| Forced alignment | inference/fa.py | transformers, torchaudio |
| Morphosyntactic analysis | inference/morphosyntax.py | stanza |
| Speaker diarization | inference/speaker.py | nemo, pyannote |
| Utterance segmentation | inference/utseg.py | stanza |
| Translation | inference/translate.py | googletrans, seamless |
| Audio feature extraction | inference/opensmile.py | opensmile |
| Cantonese ASR | inference/languages/cantonese/ | tencent/aliyun/funasr SDKs |
Each module exports a pure inference entrypoint used by the live V2 worker host
(for example the morphosyntax helper called from _text_v2.py, or the ASR/FA
helpers used by execute_v2). Python workers are stateless ML endpoints;
server/client orchestration is in Rust (axum + Rust CLI), and server-to-worker
transport is stdio IPC.
Background
This current Rust AST architecture replaced the older Python-heavy CHAT path, which depended much more on string manipulation, text flattening, and post-hoc reconstruction. The durable current rule is:
- CHAT parsing, extraction, injection, validation, and serialization belong in Rust
- Python workers should focus on inference, not CHAT ownership
That boundary is what makes the current morphosyntax, alignment, translation, and utterance-segmentation behavior more predictable than the older BA2-era paths.
Current boundaries
Current DP posture
The important current distinction is not “DP never exists anywhere.” It is:
- current CHAT parsing and tier construction no longer depend on broad flattened-text reconstruction
- current morphosyntax standard paths are index-driven
- current FA handling in the Rust path is deterministic and identity/index-first
- edit-distance style algorithms remain legitimate for evaluation tasks such as WER
If you need the public migration story for where older BA2-style DP-heavy recovery changed, use the migration chapters rather than this architecture page.
See:
- Python-Rust Boundary , server-side CHAT ownership, wire protocol, capability discovery, worker module layout.
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Server Dispatch Architecture
Status: Current Last updated: 2026-08-31 07:13 EDT
This page describes the implemented batchalign3 runtime:
batchalignhandles CLI parsing, file discovery, dispatch, daemon lifecycle, and local output writing.batchalignprovides the HTTP server, job store, worker pool, OpenAPI, and server-side CHAT orchestration.- Python workers in
batchalign/worker/load ML dependencies and execute inference over stdio JSON-lines IPC.
The Rust control plane never loads ML models directly.
Design rationale
The current split exists to keep the control plane separate from the ML runtime:
- The CLI and server share one Rust workspace and one typed contract surface.
- Remote-only clients can use the CLI without local ML dependencies.
- Local processing still relies on Python workers, but model loading is pushed out of the Rust process and managed through the worker pool.
- Rust owns CHAT parsing, validation, cache lookup, injection, and serialization for the server-side command paths.
Locked de-Pythonization boundary
The current repository finish line is not “remove Python completely.” The boundary is intentionally narrower and should be treated as the target for future cleanup work:
- keep the worker subprocess model;
- keep Python only at direct model/SDK boundaries plus the thinnest bootstrap and dispatch code needed to host those calls;
- move everything practical that is provider-independent, config ownership, payload preparation, cache policy, post-processing, validation, CHAT mutation, and orchestration, into Rust;
- keep already-landed BA2 compatibility shims out of scope for this wave.
| Bucket | Current surfaces | Direction |
|---|---|---|
| Stays Python (for now) | batchalign/worker/, batchalign/inference/, batchalign/models/ | host for ML model calls until Rust gains the equivalent coverage |
| Thin worker-side glue | batchalign/providers/ (re-exports worker IPC types), schema mirrors at the worker boundary | keep minimal; Rust owns all document semantics |
| Already moved to Rust | config/runtime policy, payload preparation, post-processing, CHAT mutation, validation, orchestration, WER scoring | done; no backsliding |
| Already removed | batchalign.compat, batchalign.pipeline_api, batchalign.inference.benchmark, ParsedChat | gone, no Python public API exists |
The detailed module inventory lives in Python-Rust Boundary.
Runtime layout
+------------------+ HTTP +------------------+ stdio JSON +----------------------+
| Rust CLI | -----------> | Rust Server | -------------> | Python worker |
| (batchalign) | /jobs | (batchalign) | IPC | (batchalign/worker) |
+------------------+ +------------------+ +----------------------+
| |
v v
+----------+ +-------------+
| jobs.db | | ML models |
| SQLite | | Stanza/ASR |
+----------+ +-------------+
Runtime ownership boundaries
The server runtime is organized around three owned subsystems plus one shallow route-state aggregate:
JobStoreowns in-memory job state plus SQLite write-throughRuntimeSupervisorowns the queue-dispatch loop and tracked per-job tasksWorkerPoolowns Python worker process lifecycle and serializes per-key bootstrap so bursty demand does not launch multiple heavy workers for the same bucket at onceAppStategroups route-visible handles as control plane, worker subsystem, environment, and build identity
flowchart LR
routes["Routes"] --> store["JobStore"]
routes --> supervisor["RuntimeSupervisor"]
supervisor --> queue["Queue dispatcher"]
supervisor --> jobs["Job tasks"]
jobs --> pool["WorkerPool"]
store --> db["SQLite"]
routes --> state["AppState"]
state --> control["Control plane"]
state --> workers["Worker subsystem"]
state --> environment["Environment"]
state --> build["Build identity"]
Shared-state ownership rule
The control-plane rule is:
- state that coordinates multiple routes, jobs, or background tasks gets an owned task or actor boundary
- mutexes stay private to a subsystem when they only protect tiny local cells
JobRegistry actorization completed that rule for the main in-memory jobs map.
Routes, query modules, and runner code now call named JobStore/JobRegistry
methods instead of borrowing a shared lock.
flowchart LR
callers["Routes / queries / runner"] --> store["Named store methods"]
store --> registry["JobRegistry actor"]
registry --> map["Owned jobs map"]
registry -. "recovery only" .-> bulk["inspect_all / mutate_all"]
inspect_all() / mutate_all() remain deliberate escape hatches for crash
recovery and other rare collection-wide reconciliation. New feature work should
prefer per-job projections and transitions. Local mutexes still exist inside
subsystems such as OperationalCounterStore and WorkerPool, but those are
owner-private implementation details rather than architectural coordination
seams.
Route state boundary
HTTP handlers share one Arc<AppState>, but the root state is intentionally
shallow:
AppControlPlanefor job store, queue wakeups, runtime supervision, and WS broadcastWorkerSubsystemfor worker-pool access and command capability dataAppEnvironmentfor config, media resolution, and filesystem rootsAppBuildInfofor version/build identity reported to clients
flowchart LR
routes["HTTP handlers"] --> state["AppState"]
state --> control["AppControlPlane"]
state --> workers["WorkerSubsystem"]
state --> environment["AppEnvironment"]
state --> build["AppBuildInfo"]
That keeps route code from depending on a flat catch-all server struct and keeps runner-only dependencies such as cache and infer metadata out of shared handler state entirely.
Job shape
JobStore still owns a shared jobs registry, but it now does so through an
explicit JobRegistry component with named operations for submission,
listing, cancellation, queue claiming, and runner snapshots, plus narrower
per-job helpers for the remaining local transitions. OperationalCounters
also live in their own OperationalCounterStore component instead of another
interior Arc<Mutex<_>>. The registry’s shared map now lives inside one owned
actor task: JobStore and the surrounding query/runner helpers send Inspect
or Mutate commands over an unbounded channel and await oneshot replies, so
access is serialized at a message boundary rather than through a shared mutex
field. Each Job is also no longer a flat field bag. The current runtime shape
is grouped as:
JobIdentityJobDispatchConfigJobSourceContextJobFilesystemConfigJobExecutionStateJobScheduleStateJobRuntimeControl
flowchart LR
job["Job"] --> identity["Identity"]
job --> dispatch["Dispatch"]
job --> source["Source context"]
job --> filesystem["Filesystem"]
job --> execution["Execution state"]
job --> schedule["Schedule and lease"]
job --> runtime["Runtime control"]
That split matters because routes, queueing, and runner code no longer need one 30+ field interior runtime record just to touch one concern.
Runner boundary
The runner now has a sharper read/write split:
- dispatchers receive immutable
RunnerJobSnapshotvalues for static job configuration JobStoreowns named execution mutations, andJobRegistryowns the in-memory projection/transition API, but the actual job-level state transitions for re-queue, running, failure, and finalization now live onJob- registry methods now return typed summary/file projections for WebSocket
publication, so query modules no longer borrow raw
Jobvalues just to publish live updates - queue dispatch now uses typed
QueuePollsnapshots andLeaseRenewalOutcomeinstead of raw strings, timestamps, and booleans - file-level status transitions now reconcile through
Jobmethods and then flow through runner utility helpers, instead of open-coded store-lock blocks in every dispatcher
flowchart LR
runner["run_job"] --> snapshot["RunnerJobSnapshot"]
runner --> mutations["JobStore execution methods"]
mutations --> jobexec["Job execution transitions"]
snapshot --> dispatch["FA / transcribe / infer dispatchers"]
dispatch --> fileops["File status helpers"]
fileops --> job["Job file transitions"]
jobexec --> store["JobStore"]
job --> store
That still leaves a shared logical job registry, but callers now cross the
registry actor boundary instead of reaching for a shared lock or open-coded
store-wide collection helpers. The remaining bulk escape hatches stay inside
JobRegistry for recovery-style operations that genuinely need collection-wide
ownership.
Queue and lease boundary
The local queue backend now crosses the store boundary with typed values:
QueuePollfor claimed ready jobs plus the next wake deadlineLeaseRenewalOutcomefor the heartbeat loopJobmethods for local-dispatch readiness, claim, release, and renewal
That keeps queue wakeups and lease renewal from depending on Vec<String>,
Option<f64>, bare booleans, and open-coded lease field mutation.
flowchart LR
store["JobStore"] --> job["Job lease methods"]
job --> poll["QueuePoll"]
poll --> backend["QueueBackend"]
backend --> dispatcher["QueueDispatcher"]
runner["Job task"] --> lease["LeaseRenewalOutcome"]
lease --> job
Current crate and package map
| Component | Current location | Role |
|---|---|---|
| CLI | crates/batchalign | clap CLI, dispatch router, daemon lifecycle, output writing |
| Server | crates/batchalign | axum routes, job store, worker pool, OpenAPI, server-side orchestration |
| CHAT ops | crates/batchalign | CHAT extraction, injection, validation, FA/morphosyntax helpers |
| Python worker | batchalign/worker/ | worker entry point, model loading, capabilities, infer/execute dispatch |
| Python inference | batchalign/inference/ | engine-specific inference backends |
Older names such as the nested Rust workspace and batchalign-server are
historical. batchalign-types is an active crate that holds shared domain
newtypes and worker protocol types (see the workspace Cargo.toml).
Dispatch resolution
The CLI router in crates/batchalign/src/cli/dispatch/mod.rs resolves targets
in this order:
- explicit
--serverfor command classes that can target a remote server directly - local daemon if
auto_daemonis enabled - already-running loopback server on the configured local port
- direct local execution
Special cases:
transcribe,transcribe_s,benchmark, andavqiprefer local-daemon dispatch whenauto_daemonis enabled; if that daemon path is unavailable, the router still falls back to the explicit--server- explicit
--serveralways stays on content mode, even forlocalhost - local daemons and auto-detected loopback servers use shared-filesystem
paths_modefor local-audio commands - multi-server
--server URL1,URL2is rejected in the current release daemon.rsstill contains sidecar lifecycle helpers, but the current dispatch path does not auto-select a sidecar yet
Server endpoints in use
The current server exposes these job/control endpoints:
GET /healthPOST /jobsGET /jobsGET /jobs/{job_id}GET /jobs/{job_id}/resultsGET /jobs/{job_id}/results/{filename}POST /jobs/{job_id}/cancelDELETE /jobs/{job_id}POST /jobs/{job_id}/restartGET /jobs/{job_id}/streamGET /media/listGET /ws
Dashboard and bug-report routes are also present, but the list above is the core processing surface.
Concurrency mapping
| Legacy Python implementation | Rust rewrite equivalent |
|---|---|
ProcessPoolExecutor for CPU-heavy commands | Stanza/IO profile: persistent Python subprocesses, exclusive checkout |
ThreadPoolExecutor for GPU/ASR paths | GPU profile: SharedGpuWorker with Python ThreadPoolExecutor inside one process |
| Global pool size logic in Python server | Job-level semaphore (max_concurrent_jobs) + per-profile pool limits in Rust server |
Additional safeguards:
- Memory gate before job start (skipped when idle workers for the job’s
(command, lang)already exist in the pool). - Auto-concurrency defaults use 12 GB/slot and hard-cap at 8 slots.
Command routing
| Command class | Routing behavior |
|---|---|
morphotag, align, translate, utseg, coref, compare | Explicit single --server, local daemon, auto-detected loopback server, or direct local fallback |
transcribe, transcribe_s, benchmark, avqi | Prefer local daemon when auto_daemon is enabled; if it is unavailable, fall back to explicit single --server, then loopback server, then direct local |
The current mixed-runtime sidecar idea remains only partially wired: the daemon lifecycle helpers still exist, but dispatch does not yet auto-select a sidecar server for transcribe-related commands.
Server-side inference
For text-only commands, the server owns the full CHAT lifecycle, no CHAT text crosses IPC to Python workers:
- Parse: read
.chafiles, parse into ChatFile AST - Extract: collect payloads (words, text) from the AST
- Cache check: look up each utterance in the server-side UtteranceCache
- Infer: send cache misses to Python workers via typed
execute_v2requests (cross-file batching per language for text tasks) - Inject: insert model results back into the AST
- Serialize: validate and write output
.chafiles
| Command | Dispatch Path | Worker Role |
|---|---|---|
| morphotag, utseg, translate, coref | infer (cross-file) | Stateless model inference only |
| align | infer (per-file, per-group) | Stateless audio/text alignment inference |
| transcribe, transcribe_s | infer (per-file audio) | Raw ASR inference feeding a Rust-owned pipeline |
| benchmark | infer (per-file audio + compare) | Raw ASR inference feeding Rust transcribe + compare |
| diarize, opensmile, avqi | infer (per-file media V2) | Rust-owned prepared-audio media analysis over typed worker requests |
There is no CLI command literally named speaker; speaker is the low-level
worker capability. It supports integrated transcribe_s and the standalone
diarize command, whose product is anonymous turns JSON rather than CHAT.
SSE job streaming
For lightweight real-time progress monitoring (alternative to WebSocket):
GET /jobs/{job_id}/stream
Returns Server-Sent Events:
snapshot: initial file statuses on connectfile_update: per-file status changesjob_update: overall job status changescomplete: job finished (stream closes)
Worker protocol
Workers are spawned by the server pool and communicate over stdio JSON-lines. The key operations are:
healthcapabilities: reports infer tasks and engine versions; Rust derives commandsprocessbatch_infer(shrinking compatibility path)execute_v2(live typed infer path)shutdown
The current Rust worker handle tolerates a bounded amount of non-protocol stdout noise while waiting for startup or a response, which protects the pool from common library banners and download messages. Protocol-shaped malformed JSON is still treated as a hard framing error so the request fails loudly instead of silently desynchronizing the stream.
For live execute_v2 requests, the worker/result contract is also split on
purpose: malformed request payloads and unreadable prepared artifacts stay in
invalid_payload / attachment error buckets, while malformed model-host output
is reported as runtime_failure. That keeps bad Python/SDK result shapes from
masquerading as caller input mistakes.
Concurrent dispatch for GPU workers
GPU profile workers support concurrent V2 requests via request_id multiplexing:
- Rust sends multiple
execute_v2requests to one GPU worker without waiting for responses - Python’s
_serve_stdio_concurrent()dispatches to aThreadPoolExecutor(4 threads) - Responses carry
request_idfields, Rust’s background reader routes them to pending oneshot channels - Non-V2 ops (health, capabilities, shutdown) use a separate sequential control channel
sequenceDiagram
participant R1 as Rust task 1
participant R2 as Rust task 2
participant W as GPU Worker
participant T1 as Python thread 1
participant T2 as Python thread 2
R1->>W: execute_v2(id=1, FA)
R2->>W: execute_v2(id=2, FA)
W->>T1: dispatch(id=1)
W->>T2: dispatch(id=2)
T2-->>W: response(id=2)
W-->>R2: response(id=2)
T1-->>W: response(id=1)
W-->>R1: response(id=1)
How a shared GPU worker gets created
Two different serializations govern worker creation, and conflating them leads to wrong conclusions about where a stall came from.
| Level | Mechanism | What it serializes |
|---|---|---|
| Process | memory_guard’s SPAWN_SEMAPHORE, one permit, held until the worker signals ready | Every spawn in the process, so a second model load never checks free RAM before the first one’s models are resident |
| Key | the GpuWorkerSlot in each gpu_workers entry (worker/pool/gpu_slot.rs) | The callers of ONE key, so they share a single spawn instead of racing to start several worker processes |
The map’s own lock is held only long enough to hand out a slot. It used to be
held across the whole spawn, which did prevent duplicate spawns but also made
every other user of the map wait for an unrelated key’s model load: dispatches
whose worker was already warm, and /health, which walks the same map. On a
busy host that is tens of seconds of unrelated stalling per cold key.
Two consequences worth knowing:
- Per-key coordination does not make spawns parallel. The process-level semaphore still admits one at a time, deliberately. What it removes is work that needs no spawn queuing behind one.
- A spawn can now finish after
shutdown()has drained the map.shutdown()cancels its token before draining, and the spawning task retires its own worker when it sees that token set, rather than returning a worker nothing would reap. Callers getWorkerError::PoolShuttingDown.
execute_v2 is the main path for live server-owned inference:
- Rust prepares text/audio artifacts
- Python workers run inference on those prepared inputs
- Rust injects results back into the AST and serializes output
batch_infer remains only as a shrinking compatibility surface:
- Rust extracts payloads from CHAT
- Python workers run inference on those payloads
- Rust injects results back into the AST and serializes output
This path is intentionally not the target boundary for new work. New
control-plane logic should land either in Rust or on the typed execute_v2
surface, not by widening process or batch_infer.
Rev.AI submission is no longer a worker IPC operation. The Rust server owns
Rev.AI-backed raw-ASR evidence lookup, language identification, submission,
polling, validation, and durable commit through batchalign::revai
(crates/batchalign/src/revai/), so the Python boundary stays inference-only.
Only a typed cache miss can authorize a paid request, and a per-key lease makes
concurrent identical requests converge on one service crossing. The same
server-owned boundary handles Rev-backed timed-word recovery for align UTR.
The legacy batch preflight upload path is deliberately disabled: it submitted before evidence lookup and could not enforce the typed miss authorization boundary. Cold Rev.AI batches therefore run through the normal per-file concurrency today. A future parallel preflight replacement must plan each file as either a validated evidence hit or an authorized miss before submitting any provider work.
Capability detection
Capabilities are detected lazily from the first real worker spawn rather than
from a dedicated probe worker at startup. When the first worker for any profile
starts, it reports which infer tasks the Python environment supports via import
probes (importlib checks whether each task’s dependencies are installed) and
returns a non-empty engine version for every advertised task, it does not load
full models beyond what the spawned command requires. Rust then derives the
released command surface from that infer-task set and gates job submission on
the derived commands only.
See Capability Discovery for the full flow, the import probe table, and troubleshooting tips.
Local daemon state
The local daemon uses the same configured port from ~/.batchalign3/server.yaml
(default 8000). It records state in daemon.json and can start a separate
sidecar profile for transcribe workloads.
serve start writes server.log for manually started servers, and the server
itself writes server.pid: a handshake naming its PID and the port it actually
bound. Callers read the port from there rather than from server.yaml, whose
port is a request (0 asks the OS to choose).
Auto-daemon state is tracked separately from manual serve start.
Startup recovery
Server startup now treats crash recovery as an explicit typed transition rather than ad hoc map mutation.
- SQLite marks previously active jobs as
Interrupted. JobStore::load_from_db()rebuilds eachJobvalue from persisted rows.Job::reconcile_recovered_runtime_state()decides the canonical next state: requeue unfinished work or promote all-terminal jobs toCompleted/Failed.- The reconciled status and cleared lease metadata are written back to SQLite before normal queue dispatch resumes.
That keeps the in-memory control plane and the persisted recovery snapshot in sync after every restart.
Job lifecycle and cancellation
Jobs progress through a small state machine. The transitions are explicit
methods on Job (store/job/lifecycle.rs); routes, runners, and reconcilers
never mutate JobStatus ad-hoc.
stateDiagram-v2
[*] --> Queued
Queued --> Running: dispatched
Queued --> Cancelled: user cancel
Running --> Completed: all files done
Running --> Failed: any file errored
Running --> Cancelled: user cancel
Running --> Interrupted: server shutdown
Interrupted --> Queued: recovery (resumable files)
Interrupted --> Completed: recovery (all files done)
Interrupted --> Failed: recovery (any file errored)
Two things distinguish this from a flat “every terminal looks the same” model:
Cancelledis reserved for user gestures. TUI cancel and HTTPPOST /jobs/{id}/cancelreach this state. Cancelled is permanent, a Cancelled job is never auto-resumed. The user said stop; the server honors that.Interruptedis the system-initiated counterpart. Graceful server shutdown and crash recovery (db.recover_interruptedSQL migration) both writeJobStatus::Interrupted. AlthoughJobStatus::is_terminal()returnstruefor it, the recovery sequence above is special-cased to transition resumable Interrupted rows back toQueuedso the next local runner attempt picks up where the previous server left off.
Writing Cancelled for a system event would conflate “user said stop” with
“server bounced”, and the two require opposite responses. The 2026-04-27
investigation found long-running fleet jobs perpetually labeled cancelled
even though no user had pressed cancel, because the shutdown handler used
the user-cancel transition. The fix routed shutdown through Interrupted
so the recovery sequence can act on it.
Cancel-provenance audit
Every cancel attempt, user or system, appends one row to the
cancellations audit table with a typed source, host, pid, reason, and
in-flight filename. Multiple rows per job are normal (two cancel clicks an
hour apart, one user cancel followed by a system cancel at shutdown,
etc.).
CancelSource value | Origin |
|---|---|
Tui | TUI cancel keystroke |
Api | HTTP POST /jobs/{id}/cancel |
Cli / Dashboard / Staging | other user-facing entry points |
Signal | system-initiated: server-shutdown handler |
CancelReason is a free-form string. CancelReason::server_cancel_all()
is the stable reason emitted by the shutdown path so audit readers can tell a
system interrupt from a user cancel without parsing ad hoc strings.
Migration-hash drift (deploy hardening)
Sqlx records SHA-384 of each migration’s SQL bytes in
_sqlx_migrations.checksum at apply time and refuses to start a binary
whose embedded migration content hashes don’t match, even comment-only
edits change the hash. Without intervention, a privacy scrub or
documentation fix on a shipped migration wedges every fleet host into a
startup crash loop on the next deploy. KeepAlive=true masks the
failure as a tight crash loop, which launchctl reports as
“spawn scheduled, active count = 0”, easy to misdiagnose as a
launchctl issue.
The deploy runtime self-heals this. At deploy build time,
automation/pyinfra/deploys/deploy_batchalign3.py::_compute_migration_hashes
hashes every crates/batchalign/migrations/*.sql and embeds the
list as expected_migrations in the per-host JSON config. Before
bootstrapping the new daemon,
LocalBatchalignServiceSystem.reconcile_migration_hashes reads
_sqlx_migrations, compares each row’s stored hash against the
expected hash, and UPDATEs any drifted row with a WARN log line.
The trust model: the developer who pushed the migration content change is asserting (by deploying) that the change is semantically benign. The runtime codifies that assertion against the fleet. Operational runbook: the deploy procedure’s migration-hash drift (self-healing) section.
Key files
| File | Role |
|---|---|
crates/batchalign/src/cli/dispatch/mod.rs | top-level dispatch router |
crates/batchalign/src/cli/dispatch/single.rs | explicit remote single-server dispatch |
crates/batchalign/src/cli/dispatch/paths.rs | local-daemon paths-mode dispatch |
crates/batchalign/src/daemon.rs | daemon lifecycle, state files, sidecar handling |
crates/batchalign/src/routes/mod.rs | axum router composition and middleware |
crates/batchalign/src/routes/jobs/mod.rs | job submission/list/detail routes |
crates/batchalign/src/routes/health.rs | /health payload and capability reporting |
crates/batchalign/src/types/config/ | ServerConfig, defaults, validation, state dir (split: layout.rs, load.rs, resolve.rs, server.rs, tests.rs) |
crates/batchalign/src/runner/ | job runner, dispatch shape selection |
crates/batchalign/src/runner/dispatch/ | batched infer, FA, transcribe pipelines |
crates/batchalign/src/morphosyntax/ | morphosyntax orchestrator (parse→cache→infer→inject) |
crates/batchalign/src/fa/ | forced alignment orchestrator |
crates/batchalign/src/runner/dispatch/transcribe_pipeline.rs | transcribe orchestrator (ASR→postprocess→CHAT assembly) |
crates/batchalign/src/utseg.rs | utseg orchestrator |
crates/batchalign/src/translate.rs | translation orchestrator |
crates/batchalign/src/coref.rs | coreference orchestrator |
crates/batchalign/src/cache/ | Tiered utterance cache (moka hot + SQLite cold), BLAKE3 keys |
crates/batchalign/src/worker/pool/ | worker spawn, checkout, health loop, idle timeout |
crates/batchalign/src/db/ | SQLite persistence (WAL), schema, recovery, TTL pruning |
batchalign/worker/_main.py | Python worker entry point |
batchalign/worker/_model_loading/ | Python worker model-loading package |
batchalign/worker/_stanza_loading.py | Stanza configuration and ISO-code mapping |
This page last changed: 2026-08-31 (commit 386c6460). The whole book last changed: 2026-09-16 (commit 34d249d8).
Dashboard Architecture
Status: Current Last updated: 2026-05-21 15:00 EDT
The Batchalign dashboard is one React UI shipped two ways: the web dashboard served by the Rust control plane, and a desktop operator app via Tauri that hosts the same React code. Rust OpenAPI is the canonical dashboard contract source.
Two delivery surfaces, one UI
┌──────────────────────────────────────┐
│ Same React codebase (one UI) │
└──────────────────────────────────────┘
│ │
▼ ▼
Web dashboard Tauri desktop shell
(served by (apps/dashboard-desktop)
Rust server)
│ │
▼ ▼
┌──────────────────────────────────────┐
│ Rust control plane (batchalign) │
│ OpenAPI = canonical contract │
└──────────────────────────────────────┘
React layer
React is the canonical dashboard UI implementation. The web dashboard is the supported public surface; the Tauri shell is a desktop wrapper around the same React code with a researcher-friendly processing flow.
The Tauri side is intentionally thin, plugins + one custom command. All UI logic lives in React. Reasons:
- One UI codebase across web and desktop.
- Mature ecosystem for web UI quality, testing, and observability.
- Strong desktop packaging / update path via Tauri with minimal bespoke runtime code.
Tauri shell features
The desktop processing flow (apps/dashboard-desktop) provides:
- Command picker.
- Native folder dialog.
- Job submission with
paths_mode. - SSE-driven progress.
- “Open output folder” action.
- Server auto-start on launch, auto-stop on exit.
- Status bar with manual start / stop controls.
- First-time setup wizard (engine selection + Rev.AI key) matching
batchalign2’s mandatory
interactive_setup()gate.
The CLI also gates processing commands on ~/.batchalign.ini
existence, matching the BA2 behavior.
Rust OpenAPI contract
The dashboard contract is generated from Rust types. Both the web dashboard and the Tauri shell consume the same OpenAPI spec, so schema drift between the two delivery surfaces is impossible by construction.
Scope rules
- React is the dashboard feature-development target.
- The web dashboard is the supported current public surface.
- The Tauri shell provides the end-user processing flow
(
/processroute) for researchers who aren’t comfortable with terminals. - The Tauri side stays thin (plugins + one custom command); all UI logic lives in React.
- Rust-only end-to-end UI stacks are not pursued, adding Node/TypeScript toolchain ownership is the conscious tradeoff for ecosystem maturity.
Source layout
| Path | Role |
|---|---|
frontend/ | React dashboard sources (served by batchalign server) |
apps/dashboard-desktop/ | Tauri shell (rust-tauri + React UI build) |
crates/batchalign/src/openapi.rs | OpenAPI schema generation |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Batchalign Workers
Status: Current Last updated: 2026-07-30 18:21 EDT
Per-app worker concerns specific to the Batchalign runtime: pool
sizing, RAM-tier-aware memory budgets, model loading per worker,
saturation safeguards, and the three-layer parallelism model. These
build on the workspace’s shared concurrency primitives: the Tokio
runtime, the Semaphore + RAII pool pattern, channels, lock ordering,
and the no-Mutex policy.
Implementation note. Tier scaling is wired (b4189037).
estimate_per_worker_peak_mb_with_profile(Phase β,batchalign-types::memory) clamps the per-job execution envelope to the detected tier’s per-profile startup budget, so a 16 GB laptop with a clean memory state can admit a 1-worker morphotag job (Contract C-16GB is GREEN). The five architectural principles that prevent the layer-by-layer consolidation bug class from returning are covered by the tier-aware memory tests inbatchalign-types.
Three Layers of Parallelism
Batchalign has three independent parallelism controls:
graph TD
A["--workers N (CLI flag)"] --> B["max_workers_per_job\n(ServerConfig)"]
B --> C["compute_job_workers()"]
C --> D["JoinSet + Semaphore\n(file-level parallelism)"]
E["max_workers_per_key\n(PoolConfig, default: 8)"] --> F["Worker Pool\n(Python processes per profile+lang)"]
G["gpu_thread_pool_size (default 4)"] --> H["dispatch_semaphore\n(Rust, SharedGpuWorker)"]
G --> I["ThreadPoolExecutor\n(Python, max_workers=K)"]
H --> J["execute_v2 in flight ≤ K\n(matches Python serving capacity)"]
I --> J
Layer 3’s twin nodes (dispatch_semaphore on the Rust side and
ThreadPoolExecutor on the Python side) share one ceiling
K = gpu_thread_pool_size. The Rust gate ensures a caller waiting
for an executor slot does not hold an active per-request timer; the
timer only ticks once a slot is granted and work is issued. See
developer/worker-protocol-v2.md § “The dispatch semaphore contract”
for the architectural rule.
Layer 1: File parallelism (user-facing)
How many files are processed concurrently within a single job.
- User control:
--workers NCLI flag, ormax_workers_per_jobinserver.yaml. - Default: GPU-heavy commands (
transcribe,align,benchmark): 1 file (prevents OOM). CPU-only commands (morphotag,utseg,translate): auto-tuned bycompute_job_workers()based on available RAM and CPU cores. - Implementation:
runner/dispatch/uses aJoinSetwith aSemaphore(num_workers)to cap concurrent file-processing tasks.
sequenceDiagram
participant CLI as CLI (--workers N)
participant Daemon as Auto-Daemon
participant Server as Rust Server
participant AutoTune as compute_job_workers()
participant Runner as Job Runner
CLI->>Daemon: spawn with --workers N
Daemon->>Server: start with max_workers_per_job=N
Note over Server: Job submitted with 10 files
Server->>AutoTune: command, num_files, config
AutoTune-->>Server: min(N, files, RAM/budget, CPUs)
Server->>Runner: Semaphore(computed_workers)
Runner->>Runner: Process files with bounded concurrency
Layer 2: Worker pool (operator-facing)
How many Python worker processes exist per (profile, language, engine) key.
- User control:
max_workers_per_keyinserver.yaml(not a CLI flag, operator concern). - Default: 8 per key. GPU profile: 1 process (concurrent via threads). Stanza profile: auto-tuned. IO profile: 1 process.
- Implementation:
worker/pool/mod.rsmanages worker lifecycle. Workers are spawned lazily and cached.
Layer 3: GPU dispatch concurrency (Rust gate + Python pool)
How many execute_v2 calls are in flight at the same time per
shared GPU worker.
- User control:
gpu_thread_pool_sizeinserver.yaml(default 4). Single knob sets both PythonThreadPoolExecutor(max_workers=K)and Rust-sidedispatch_semaphorepermit count. - Default: 4. On Apple Silicon (MPS excluded for batchalign3), set to 1, there is no compute parallelism to gain, and a higher value just means CPU-bound inferences contending for cores.
- Implementation
- Rust:
worker/pool/shared_gpu/stdio.rsandworker/pool/shared_gpu/tcp.rseach carry adispatch_semaphore: Arc<Semaphore>withKpermits, acquired before the per-request timeout starts. - Python:
batchalign/worker/_protocol.py::_serve_stdio_concurrenthosts aThreadPoolExecutor(max_workers=K).
- Rust:
sequenceDiagram
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3 (queued)
participant Sem as dispatch_semaphore (K=2)
participant Worker as SharedGpuWorker
participant Py as Python ThreadPool (max_workers=2)
T1->>Sem: acquire (granted)
T2->>Sem: acquire (granted)
T3->>Sem: acquire (waiting, no timer running yet)
T1->>Worker: pending.insert + stdin.write + tokio::time::timeout START
T2->>Worker: pending.insert + stdin.write + tokio::time::timeout START
Worker->>Py: execute_v2 (id=1, id=2)
Py-->>Worker: response(id=1)
Worker-->>T1: success
T1-->>Sem: release
Sem->>T3: granted
T3->>Worker: pending.insert + stdin.write + tokio::time::timeout START
Task 3’s per-request timer only starts at the moment it acquires the
permit, never during queue-wait. This is the contract asserted by
tests/gpu_concurrent_dispatch.rs::gpu_concurrent_dispatch_does_not_charge_queue_wait_against_per_request_timeout.
Why GPU commands default to 1 worker
Each GPU-heavy inference (Whisper ASR, Whisper FA, Wave2Vec) loads 2-5 GB of model weights into GPU/MPS memory. Multiple concurrent files all share the same GPU memory pool.
On a 64 GB developer machine with MPS:
- 1 concurrent file: ~5 GB GPU memory, safe.
- 4 concurrent files: ~20 GB GPU pressure, risky.
- 8 concurrent files (old default): ~40 GB GPU pressure, kernel OOM crash.
Setting GPU commands to default to 1 file prevents this class of
crash entirely. Operators with dedicated GPU hardware can safely
increase via --workers N or server.yaml.
RAM-Tier Adaptive Budgets
All memory budgets are scaled automatically based on total system RAM. The server detects the tier once at startup and logs it. No user configuration, works on machines from 16 GB laptops to 256 GB servers.
flowchart LR
detect["sysinfo::total_memory()"]
tier{"MemoryTier::from_total_mb()"}
small["Small < 24 GB\nheadroom: 2 GB\nstanza: 3 GB\ngpu: 6 GB\nworkers: 1"]
medium["Medium 24-48 GB\nheadroom: 4 GB\nstanza: 6 GB\ngpu: 3 GB (LazyProfile)\nworkers: 1"]
large["Large 48-128 GB\nheadroom: 8 GB\nstanza: 12 GB\ngpu: 16 GB\nworkers: 4"]
fleet["Fleet ≥ 128 GB\nheadroom: 8 GB\nstanza: 12 GB\ngpu: 16 GB\nworkers: 8"]
detect --> tier
tier -->|"< 24 GB"| small
tier -->|"24-48 GB"| medium
tier -->|"48-128 GB"| large
tier -->|"≥ 128 GB"| fleet
Medium-tier GPU uses LazyProfile mode: the GPU worker starts with only process overhead (~3 GB), loading model weights on demand rather than at spawn. This is what lets a 32 GB workstation admit a GPU worker at all, eager-loading 16 GB of Whisper weights would fail the headroom check.
The Large and Fleet tiers reproduce the original fixed constants
from runtime_constants.toml exactly, fleet machines see zero
behavior change. The TOML constants remain as the Large/Fleet
baseline; the tier system scales them down for smaller machines.
Source: crates/batchalign/src/types/runtime.rs,
MemoryTier::from_total_mb() (pure, testable) and
MemoryTier::detect() (reads sysinfo).
Why small machines need smaller budgets
The original constants were tuned for 64-256 GB fleet machines where multiple concurrent workers are the norm. On a 16 GB laptop the fleet defaults are infeasible:
- Stanza startup 12 GB exceeds available memory (~9 GB on macOS) → daemon can never start.
- Host headroom 8 GB leaves no room for any worker at all.
- GPU startup 16 GB exceeds total system RAM.
The Small tier reduces these to match actual model sizes: Stanza uses ~2-3 GB RSS, Whisper float32 ~4-5 GB. The reduced headroom (2 GB) still prevents OOM while allowing the single-worker model to function.
Memory Check Flow
Every point where memory is checked or reserved, from daemon spawn through job completion. Each gate that can block is marked.
flowchart TD
start["CLI: batchalign3 morphotag corpus/ output/"]
daemon["ensure_daemon()\n(daemon.rs)"]
spawn_server["spawn batchalign3 serve start\n--foreground"]
tier_detect["MemoryTier::detect()\n(runtime.rs)"]
bind["Bind TCP port immediately\n/health available\nno Python running yet"]
health_poll["CLI polls /health\n(passes instantly)"]
job_submit["POST /jobs\n(accepted optimistically)"]
job_plan{{"Memory guard #1\nwait_for_job_execution_plan()\n(execution budget × workers)"}}
worker_spawn{{"Memory guard #2\ncheckout() → spawn worker\n(startup reservation)"}}
caps_detect["Query capabilities from\nfirst worker (OnceLock)"]
inference["Python worker runs\nML inference"]
result["Return results\nrelease all leases"]
start --> daemon
daemon --> spawn_server
spawn_server --> tier_detect
tier_detect --> bind
bind --> health_poll
health_poll --> job_submit
job_submit --> job_plan
job_plan -->|"passes"| worker_spawn
job_plan -->|"denied: reduces\nworker count or\nretries"| job_plan
worker_spawn -->|"passes"| caps_detect
worker_spawn -->|"denied: retries\nup to 300s"| worker_spawn
caps_detect --> inference
inference --> result
Key behavior. No Python process runs until the first job
arrives. The daemon starts in <1 second and uses zero memory at
idle. Memory guards only fire when actual work is requested, using
tier-scaled budgets (Small tier: 3 GB Stanza, 2 GB headroom →
9000 − 3000 = 6000 ≥ 2000 passes on 16 GB).
Worker-Pool Saturation Safeguards
Two safeguards together prevent silent corruption when the worker
pool hits its global cap during a multi-language morphosyntax batch.
Both exist because an earlier architecture could silently emit CHAT
files with missing %mor/%gra tiers on utterances in languages
the pool had temporarily “locked out.”
The failure mode
A morphotag batch processes utterances grouped by language. Each language group needs a Stanza worker for its 3-letter language code. When the batch spans more languages than the pool can hold concurrently, earlier groups’ workers go idle but stay counted in the global cap, and later language groups find no slot available to spawn.
Without safeguards the failure surfaces at two layers:
- Pool layer:
WorkerPool::checkoutreturnsErr(SpawnFailed("... cannot wait (would deadlock)"))rather than freeing a slot from an idle worker of a different key. - Orchestrator layer: the morphosyntax batch catches the
pool error, logs a warning, substitutes an empty
UdResponsefor every utterance in the affected group, and continues to injection.clear_morphosyntaxalready cleared the existing tiers; the empty placeholder is stripped byremove_empty_morphosyntax_placeholders. Net effect: file serialized withrc=0but affected utterances have lost their morphosyntactic annotation. Nothing in the log names the file; downstream auditors see a per-file success.
Safeguard 1: idle-eviction + bounded wait in checkout
crates/batchalign/src/worker/pool/eviction.rs,
/dispatch.rs, /checkout.rs.
When checkout(target, lang, overrides) finds the pool saturated
and this key has zero live workers, the loop does three things in
order before returning any error:
- Try eviction,
WorkerPool::try_evict_idle_from_other_groupsnapshots every group’s idle count, picks the non-skip group with the highest idle count via the pure helperselect_eviction_target, non-blockingly acquires itsavailablepermit, pops one idle handle, decrements that group’stotal, and drops the handle on a detached task. One global-cap slot is now free; the main loopcontinues back to spawn. - Park on
worker_returned: Arc<Notify>with a bounded deadline (checkout_wait_timeout_s, default 300 s). EveryCheckedOutWorker::dropcallsnotify_waiters()so all saturated checkouts across all keys wake on any return and retry in parallel. On wakeup, retry eviction (now backed by the freshly returned idle worker) and spawn. - On deadline, return
Err(WorkerError::SpawnFailed("no worker available for {target}/{lang} within {secs}s, pool saturated with no idle workers to evict")). Genuine starvation case (every worker checked out and busy, no returns for 5 minutes); propagates to the orchestrator which turns it into a per-file error, never a silent empty tier.
flowchart TD
CO["checkout(target, lang)"] --> FP{"fast path\n(idle handle available?)"}
FP -- yes --> RET["return CheckedOutWorker"]
FP -- no --> SP["try_spawn_into_group"]
SP -- spawned --> RET
SP -- at cap --> HASW{"this key has\nlive workers?"}
HASW -- yes --> AWAIT1["await available permit\n(existing behavior)"]
AWAIT1 --> RET
HASW -- no --> EV["try_evict_idle_from_other_group"]
EV -- Evicted --> SP
EV -- NoIdleElsewhere --> AWAIT2["await worker_returned\n(bounded: checkout_wait_timeout_s)"]
AWAIT2 -- wakeup --> EV
AWAIT2 -- deadline --> FAIL["Err(SpawnFailed saturation_timeout)"]
Invariants:
group.totalnever underflows. The decrement in eviction runs only after a successfultry_acquire+pop_front.- No worker is destroyed while it has pending work. Only idle
workers are evicted (non-blocking
try_acquireonavailable). - No new lock-ordering hazard. Same order as
global_worker_countandtry_spawn_into_group. - Graceful shutdown. The evicted handle is dropped on a detached
task so
checkoutnever waits onWorkerHandle::Drop(SIGTERM+SIGKILL).
select_eviction_target is a pure function of
HashMap<K, GroupSnapshot>. Five unit tests in
worker/pool/eviction.rs cover every selector branch.
Safeguard 2: orchestrator-level failure propagation
crates/batchalign/src/morphosyntax/{mod.rs, worker.rs}. Even
with idle-eviction, a language group can still fail dispatch. The
orchestrator must not silently continue with an empty UdResponse.
Each per-language-group infer call returns
Result<Vec<UdResponse>, ServerError>. The morphosyntax orchestrator
collects these results and, on any Err, propagates a typed
ServerError::Validation upward with a message naming the failed
languages. The clear step has already reset the affected files’
tiers in place, so failure short-circuits before injection, no file
is serialized with stripped tiers.
Files whose language groups all succeeded still get injected normally; the CLI surfaces the per-file failure list through the standard job/file-status reporting path.
End-to-end contract
- Idle-eviction prevents the saturation-induced “cannot wait (would deadlock)” error in the common case where other groups hold idle workers.
- Bounded wait handles the rarer case where every worker is genuinely checked out by returning a typed error after 5 minutes rather than blocking forever.
- The morphosyntax orchestrator converts every pool-level failure into per-file errors that the CLI surfaces as non-zero exit codes. No code path writes a file with a stripped tier.
Configuration knobs
| Key | Default | Purpose |
|---|---|---|
max_workers_per_key | per-profile, RAM-derived (recommend_max_workers_per_key); GPU ≈ ram_total_mb / 16 GB, Stanza ≈ ram_total_mb / 12 GB, IO 1 | Per-key cap; prevents one language from hogging |
max_total_workers | computed from RAM (clamped 2-32) | Global cap |
checkout_wait_timeout_s | 300 | Bounded wait before saturation error |
Raising max_total_workers or max_workers_per_key reduces how
often eviction fires but never changes correctness. The
saturation-timeout knob should match the orchestrator’s
per-language-group timeout so a checkout stall and a language stall
surface at the same timescale.
Pipeline Parallelism
The job pipeline today is batch-all-then-dispatch: read all
files → parse all → collect payloads → cache partition → group by
language → dispatch language groups → inject all results →
serialize → write all outputs. CPU-bound work is wrapped in
tokio::task::spawn_blocking and parallelized per-file with
rayon::par_iter so the async runtime stays responsive during
parsing, injection, and serialization.
flowchart TD
A["Read ALL files\n(async)"] --> B["Parse ALL files\n(spawn_blocking + rayon)"]
B --> C["Collect ALL payloads\n(spawn_blocking + rayon)"]
C --> D["Cache partition\n(async)"]
D --> E["Group by language"]
E --> F["Dispatch language groups\n(async, semaphore-bounded)"]
F --> G["Inject ALL results\n(spawn_blocking + rayon)"]
G --> H["Write ALL outputs\n(async, concurrent JoinSet)"]
What this gives:
- Async runtime stays responsive during CPU work, heartbeats, progress updates, health checks all work.
- Per-file injection / serialization runs on all CPU cores. 8-core machine, 500-file injection: ~4 min → ~30 s.
What this does not solve:
- First file still can’t complete until ALL language groups finish.
- Progress is still batch-level, not per-file-streaming.
- Memory: all 500 parsed ASTs in memory simultaneously.
- A crash after injection but before write loses all work.
A streaming redesign (per-file flow through stages with windowed language accumulators) is a future direction; the trade-offs (batching efficiency, cross-file language grouping, error propagation, testing complexity) are documented in the forward-looking proposal, not a commitment.
Source File Map
| File | Role |
|---|---|
runner/dispatch/infer_batched.rs | Batch dispatcher: read → delegate → write |
morphosyntax/batch.rs | Morphotag L2 dispatch for @s words |
morphosyntax/worker.rs | Per-language-group worker dispatch with chunking |
morphosyntax/mod.rs | Top-level morphotag orchestrator; aggregates per-language Result<Vec<UdResponse>, ServerError> and propagates failures upward |
fa/mod.rs | FA per-file processing |
runner/dispatch/fa_pipeline.rs | FA orchestrator with JoinSet concurrency |
runner/dispatch/transcribe_pipeline.rs | Transcribe per-file with optional morphotag |
utseg.rs, translate.rs, coref.rs | Other batched text commands |
worker/pool/mod.rs | Worker lifecycle and group management |
worker/pool/eviction.rs | Idle eviction (try_evict_idle_from_other_group, select_eviction_target) |
worker/pool/checkout.rs / dispatch.rs | Checkout state machine (saturation timeout, worker_returned notify) |
runner/util/auto_tune.rs | compute_job_workers() planning |
types/runtime.rs | Re-exports MemoryTier::from_total_mb and estimate_per_worker_peak_mb_with_profile from batchalign-types::memory (Phase β); command_execution_budget_mb for legacy callers. MemoryTier is the sole canonical source of per-tier per-profile envelopes (Principle 1); estimate_per_worker_peak_mb_with_profile is the tier-aware per-command estimator (Principle 2). |
batchalign/runtime_constants.toml | Per-command base RAM (process and threaded variants), worker caps, command-to-task map. Generated from batchalign-types/src/command_spec.rs via xtask gen-runtime-toml (Phase β); do not edit directly. No longer holds per-profile worker startup envelopes, those live on MemoryTier. |
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
Cantonese and CJK: Architecture
Status: Current Last updated: 2026-05-19 17:38 EDT
Architecture and rationale for batchalign’s CJK-language pipelines: ASR
engine dispatch, Rust-side text normalization, and word segmentation
(--retokenize). User-facing reference (engine table, credentials, usage
examples) lives in
batchalign/reference/languages/cantonese.md.
This page also covers the CJK utterance-segmentation split:
yueuses the PolyU Cantonese utterance modelcmn/zhousetalkbank/CHATUtterance-zh_CN- all three are separate from the word-segmentation /
--retokenizepath
Engine Dispatch: Enum, Not Plugins
Cantonese ASR/FA engines are registered directly in worker model loading
and dispatch code. No plugin discovery, no entry points, no dynamic
registration. Engine selection uses AsrEngine and FaEngine enums in
worker/_types.py.
flowchart LR
cli["CLI\n--engine-overrides\n'{\"asr\": \"tencent\"}'"]
server["Rust Server"]
worker["Python Worker"]
subgraph "Worker Model Loading"
load["load_worker_task()"]
select{"AsrEngine?"}
whisper["load whisper"]
tencent["load_tencent_asr()"]
funaudio["load_funaudio_asr()"]
aliyun["load_aliyun_asr()"]
end
cli --> server --> worker --> load --> select
select -->|whisper| whisper
select -->|tencent| tencent
select -->|funaudio| funaudio
select -->|aliyun| aliyun
Engines are (load, infer) function pairs in
batchalign/inference/languages/cantonese/. Each engine fails at startup
with a clear error if its model/credentials are unavailable, never at
runtime during inference. Compile-time exhaustiveness checking on the
enum guarantees no engine can be silently missed.
Provider boundary split
Python owns SDK/model loading and the transport call. Rust owns the shared
projection from raw provider output into monologues and timed-word payloads
(crates/batchalign-pyo3/src/cantonese_asr_bridge.rs):
- Tencent:
ResultDetailwith pre-segmentedWordsarray → absolute timestamps computed from segment start + word offset. - FunASR: FunASR’s own unit list (
wordsorraw_text) paired with its per-unit timestamps, counts checked before pairing. - Aliyun: Sentence-level results with optional per-word timing → fallback character tokenization when per-word timing unavailable.
No word is filtered by its duration. Every time is admitted by
AdmittedInterval, the one owner of rounding and range admission: an inverted
pair (end_ms < start_ms) is inadmissible and refuses the file by name, giving
the provider, the position and the fault, while a zero-width span becomes an
untimed word carrying UntimedCause::ZeroLengthSpan, because a span covering
no time cannot locate a word in audio. The word keeps its surface either way;
only a word with an admitted interval reaches timed_words(). FunASR
timestamps are sort-normalized into start-time order before downstream
processing.
No provider bridge normalizes text. Every surface crosses as the provider wrote it; the section below says where normalization does happen.
Text Normalization: one owner in the server
Cantonese ASR engines (FunASR, Tencent, Aliyun, Qwen, Whisper) return text in
simplified Chinese or with Mainland character variants. CHAT corpora require
Traditional Chinese with domain-specific corrections. Normalization runs
automatically for lang=yue; no configuration, no opt-out.
flowchart LR
input["One monologue's words\n(provider surfaces)"]
joined["Joined into one run"]
OpenCC["OpenCC s2hk\nconversion"]
replace["Domain replacements\n(Aho-Corasick)"]
check{"same character\ncount?"}
refill["Each word gets back\nexactly its own characters"]
refuse["NormalizationChangedLength\n(file refused)"]
input --> joined --> OpenCC --> replace --> check
check -->|yes| refill
check -->|no| refuse
Implementation: crates/batchalign-transform/src/asr_postprocess/cantonese.rs.
AlignedNormalizationis the only route to normalized Cantonese text.normalize_cantoneseitself is private. The type takes a RUN of units, not a string, because the engines report one unit per Han character and a two-character replacement (真系to真係) cannot match inside a single character. It normalizes the concatenation once and hands each unit back exactly as many characters as it contributed.- The constructor is the proof. Refilling is only sound while the character
count is unchanged, so the constructor checks it and returns
NormalizationChangedLength(carrying both counts) otherwise. The ASR pipeline pairs units with per-unit timings, so a count change would shift every later timing while the text still looked plausible. ferrous-opencc(pure-Rust crate) embeds OpenCC’sS2hkconversion tables in the build. No C++ dependency, no optional import, no fallback path.- 31-entry domain replacement table: Aho-Corasick with
LeftmostLongestmatching ensures multi-character patterns (e.g.,聯係→聯繫) take priority over single-character ones (系→係). Multi-character entries (13) match before single-character entries (18). Every entry maps N characters to N characters, and a unit test holds that. - Why one owner matters here: the transformation is NOT idempotent. The
table maps
繫to係, so normalizing an already-normalized聯繫yields聯係. Before 2026-09-16 it ran in three places (the provider bridge, the server’s stage 4b, and the character tokenizer), so text could be normalized twice or one character at a time. - No Python surface.
normalize_cantoneseandcantonese_char_tokensused to be exported to Python; production called neither, and they offered a second route into a transformation that must run exactly once.
Can the refusal actually fire?
Not with the tables this build embeds, on any input measured so far.
cargo run -p batchalign-transform --example s2hk_length_audit -- <dictionaries>
normalizes every Han code point the pipeline recognizes (81,520 of them) plus
every key and value of OpenCC’s own s2hk dictionaries (STPhrases,
STCharacters, HKVariantsPhrases, HKVariants,
CJK_Compatibility_Ideographs: 109,605 further strings) and reports how many
changed length. On 2026-09-16, with ferrous-opencc 0.4.0: none of 191,125.
The refusal stays because a table update is a data change, and the constructor
is what makes such a change fail loudly instead of silently moving timings.
Pipeline integration
Normalization runs inside prepare_words_pre_expansion(), once per monologue,
BEFORE anything splits the words:
1. Compound merging
2. Timed word extraction (seconds → ms)
2d. Cantonese normalization (simplified → traditional + domain table)
3. Multi-word splitting (timestamp interpolation)
4. Number expansion (digits → traditional Chinese characters)
5. Long-turn splitting (>300 words)
6. Retokenization (punctuation-based utterance splitting)
It has to precede stage 3: that stage interpolates timestamps across a token’s characters, so it must see final text, and normalizing after it would normalize one character at a time.
UTF-8-safe retokenization
crates/talkbank-transform/src/asr_postprocess/mod.rs uses
char_indices() for proper UTF-8 handling, byte slicing would panic
on multi-byte CJK characters:
let last_char_boundary = text.text.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
Word Segmentation: --retokenize
CJK ASR engines output character-level tokens because Chinese characters
are the atomic unit of speech recognition. Stanza POS/dependency models
expect word-level input, tagging individual characters produces
meaningless results. The --retokenize flag on morphotag enables word
segmentation before Stanza inference.
Segmenter selection
| Language | Retokenize segmenter | Why |
|---|---|---|
yue (Cantonese) | PyCantonese segment() | Cantonese-specific dictionary; Stanza zh is Mandarin-trained and misses Cantonese compounds (佢哋, 鍾意) |
cmn/zho (Mandarin) | Stanza zh jointly-trained tokenizer | Best available for Mandarin; no comparable open-source dictionary segmenter exists |
Verified empirically in test_cjk_word_segmentation_claims.py:
PyCantonese correctly groups 佢哋 (they) and 鍾意 (like). Stanza
correctly groups 商店 (store) for Mandarin. These are real model
inferences, not assumptions.
Why opt-in, not always-on
morphotag never silently changes tokenization. Surprise AST rewrites
would invalidate existing %wor bullets or forced-alignment timing. A
diagnostic warning is emitted when Cantonese input appears per-character
without --retokenize, guiding users to the flag.
Lazy pipeline loading
Mandarin retokenize uses Stanza’s tokenize_pretokenized=False, which
loads a neural tokenizer model (~200 MB). Loading at startup wastes RAM
when retokenize isn’t requested. The retokenize pipeline is loaded on
first request and stored under key "{lang}:retok" in worker state.
Wire protocol
MorphosyntaxRequestV2.retokenize is an opt-in field with
#[serde(default)] in Rust and retokenize: bool = False in Python.
Workers that don’t yet understand the field default to False; old Rust
senders that don’t include it get the default behavior.
Cache key differentiation
The cache key includes |retok when retokenize is active. Without this,
a non-retokenize cache entry (per-character %mor) would be incorrectly
returned for a retokenize request (word-level %mor), or vice versa.
Data flow: Cantonese with --retokenize
sequenceDiagram
participant CLI as Rust CLI
participant Server as Rust Server<br/>(batchalign)
participant Worker as Python Worker<br/>(morphosyntax.py)
participant PC as PyCantonese
participant Stanza as Stanza NLP
CLI->>Server: morphotag --retokenize<br/>(file's @Languages: yue drives per-file lang)
Server->>Server: parse CHAT, extract per-char words<br/>["故", "事", "係", "好"]
Server->>Worker: MorphosyntaxRequestV2 { retokenize: true, lang: "yue" }
Worker->>PC: pycantonese.segment("故事係好")
PC-->>Worker: ["故事", "係", "好"]
Worker->>Stanza: nlp("故事 係 好") pretokenized=True
Stanza-->>Worker: UD words with POS/dep
Worker-->>Server: UdResponse
Server->>Server: retokenize_utterance()<br/>rewrites AST: ["故","事","係","好"] → ["故事","係","好"]
Server->>Server: inject %mor/%gra, serialize
Stanza pipeline selection
flowchart TD
input{"Language + retokenize?"}
yue_retok["yue + retokenize=true"]
yue_std["yue + retokenize=false"]
cmn_retok["cmn/zho + retokenize=true"]
cmn_std["cmn/zho + retokenize=false"]
other["all other languages"]
pyc["PyCantonese segment()\n→ Stanza zh pretokenized=True"]
zh_pretok["Stanza zh\npretokenized=True"]
zh_retok["Stanza zh\npretokenized=False\n(key: '{lang}:retok')"]
std["Standard Stanza pipeline\n(per-language config)"]
input --> yue_retok --> pyc
input --> yue_std --> zh_pretok
input --> cmn_retok --> zh_retok
input --> cmn_std --> zh_pretok
input --> other --> std
POS-Depparse Inconsistency
The PyCantonese POS override changes upos in the UD word dict after
Stanza has already computed dependency relations using its own (wrong)
POS. So %mor tiers carry PyCantonese POS but %gra tiers were computed
with Stanza’s Mandarin POS. The dependency tree structure was not
recomputed with the corrected POS.
In practice this is still an improvement: %mor POS was wrong before
(50%) and is now better; %gra was always computed with wrong POS and
has not gotten worse. The proper fix is a Cantonese-specific Stanza model
that handles POS and depparse jointly.
Known limitations
- Word segmentation depends on PyCantonese dictionary. Words not in
the dictionary (novel compounds, baby talk, code-mixed
Cantonese-English) won’t be grouped. Common words (
佢哋,鍾意,故事) are handled correctly. - All Cantonese ASR engines produce per-character output. Verified
empirically including Tencent (which earlier reports claimed did
word-level segmentation).
--retokenizeis needed for all Cantonese morphotag. - POS tagging accuracy is ~50% on Cantonese vocabulary without the
PyCantonese override. Stanza’s
zhmodel is Mandarin-trained and misclassifies佢/佢哋(he/they → PROPN instead of PRON),嘢(thing → PUNCT instead of NOUN),唔(not → VERB instead of ADV),係(is/be → VERB instead of AUX). PyCantonese POS override fixes core vocabulary but has gaps on compound nouns, some SFPs, and resultative verbs. - Trained Cantonese Stanza model exists but is not deployed. Trained
on UD_Cantonese-HK + tested on held-out UD set: POS 93.5% (vs. 63%
Mandarin baseline), LAS 65.2% (vs. 24%). On spoken Cantonese test
sentences PyCantonese POS still wins (96% vs. 86%) on core vocabulary
due to domain mismatch, the trained model would be most useful as a
fallback for words PyCantonese doesn’t know. Requires packaging the
model file and updating
_stanza_loading.py. - FunASR CER varies with speech clarity. FunASR/SenseVoice produces the lowest CER for clear adult Cantonese speech, but CER increases with overlapping speech, soft/unclear speech, and child speech.
File Map
Rust
crates/batchalign-transform/src/asr_postprocess/
├── mod.rs: Pipeline: process_raw_asr()
├── prepare.rs: stage 2d, one Cantonese run per monologue
├── cantonese.rs: AlignedNormalization (the one owner), cantonese_char_tokens()
├── compounds.rs: Compound word merging
├── num2text.rs: Number expansion
└── num2chinese.rs: Chinese/Japanese number converter
crates/talkbank-transform/src/retokenize/: Language-agnostic AST rewrite
crates/batchalign/src/chat_ops/cache_key.rs: cache_key() with retokenize differentiation
crates/batchalign/src/morphosyntax/mod.rs: Per-character warning diagnostic
crates/batchalign-pyo3/src/cantonese_asr_bridge.rs: Provider output projection
crates/batchalign-types/src/worker_v2/requests.rs: MorphosyntaxRequestV2.retokenize wire field
Python
batchalign/inference/languages/cantonese/
├── __init__.py: Engine registration
├── _common.py: read_asr_config(), parse_timestamp_pair()
├── _tencent_asr.py: Tencent Cloud ASR load/infer
├── _tencent_api.py: TencentRecognizer class
├── _aliyun_asr.py: Aliyun NLS WebSocket ASR
├── _funaudio_asr.py: FunASR/SenseVoice load/infer
├── _funaudio_common.py: FunAudioRecognizer class
├── _cantonese_fa.py: Cantonese forced alignment (jyutping + Wave2Vec)
└── _asr_types.py: Internal TypedDicts
batchalign/inference/morphosyntax.py: _segment_cantonese(), Mandarin retokenize selection
batchalign/worker/_stanza_loading.py: load_stanza_retokenize_model() (lazy Chinese)
Tests: no unittest.mock
The test suite uses test doubles (alternate protocol implementations)
rather than mocks: PyCantoneseFake (deterministic jyutping lookup,
7-character dictionary), _FakeAudioFile / _FakeAudioChunk,
_fake_infer_wave2vec_fa (deterministic FA: word i gets timing
(i*100, (i+1)*100)), cantonese_fa_env fixture patching module-level
state. monkeypatch.setattr() replaces unittest.mock.patch()
everywhere.
| Test file | Coverage |
|---|---|
tests/languages/cantonese/test_common.py | Normalization, config, timestamps, language codes |
tests/languages/cantonese/test_funaudio.py | Text cleaning, tokenization, timed word sorting |
tests/languages/cantonese/test_tencent_api.py | Model selection, monologues, timed words, normalization |
tests/languages/cantonese/test_cantonese_fa.py | Jyutping conversion, romanization, batch FA inference |
tests/languages/cantonese/test_aliyun.py | Sentence parsing, word extraction, error handling |
tests/languages/cantonese/test_helpers.py | Cross-engine smoke tests |
tests/languages/cantonese/test_integration.py | End-to-end with real models (auto-skips) |
tests/pipelines/morphosyntax/test_cjk_word_segmentation_claims.py | Word segmentation claims (real PyCantonese + batchalign_core) |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Stanza Limitations and Their Workarounds
Status: Current Last updated: 2026-07-28 13:18 EDT
Stanza is the morphosyntax engine behind batchalign3 morphotag. It does not
guarantee Universal Dependencies conformance, and several of its language
models misbehave on the short, single-word utterances that dominate
child-language corpora. This page records every limitation we have found, the
evidence for it, and what the pipeline does about it.
The governing rule: never trust Stanza’s output shape. Everything it
returns crosses a validation boundary before anything downstream sees it. That
boundary is RepairedSentence in batchalign/inference/morphosyntax.py:
batch_infer_morphosyntax builds each returned item from one, and the only way
to build one is from raw Stanza words, so the step cannot be skipped.
Every relation it rewrites is reported as well as applied. The repair travels
to the server on the analyzed item, and the file’s morphotag provenance comment
counts it as ud_repairs=, so a repaired transcript says so in itself rather
than only in a worker log.
Why that boundary is load-bearing (2026-07-28 incident)
The validators existed and were unit-tested for months, and nothing on the
production path ever called them. batch_infer_morphosyntax took
doc.to_dict() straight into the response. The consequences reached published
data:
<PAD>sanitization existed, yet barePADappears as a%grarelation in the corpora.- Non-UD
iobflowed into%graasIOBacross many files.
Neither was noticed because nothing checked relations on either side: CLAN
CHECK does not validate %gra at all, and chatter only gained the rule (E761)
in v0.4.0, which is what finally surfaced it.
The generalizable lesson, which cost real corpus damage: a validator that its
own unit tests exercise but no production caller invokes is worse than no
validator, because it produces confident false assurance. Tests for this
boundary must drive batch_infer_morphosyntax, not UdWord in isolation.
Limitation 1: non-UD dependency relations
Symptom. Stanza’s Italian model emits deprel="iob". Universal
Dependencies has no such relation; the label is iobj.
Evidence (stanza 1.13.0, verified 2026-07-28):
'attenzione .' id=2 text='ne' upos=PRON head=1 deprel='iob'
Impact. 2|1|IOB written into %gra across published corpora, oldest
observed provenance ba3 morphotag | engine=stanza-1.11.1 (2026-05-08).
Workaround. UdWord._normalize_deprel_to_ud checks the relation HEAD
against UD_RELATIONS (the 37 UD v2 relations), maps known aliases via
UD_DEPREL_ALIASES (iob -> iobj), and degrades anything unrecognized to
dep with a warning.
Only the HEAD is a closed set. UD defines SUBTYPES as open and
language-specific, and the corpora legitimately use many (nmod:poss,
acl:relcl, flat:foreign), so the subtype is preserved verbatim. This
mirrors exactly what chatter’s E761 enforces on the reading side; the two
vocabularies must not drift apart.
Limitation 2: Italian MWT destroys single-word -ne nouns
Symptom. In ISOLATION, every tested Italian noun ending in -ne is split
by the MWT processor into a nonexistent verb plus the clitic ne.
Evidence (stanza 1.13.0, 2026-07-28), 10/10 mis-split:
attenzione -> ('attenzi', VERB, root) ('ne', PRON, iob)
stazione -> ('stazio', VERB, root) ('ne', PRON, iob)
canzone -> ('canzo', VERB, root) ('ne', PRON, iob)
persone -> ('perso', VERB, root) ('ne', PRON, iob)
opinione, ragione, lezione, situazione, televisione, macchine: same shape
In sentence context the same words are analyzed correctly, so this is specific to short utterances:
mi piace questa canzone . -> canzone intact, no iob
ci sono molte persone qui . -> persone intact, no iob
Why this matters here more than elsewhere. Single-word utterances are the
norm in child speech (*CHI:\tmacchine .), so a defect that only bites short
utterances bites CHILDES hardest. The damage is not limited to the relation
label: %mor records verb|attenzare for the noun attenzione, inventing a
verb that does not exist in Italian.
Corpus impact (measured 2026-07-28 over all 106,158 corpus files with a
Rust tool on the typed CHAT AST, resolving language per WORD via the canonical
resolver: @Languages primary vs secondary, [- lang] precodes, @s
markers). 657 files are Italian-PRIMARY, carrying 809,264 MOR-domain words of
which 806,133 resolve to Italian; a further 428 files declare Italian only as
a SECONDARY language and their words default elsewhere. Across both, 1,608
Italian-resolved single-word utterances in 338 files carry a verb+enclitic
%mor analysis: the superset containing all committed mis-split damage plus
the genuine dammelo-class imperatives, which regeneration with the fixed
pipeline distinguishes. Word-language resolution left ZERO words unresolved,
and explicitly marked code-switching in Italian-primary files is small (793
@s-marked words, 2,279 precoded utterances, top other languages nld/deu).
Status: FIXED 2026-07-28, in batchalign/inference/_italian_mwt.py.
Two fixes that do not work
Both are worth recording, because both look right and both were measured wrong.
A closed-class allowlist. Italian’s preposition+article contractions and
ecco+enclitic forms are enumerable, so expansion can be suppressed outside
those sets. This passes every noun assertion and destroys every genuine
imperative, because verb+enclitic is an OPEN class and no surface pattern
separates giralo (turn it) from cavallo (horse). Worse, it does not even
fail safe: suppressed, diglielo comes back as verb|diglielare, an invented
verb, so the fix reproduces the exact defect it was written to remove.
A part-of-speech probe. Analyze the unsplit form with MWT disabled and allow
the split only when it tags VERB. Scored 29/39 on a discrimination set: it
suppresses giralo (NOUN), aprila (NOUN) and eccolo (ADJ), and it allows
dondolo, viola, scivola, disegna and cancello, which are ordinary
words. Note also that tokenize_pretokenized=True disables MWT expansion
outright, so a probe built that way answers “never splits” for every word: a
result that reads as clean and is a measurement that never ran.
The fix: validate the split Stanza proposes
The decision is made in the tokenizer postprocessor, which Stanza runs BEFORE
the MWT processor, so the split being judged does not exist yet. A minimal
tokenize,mwt probe pipeline (worker-state key {lang}:mwtprobe, loaded
lazily, one batched call per batch) previews it. The proposal is then checked
against four facts, and the split is allowed only if all four hold:
| # | Test | Source | Rejects |
|---|---|---|---|
| 0 | the split accounts for every character of the word | structural invariant | pentolone -> pento+lo (drops ne), hallo, tagliatelle, cavalla |
| 1 | every non-initial piece is an Italian enclitic | closed class of the language | gallina -> galli+na, disegna -> di+se+gna |
| 2 | the base is an attested verb, or ecco | Stanza’s own shipped lexicon | bello -> ib+lo, pello -> ip+lo, spaghetti, cielo |
| 3 | the whole form is not itself a dictionary word | Stanza’s own shipped lexicon | cavallo -> cava+lo, pentola, cavolo |
Each is load-bearing; test_each_of_the_four_tests_is_load_bearing fails if any
is removed. Test 0 allows the one regular departure from plain concatenation:
only the apocopated monosyllabic imperatives da'/di'/fa'/sta'/va' double the
following clitic (da + me + lo = dammelo), and gli never doubles.
Test 2 restores the elided e of an apocopated infinitive, since caricare +
lo surfaces as caricar + lo.
The two lexical tests read Stanza’s own Italian word list (about 50k surface
forms and 13k verb forms) rather than a table we maintain. That is deliberate:
the April 2026 MWT audit retired five hand-maintained per-language tables
precisely because they had drifted from what the models do. The list lives
behind a private Stanza attribute, so access is isolated in
extract_stanza_lexicon and fails loudly; test_italian_mwt_lexicon.py pins
its shape so a Stanza upgrade breaks CI instead of a corpus.
Measured before and after on the real corpus words, via the full pipeline:
| utterance | before | after |
|---|---|---|
attenzione | verb|attenzare-Inf-Ind-Imp-S2~pron|ne | noun|attenzione-Fem |
macchine | verb|maccare-Part-Past-P~pron|ne | noun|macchina-Fem-Plur |
gallina | galli + na | noun|gallina-Fem |
cavallo | cava + lo | noun|cavallo-Masc |
bello | ib + lo | adj|bello |
mucche | mu + cce + he | noun|mucca-Fem-Plur |
eccolo | ecco + lo | adv|ecco~pron|lo (PRESERVED) |
dammelo | da + me + lo | PRESERVED |
diglielo | di + glie + lo | PRESERVED |
giralo | gira + lo | PRESERVED |
Residual error, known shapes
Two under-generation shapes are known and accepted. Forms like pentolo that
reconstruct, carry real clitics and a plausible base, and are absent from the
lexicon are invisible to every test the rule has; catching them needs a real
morphological analyzer. And reflexive imperatives that Stanza’s lexicon lists
as words in their own right (svegliati, vestiti) are not split, which is
defensible: vestiti really is both “get dressed!” and “clothes”, and
context-free it is genuinely ambiguous.
Where the rule must guess, it prefers to under-split. Losing a split leaves a real word coarsely analyzed; a false split invents a verb that does not exist in Italian, which is the defect this page is about. The asymmetry decides it.
Residual RATES await the language-resolved corpus measurement.
Limitation 3: Italian MWT over-splits IN CONTEXT
Symptom. The same over-splitting as limitation 2, but surviving in full sentences where no single-word gate can reach it:
la stazione e molto grande . -> la = il + i (DET/DET)
secondo la mia opinione hai ragione . -> hai = ha + i (VERB/DET)
questa e la mozzarella . -> mozzarella = mozzar + la
mangiamo le tagliatelle stasera . -> tagliatelle = tagliate + le
prendi il pennarello rosso . -> pennarello = pennar + lo
Corpus impact: not separately quantified. The language-resolved audit
measures the single-word signature (limitation 2); committed in-context damage
has a different %mor shape and awaits its own signature scan. The mechanism
is certain: the five example sentences above are monolingual Italian and
reproduce through the real pipeline, asserted in
golden_morphotag_ita_multi_word_keeps_genuine_mwts, and parla -> par +
la is independently attested by the Defect 6 record in the Italian language
page.
Status: FIXED 2026-07-28, by the same rule as limitation 2.
Why one rule covers both
The rule validates the split Stanza proposes against facts about Italian, none
of which mention context, so it answers identically wherever the word appears.
Candidates are exactly the tokens Stanza itself marks (text, True) in the
tokenizer postprocessor, which is its documented “expand this” hint. Reading
that marker instead of guessing which words might be multi-word tokens is what
makes the pass cover every context AND cost less: a typical sentence marks two
tokens out of eleven.
Italian has exactly FOUR legitimate multi-word patterns, three of them closed:
| pattern | example | test |
|---|---|---|
| preposition + article | alla = a + la | surface is in the contracted paradigm AND the split is ADP + DET |
ecco + enclitic | eccolo = ecco + lo | reconstruction + real clitics + the presentative host |
| clitic cluster | glielo = glie + lo | reconstruction + every piece is an enclitic |
| verb + enclitic | giralo = gira + lo | reconstruction + real clitics + attested verb base + the whole form is not a dictionary word |
Anything matching none of the four is suppressed.
The preposition+article test needs BOTH halves. Validating only the
analysis (is this ADP + DET?) would accept ANY pair the tagger labels that way,
including mangles of non-paradigm surfaces (verified against raw Stanza:
English well -> In + l passes the structural test alone). Validating only
the surface would admit nothing useful, since la -> il + i has to be
rejected on its analysis. The contracted paradigm is a closed, centuries-stable
set, so requiring the surface to belong to it costs nothing and closes the
structural hole. Whether non-Italian material ever reaches the Italian pipeline
in production is a separate, unanswered question about language routing; the
rule is safe either way.
The probe must see the same context as the pipeline. This is the one thing
that does not generalize for free, and it cost a full RED-GREEN cycle to find:
hai in isolation is left whole, but in secondo la mia opinione hai ragione .
it becomes ha + i. A probe over isolated words therefore reports no split to
judge, and every in-context over-split passes through while the single-word
cases are still caught, which looks like a working fix. The probe runs over
whole utterances for exactly this reason.
Limitation 4: Italian MWT FAILS to split genuine imperatives (Defects 12/13)
Symptom. The mirror image. Stanza declines to expand a real imperative+enclitic and invents a verb for the whole surface:
aprilo -> verb|aprilare leggila -> verb|leggilare
aprila -> verb|aprilare finila -> verb|finilare
None of those verbs exist. Milder variants lose the clitic without inventing
anything (dimmi comes back as bare verb|dire) or leave the surface
unanalyzed (buttalo lemmatizes to buttalo).
Prior mitigation and its limit. IT_COMPOUND_IMPERATIVES in lang_it.rs
repairs eleven forms downstream: dammela, dammelo, prendilo, prendila, prendili, prendile, aprila, aprili, finila, aprilo, leggila. Verb+enclitic is
an open class, so an eleven-entry list cannot be complete by construction, and
forms outside it (dimmi, buttalo, mettilo, lascialo among those
verified through the pipeline) received no repair.
Status: FIXED 2026-07-28, by making the same rule bidirectional.
Suppression cannot help here: there is nothing to suppress. But Stanza’s
tokenizer hint protocol runs both ways, and its MWT processor OBEYS (text, True): hinted, aprilo yields apri + lo with the real lemma aprire.
So the policy now judges two kinds of candidate with the same four tests:
- tokens Stanza marked
(text, True), which may be over-splits; - tokens it left whole that a cheap lexical pre-filter (
could_be_enclisis) says might be enclisis: peel a maximal enclitic sequence off the end and ask whether the remainder can host it. Known dictionary words are excluded up front socavallois not probed on every batch only to be rejected later.
Every candidate is force-hinted in ONE probe pass. That is free for tokens
Stanza was already going to expand, since the hint is what it emitted itself,
and it is the only way to see a split it declines to make. The four tests then
decide, and the real pipeline is told (text, True) to force or (text, False)
to suppress. A candidate whose forced probe yields no split is left exactly as
Stanza left it, never asserted into an expansion with no proposed shape.
The constraint that makes forcing safe
Forcing is the dangerous direction: a wrong forced split fabricates structure
instead of merely losing it. The guard is a fact of Italian morphology. The
e-form clitics (me, te, se, ce, ve, glie) exist ONLY before another clitic:
mi becomes me in dammelo (me + lo), ci becomes ce in metticelo.
Italian has no word ending in a bare enclitic me/ce/ve, so a split whose
LAST piece is one of them is not a possible Italian word, and a candidate whose
peeled tail ends that way is rejected. Verified against raw Stanza that without
this guard, surfaces like English face (-> fa + ce) would force-split;
with it they cannot, regardless of whether such material ever reaches the
Italian pipeline in production. ne is deliberately excluded from the
restriction: it IS a real final clitic (dammene, scegline).
Verified through the real pipeline after the change (asserted in the golden
tests): dimmi -> dim + mi (dire/mi) and buttalo -> butta + lo
(buttare/lo) recover their analyses, while la, mozzarella and the nouns
stay whole and alla and dammelo keep splitting.
Consequence: the Rust mis-split allowlist is now largely redundant
IT_MIS_SPLIT_OVERRIDES in
crates/batchalign-transform/src/morphosyntax/lang_it.rs is a 23-entry
hand-curated table that repairs specific known mis-splits downstream, one row
added per production incident (Defects 6 and 7 in the Italian language page).
The rule above prevents that damage at the source, generally, so those Ranges
mostly no longer reach the reconciler.
Verified 2026-07-28: suppressing the split yields the correct analysis directly
for essentially every form in that table, including the ones it hardcodes,
parla -> VERB/parlare, coccole -> NOUN/coccola (with the correct plural
lemma), piccola -> ADJ/piccolo, cielo -> NOUN/cielo.
Not removed. The reconciler fires only on Ranges, so with no Range it simply no-ops and nothing conflicts; deleting the table is a separate change needing per-entry verification. Recorded here so the redundancy is known rather than rediscovered.
Open work
- Re-generate affected corpus files, scoped by the language-resolved
audit (338 files with Italian single-word verb+enclitic
%mor). Re-running morphotag BEFORE fixing the generator reproduces the defect (verified 2026-07-28), so regeneration follows the fix, and outputs are diffed before anything is written into the data repos. - Extend the audit with an in-context damage signature for limitation 3
(e.g.
det|il~det|iland verb+enclitic items on multi-word utterances), so in-context committed damage is enumerated the same way. - Characterize what the Italian pipeline actually receives in bilingual files, from the language-resolved data plus the L2 routing code. Whether any foreign material reaches the Italian model in production is currently unknown; nothing on this page assumes an answer.
- Revisit the residual under-splits only if a better lexical source turns up; catching lexicon-invisible forms needs a real morphological analyzer, not another heuristic layered on this one.
Related
batchalign/inference/morphosyntax.py:UD_RELATIONS,UD_DEPREL_ALIASES,_repaired_relation,RepairedSentence,batch_infer_morphosyntax.batchalign/inference/_tokenizer_realign.py: the MWT-hint postprocessor.batchalign/worker/_stanza_loading.py: per-language pipeline construction; note Italian takes thetokenize_postprocessorbranch, NOT thetokenize_pretokenizedone, which is why Stanza re-tokenizes CHAT words at all.- chatter error E761 (
%grarelation head not a UD relation): the reading-side rule that surfaced all of this.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Audio-Task Cache
Status: Current Last updated: 2026-09-16 03:36 EDT
Batchalign caches audio-task results (forced alignment, UTR ASR, raw Rev transcript evidence, dedicated transcribe speaker evidence, and media conversion). It does not cache text-NLP results (morphosyntax, utterance segmentation, translation). All caching is managed by the Rust server, Python workers are cache-unaware.
For the CHAT-core validation cache used by chatter validate, see
validation cache.
Why no text-NLP cache
A production-scale benchmark during development showed that the text-NLP cache was net negative:
| Metric | Value |
|---|---|
| Cache hit rate on a 15,748-file corpus rerun | 6-16% |
| SQLite lookup time per 25-file window | 2,500 ms |
| Inference time saved by hits | ~100 ms |
| Net effect | Cache ≈ 25× slower than re-inference |
With warm Stanza workers, batched text inference runs at ~4 ms /
sentence. Cache lookup against a multi-GB SQLite beat that by more
than an order of magnitude. The arithmetic rules out every hit-rate
scenario, for cache to win you’d need
lookup < hit_rate × inference_time, i.e. hit rate > 2,500% at the
observed costs. Not achievable.
Additional reasons:
- Most utterances are unique across files. Only short common phrases (“thank you”, “okay”) repeat. The 6-16% observed hit rate reflects this.
- Staleness is always a problem. Model upgrades and pipeline changes invalidate entries; stale entries that pass the version check but fail injection validation waste time and produce confusing warnings.
- The cache grew without bounds. No eviction, no vacuum, no WAL checkpointing. After a corpus rerun the SQLite database was multi-GB and every query was slow.
Text-NLP caching is absent end-to-end: no CLI flag, no cache key computation, no cache read, no cache write, no code paths to audit. Every text-NLP request flows straight through to the Python worker.
Difference from batchalign2
Batchalign2 still has a morphotag cache (Python-side, per-utterance). The cache is not present in batchalign3, the net-negative benchmark above is why. If you are comparing the two tools, expect batchalign2 to be faster on exact-repeat reruns of identical input and batchalign3 to be faster in every other scenario because of its warm-worker batching. The shape of the workloads TalkBank actually runs (corpus validation, model upgrades, incremental edits) puts every real scenario in the second bucket.
Why audio caching helps
- Audio inference is expensive. Whisper ASR takes 30-120 seconds per file. FA takes 10-60 seconds. Caching saves minutes, not milliseconds.
- Audio rarely changes. FA and UTR use
AudioIdentity(path + mtime + size). Speaker raw evidence uses a full BLAKE3 byte digest so copies and renames share results while changed source bytes invalidate them. - Hit rates are high for repeated alignment. Re-running
alignon a corpus where only a few files changed gives near-100% hit rate for unchanged audio.
Forced-alignment evidence and projection
Forced alignment has two cache layers for each semantic group key. They serve different purposes and are deliberately not interchangeable:
forced_alignment_raw_evidencestores a versioned envelope around an admitted direct worker-protocol response. The envelope owns the requested engine, exact selected-worker version, semantic group key, expected word cardinality, and response-proven effective engine.forced_alignmentstores the locally projectedWordTimingvector in a second versioned envelope carrying the same request facts. It is a faster compatibility fallback, not the source of truth when raw evidence is available. Historical bare timing vectors are refused because they cannot prove which direct or fallback model produced them.
Normal reads prefer raw evidence and run it through the current Rust timing
projection. This makes changes to containment, token/word reconciliation,
score handling, or evidence summaries testable without repeating model
inference. If raw evidence is absent or refused, BA3 may admit an exactly
versioned derived envelope instead. Only a miss at both layers can become an
inference request, and --require-media-cache prevents that miss from
obtaining dispatch authority.
A Wave2Vec request that succeeds only through the Whisper fallback is usable for the current run and emits its fallback trace, but neither its raw response nor its derived timings are cached. The Wave request namespace does not prove the effective Whisper model version. The persistence typestate therefore accepts direct evidence only; a later run recomputes the fallback rather than silently replaying evidence with an incomplete identity.
stateDiagram-v2
[*] --> CurrentGroup
CurrentGroup --> WorReuse: complete corroborated %wor
CurrentGroup --> RawCandidate: otherwise
RawCandidate --> RawReplay: admitted
RawCandidate --> DerivedCandidate: absent or refused
DerivedCandidate --> DerivedReuse: admitted
DerivedCandidate --> CacheMiss: absent or refused
CacheMiss --> AuthorizedInference: UseCache / SkipCache
CacheMiss --> Refused: RequireCache
AuthorizedInference --> DirectEvidence: requested engine succeeds
AuthorizedInference --> LiveFallback: fallback engine succeeds
DirectEvidence --> RawCommitted: replayable raw evidence
DirectEvidence --> DerivedCommitted: versioned current projection
LiveFallback --> CurrentChatLogic: usable now, deliberately not cached
WorReuse --> CurrentChatLogic
RawReplay --> CurrentChatLogic
DerivedReuse --> CurrentChatLogic
DerivedCommitted --> CurrentChatLogic
Refused --> [*]
CurrentChatLogic --> [*]
Tiered Cache Architecture
crates/batchalign/src/cache/ stores keyed audio-task results so
that re-processing a corpus skips utterances whose results are
already known.
CacheBackendtrait: storage contract (get, put, delete; both single and batched).TieredCacheBackend: production implementation; in-memory moka hot layer wrapping a persistentSqliteBackendcold layer.SqliteBackend: persistent storage via SQLite WAL mode for concurrent read/write safety.UtteranceCache: public entry point, wrapsBox<dyn CacheBackend>. Its reads and writes are typed: each takes aCacheTask<N>constant fromcache::tasks, which fixes the namespace typeNthat task’s rows use, and a value of that sealedCacheNamespacetype. A task cannot be handed another task’s namespace, and the constants can only be made in the cache module.UtteranceCachedeliberately does not implement the string-keyedCacheBackendtrait itself, since that would be a route around the pairing.
The typed tasks:
| Task constant | Namespace type | Namespace bytes |
|---|---|---|
FORCED_ALIGNMENT, FORCED_ALIGNMENT_RAW_EVIDENCE | FaCacheNamespace | the FA engine name the worker reported, byte for byte |
UTR_ASR | UtrAsrCacheNamespace | utr-asr-v1:<UTR engine wire name>:<composition> then one |<role>=<id>@<revision> per model |
REV_ASR_EVIDENCE | RevAsrModelRevision | the Rev provider revision |
SPEAKER_DIARIZATION_RAW_EVIDENCE | SpeakerEvidenceModelRevision | the speaker model revision |
SPEAKER_DIARIZATION_SEGMENTS | SpeakerNormalizationRevision | the local normalization revision |
The layers:
| Layer | Implementation | Capacity | Eviction |
|---|---|---|---|
| Hot | moka::future::Cache | 10,000 entries (~5-20 MB) | 24h time-to-idle |
| Cold | SqliteBackend (WAL, 5-connection pool) | Unbounded (disk) | None (manual or --override-media-cache) |
The hot layer absorbs repeated lookups and reduces SQLite round-trips
under concurrent workloads (parallel FA or transcribe processing
multiple files via JoinSet + Semaphore).
flowchart TD
subgraph "Read path"
r_start(["get(key, task, namespace)"])
r_moka{"moka\nhot lookup"}
r_verify{"task + namespace\nmatch HotEntry?"}
r_cold["SqliteBackend.get()"]
r_promote["Promote: insert\ninto moka hot"]
r_hit(["Return cached data"])
r_miss(["Return None"])
r_start --> r_moka
r_moka -->|hit| r_verify
r_moka -->|miss| r_cold
r_verify -->|match| r_hit
r_verify -->|mismatch| r_cold
r_cold -->|hit| r_promote --> r_hit
r_cold -->|miss| r_miss
end
subgraph "Write path (write-through)"
w_start(["put(key, task, namespace, data)"])
w_sqlite["SqliteBackend.put()\n(authoritative)"]
w_moka["moka.insert()\n(hot copy)"]
w_start --> w_sqlite --> w_moka
end
subgraph "Delete path"
d_start(["delete_batch(keys, task)"])
d_moka["moka.invalidate()\n(hot first)"]
d_sqlite["SqliteBackend.delete_batch()"]
d_start --> d_moka --> d_sqlite
end
- Read path: check moka → on hit, verify task + namespace match → on mismatch or miss, fall through to SQLite → promote cold hits to moka.
- Write path: write to SQLite first (authoritative), then insert into moka. Write-through, not write-back, no data loss on crash.
- Delete path: invalidate moka first, then delete from SQLite.
The moka key is the bare BLAKE3 hash string. Task and namespace are
stored inside the hot entry and checked on read, matching the SQLite
schema where key is the primary key.
The backend is reached through a sealed namespace API:
get<N: CacheNamespace> and put<N: CacheNamespace> in
crates/batchalign/src/cache/mod.rs. Each task constant is a
CacheTask<N> paired with the namespace type its rows are written under
(forced alignment with FaCacheNamespace, UTR ASR with
UtrAsrCacheNamespace, and so on), so passing one task’s identity to
another task does not compile.
Database location
| Platform | Path |
|---|---|
| macOS | ~/Library/Caches/batchalign3/cache.db |
| Linux | ~/.cache/batchalign3/cache.db |
Cache Keys
Keys are BLAKE3 content-addressed hashes (64-character hex
strings), computed by the CacheKey newtype in
crates/batchalign/src/chat_ops/cache_key.rs. There is no constructor from
arbitrary strings, keys can only be created through the
task-specific cache_key() functions, which hash input payloads
internally.
AudioIdentity (FA and UTR)
The AudioIdentity newtype (crates/batchalign/src/chat_ops/fa/mod.rs)
identifies an audio file for cache keying. It is computed from
filesystem metadata only, not from a content hash of the audio
data.
Format: "{resolved_path}|{mtime_secs}|{file_size}"
Construction in compute_audio_identity()
(runner/util/media.rs):
tokio::fs::metadata(audio_path)to get file metadata.- Extract
meta.len()(file size in bytes). - Extract
meta.modified()(mtime seconds since Unix epoch). - Build
AudioIdentity::from_metadata(path, mtime_secs, size).
Implications:
- Renaming or moving a file changes the identity because the resolved path is part of the key.
- Re-encoding audio changes the identity because re-encoding changes both mtime and file size.
- Touching a file (updating mtime without changing content) changes the identity, causing a cache miss.
- Copying a file preserves content but changes mtime, so the copy gets a different identity.
- No content hashing is performed: deliberate performance tradeoff.
This identity is not used for raw Rev or paid speaker evidence.
SpeakerAudioSourceDigest
transcribe --diarization enabled streams the entire inference media source
through BLAKE3 in 1 MiB chunks. The digest contains no path or mtime. Its key
also contains the canonical preparation-recipe revision
(mono-16khz-f32le-v1), because the worker receives mono 16 kHz float32 PCM
rather than the source container bytes directly.
This design makes copies and renames hit without paying the cost of running ffmpeg before every cache lookup. Different encodings of acoustically identical media intentionally miss. If preparation semantics change, bump the recipe revision.
RevProviderMediaDigest
Rev evidence hashes the complete inference-media file that BA3 would upload to Rev. The semantic key adds requested language, expected speaker count, Rev request-policy revision, provider/model alias, and evidence schema. No path, mtime, API credential, temporary upload URL, or Rev job ID participates.
CacheTaskName
Audio tasks that use the cache:
| Variant | Wire string | Orchestrator |
|---|---|---|
ForcedAlignment | forced_alignment | fa/ |
UtrAsr | utr_asr | runner/dispatch/fa_pipeline.rs (UTR pre-pass) |
SpeakerDiarizationRawEvidence | speaker_diarization_raw_evidence | pipeline/transcribe.rs |
SpeakerDiarizationSegments | speaker_diarization_segments | pipeline/transcribe.rs |
RevAsrEvidence | rev_asr_evidence | pipeline/transcribe.rs + revai/evidence_cache.rs |
The enum also includes Morphosyntax, UtteranceSegmentation, and
Translation variants, they are kept as named constants so
--override-media-cache-tasks morphosyntax continues to parse
cleanly, but no code writes or reads entries under those task names.
Per-task key composition
| Task | Key components |
|---|---|
| Forced alignment | audio identity + time window + words + gap-healing policy + engine |
| UTR ASR (full-file) | "utr_asr_v2" + UTR engine + audio identity + lang |
| UTR ASR (segment) | "utr_asr_segment_v2" + UTR engine + audio identity + start_ms + end_ms + lang |
| Raw dedicated-speaker evidence | schema + source-byte digest + preparation revision + backend + expected speakers + model revision |
| Derived speaker segments | raw-evidence fingerprint + normalization revision |
| Raw Rev ASR evidence | schema + provider-media digest + requested language + expected speakers + request-policy revision + model revision |
Two-stage dedicated-speaker cache
Dedicated diarization deliberately separates paid/model inference from local normalization:
semantic request -> raw evidence key -> backend inference (only on raw miss)
raw evidence fingerprint + normalizer revision -> derived segment key
SpeakerInferenceAuthorization can only be constructed by consuming a proven
raw cache miss. A derived miss cannot authorize inference. It must first look
for raw evidence and, when present, run the Rust normalizer and commit a new
derived envelope. The worker result is a tagged evidence union, so a request
for one backend cannot commit evidence claiming another backend’s provenance.
For pyannoteAI, the raw envelope contains the completed provider job ID, full provider output object, and optional warning. The derived envelope contains ordered millisecond speaker segments plus the raw fingerprint and normalization revision. Both envelopes fail closed on corruption; neither corruption path silently becomes a paid miss.
Invalidation Matrix
Which user actions cause cache misses (force re-inference) per task:
| Action | FA | UTR full | UTR segment | Speaker evidence |
|---|---|---|---|---|
| Edit transcript words | Miss | Hit | Hit | Hit |
| Change language code | Miss | Miss | Miss | Hit |
| Re-record audio | Miss | Miss | Miss | Miss |
| Rename/copy identical audio | Miss | Miss | Miss | Hit |
| Change FA engine | Miss | Hit | Hit | Hit |
| Change ASR engine | Hit | Hit* | Hit* | Hit |
| Change speaker backend/count | Hit | Hit | Hit | Miss |
| Upgrade identified model version | Miss | Miss | Miss | Miss |
Use --override-media-cache | Skip | Skip | Skip | Refresh |
Raw Rev evidence invalidation is independent of those four columns:
| Action | Raw Rev evidence |
|---|---|
| Edit transcript words | Hit |
| Change inference-media bytes | Miss |
| Rename/copy byte-identical inference media | Hit |
| Change requested language or expected speakers | Miss |
| Change Rev request-policy/model revision | Miss |
Use --override-media-cache | Refresh |
* UTR cache keys include the UTR engine’s wire name, and every UTR ASR entry is stored under a namespace naming that engine AND the models it ran, which must match on read. Changing any pinned model lands in a different namespace, so entries produced by other weights miss instead of being reused. A plan whose models are not all pinned gets no namespace at all and is neither read nor written: an unpinned model cannot promise a stored row came from the same weights, and a cache hit is only sound when it can.
Key insight: UTR cache keys are audio-only (no transcript text), so editing the transcript does not invalidate ASR results, correct because UTR re-derives timing from the same audio. FA cache keys include transcript text, so only groups whose words changed need to re-run forced alignment.
Cache Namespace Scoping
Each cache entry is scoped to its task’s namespace, and a lookup must
present the matching namespace type to reach it. The two namespaces are not
the same kind of thing. Forced alignment is scoped by the engine version its
worker reported (for example "wave2vec-fa-mms-{torchaudio_version}"), which
is known only after a worker answers. UTR ASR is scoped by the pinned plan
identity, which is known before dispatch without loading a model at all.
Upgrading a model changes the namespace, so stale entries become unreachable
by construction rather than being fetched and then rejected on a version
comparison.
Engine identities are reported by Python workers through the capabilities
IPC response, one entry per advertised task: a name, or null when the worker
supports the task but has not named the engine. The pool admits the report
once (WorkerPool::record_capabilities, into WorkerEngineReports in
crates/batchalign/src/engine_reports.rs), and forced alignment, the one stage
that namespaces cache rows by a reported engine, reads its identity through a
typed constructor that refuses a null report. There is no pipeline-wide engine
version: PipelineServices carries only the worker pool and the cache.
Forced alignment caches under FaCacheNamespace, exactly the string the FA
worker reported, carried beside the shared services in FaServices. The same
typed value travels through cache lookups and writes, cached-group admission
(FaCacheGroupAdmission), raw evidence admission and replay
(FaRawEvidence::admit_requested, ReplayableFaRawEvidence::decode), derived
timing admission, and the result (FaResult::cache_namespace), so none of
them compares against a different string. UTR ASR caches under
UtrAsrCacheNamespace (cache/mod.rs), the only namespace type the UTR_ASR
task accepts. Its one constructor, for_pinned_plan, takes the engine together
with the composition the plan pinned and returns a UtrAsrCacheEligibility:
either Pinned, carrying the namespace, or Floating, carrying nothing. The
namespace bytes are the engine’s wire name followed by the models, for example
utr-asr-v1:whisper_utr:whisper|asr=openai/whisper-large-v3@06f233fe06e710322aca913c1bc4249a0d71fce1
utr-asr-v1:rev_utr:rev|provider=revai@asynchronous-transcript-v1
The engine name alone said only WHICH engine produced a row, never which weights it produced it with, so a row written before a checkpoint moved was indistinguishable from one written after. Naming the models makes that distinction structural.
Floating is a closed state rather than an absent namespace, so both recovery
paths have to say what they do when a plan cannot be cached: the lookup reports
the same miss a deliberately skipped cache produces, and the store is a no-op.
Neither reaches for a placeholder namespace, which would silently pool
different weights under one key.
The utr-asr-v1: prefix is deliberately unchanged. W1 already moved this
namespace once in this build, and folding the model identity into that same
prefix keeps the release at ONE recompute rather than two. Older entries, both
those from builds that keyed UTR ASR under the FA engine’s version and those
keyed by engine name alone, do not match the new bytes, so they miss and the
next run recomputes and stores them. That is also why no legacy reader exists
or is needed for cached AsrResponse rows: a namespace move makes unreadable
rows unreachable by construction, rather than leaving old shapes to be parsed
by a compatibility path that must then be kept true forever.
Speaker evidence deliberately does not accept that generic ASR/FA engine
version. SpeakerEvidenceModelRevision is a distinct newtype with private
construction from SpeakerBackendV2; this prevents an ASR version from being
used accidentally. The cloud revision is currently the provider-visible
pyannote-ai:precision-2 alias. pyannoteAI does not return an immutable
backend build hash, so controlled experiments should use a forced refresh if
the provider may have changed the implementation behind that alias. Local
identifiers include the configured model family and BA3 package version; their
external model references currently float too.
Rev evidence likewise uses RevAsrModelRevision, not the Python worker ASR
version. The current provider-visible identity is
revai:asynchronous-transcript-v1; Rev does not expose an immutable acoustic
model build hash through this API, so controlled comparisons may require an
explicit refresh.
Cache Workflow in Orchestrators
FA and UTR orchestrators follow this batch-oriented pattern:
- Collect payloads (FA groups, UTR segments) from the parsed CHAT AST.
- Compute cache keys (BLAKE3 hash of payload content).
- Batch lookup: hit entries are injected directly.
- Infer misses: send uncached payloads to Python workers.
- Inject results into the AST.
- Batch put: persist new results for future reuse.
Paid Evidence Typestate
Rev and speaker evidence use a stricter state machine because a miss can
authorize a paid external call. Both share InferenceLease, a keyed
process-local single-flight guard, while retaining task-specific request,
miss, authorization, validation, and evidence types.
The speaker path is:
flowchart LR
request[SpeakerEvidenceRequest] --> lease[Acquire per-key inference lease]
lease --> lookup{Validated durable lookup}
lookup -->|hit| replay[ValidatedSpeakerEvidence]
lookup -->|missing| miss[SpeakerEvidenceMiss]
lookup -->|corrupt/invalid| fail[Fail closed; no service call]
miss --> auth[SpeakerInferenceAuthorization]
auth --> split[Consume authorization]
split --> run[AuthorizedSpeakerEvidenceRun]
split --> permit[SpeakerEvidenceCommitPermit]
run -->|consumed| service[SpeakerEvidenceInference]
service --> validate[Validate returned segments]
validate --> permit
permit --> commit[Required durable commit]
commit --> fresh[ValidatedSpeakerEvidence]
The fields of SpeakerEvidenceMiss, SpeakerInferenceAuthorization,
AuthorizedSpeakerEvidenceRun, SpeakerEvidenceCommitPermit, and
SpeakerEvidenceModelRevision are private. Consuming an authorization splits
it into exactly one run capability and one commit permit. The raw V2 worker
trait accepts the run by value, so an adapter cannot use one cache miss for two
paid calls. Raw V2 worker inference is also private behind
SpeakerWorkerInference. Consequently, the production transcribe pipeline
cannot construct permission to call the service without consuming a real miss
(or an explicit forced-refresh miss), and it cannot reuse that permission.
The run does not borrow a separately assembled worker request. It owns the
source path and digest, backend, and expected-speaker count copied from the
cache request. After authorization, the resolver rereads and verifies the
source bytes. Only the resulting VerifiedSpeakerEvidenceRun can cross the
worker trait. Rust transcodes those owned bytes through a private temporary
source, so mutation or replacement of the original path cannot make inference
consume bytes different from those used for the cache decision.
The process-local per-key lease spans lookup, inference, validation, and commit. Concurrent identical requests cannot both observe a miss: followers wait, then check the durable cache again. The persistent SQLite entry handles later jobs and server restarts.
resolve_speaker_evidence() is the one production decision path and accepts a
SpeakerEvidenceInference implementation. Tests inject a call-counting fake
through this same function, so assertions measure crossings of the billable
boundary rather than merely testing SQLite in isolation.
Stored evidence is a versioned JSON envelope containing the request
fingerprint and normalized SpeakerSegmentV2 list. Reads validate the schema,
fingerprint, nonempty labels, non-inverted intervals, and nondecreasing starts.
Corruption is an error, never a miss. A successful service response must be
validated and durably committed before the pipeline continues; a write error
fails the file instead of silently losing reusable evidence.
The cache currently preserves the exact normalized turns consumed by speaker projection, not the provider’s full raw JSON response. Retaining immutable raw provider evidence and richer provenance is a separate future architecture step.
Raw Rev transcript evidence
RevAsrEvidenceRequest keys the provider-visible media and request semantics.
RevAsrEvidenceMiss is the only constructor route to
RevAsrInferenceAuthorization; RevAsrService requires that authorization
before it can perform language identification, submission, or polling.
NonRevAsrBackend is a smaller sum accepted by generic ASR inference, so the
pipeline cannot route RustRevAi around the evidence resolver.
The durable envelope stores CompletedRevAsrEvidence: resolved ISO-639-3
language plus the provider-shaped Transcript monologues and elements. It is
intentionally earlier than transcript_to_asr_response(). Changes to BA3 token
projection or ASR post-processing can therefore replay raw Rev evidence
without another provider call. Validation rejects negative speaker indices,
non-finite/negative timings, reversed intervals, and out-of-range confidence.
Corruption and commit failures are fatal, not misses.
flowchart LR
R["RevAsrEvidenceRequest"] --> L["Acquire per-key inference lease"]
L --> K{"Validated durable lookup"}
K -->|"hit"| C["CompletedRevAsrEvidence"]
K -->|"missing"| M["RevAsrEvidenceMiss"]
K -->|"corrupt"| F["Fail closed"]
M --> A["RevAsrInferenceAuthorization"]
A --> P["Language ID / submit / poll"]
P --> V{"Validate provider evidence"}
V -->|"invalid"| F
V -->|"valid"| D["Required durable commit"]
D -->|"commit failure"| F
D -->|"committed"| C
C --> O["Deterministic local projection"]
O --> T["ASR or UTR timed words"]
Rev-backed align UTR uses the same resolver before projecting timed words.
Its older normalized utr_asr entry remains a faster derived-result cache, but
it is no longer the only protection against another provider request. A
malformed normalized entry or storage read error fails closed; if that derived
entry is absent after an algorithm change, the raw Rev envelope can be replayed
locally and committed in the new shape.
The old runner-wide Rev pre-submission path is currently disabled for transcribe, benchmark, and align. It submitted jobs before cache lookup, so it could not guarantee cost avoidance and its untyped optional job IDs bypassed the miss authorization. Cold provider calls now fan out through normal per-file concurrency. A future cache-aware parallel preflight may recover the old wider submission window only if its plan variants carry validated hits or typed miss authorizations.
Self-Correcting Cache Purges
FA/UTR post-serialization validation can delete the cache entries that
produced invalid output. Rev and speaker envelopes instead validate at their
evidence boundaries; their keys are not yet retained through final CHAT
serialization for automatic downstream purge. Validation failures also
trigger bug reports to ~/.batchalign3/bug-reports/.
Override
--override-media-cache bypasses audio cache lookups, forcing fresh
inference for every payload. Use this when validating behavior
changes or after model upgrades. For a developer-facing guide on when
--override-media-cache is actually needed after code changes, see
the
Cache Override Guide.
UtteranceCache::noop() creates a backend that always misses and
silently discards puts. Available for testing.
UTR ASR Caching
UTR (Utterance Timing Recovery) ASR results are cached, making repeat alignment runs on the same audio instant. Rev-backed UTR additionally retains the earlier provider evidence so projection experiments do not depend on the derived cache shape.
flowchart TD
start([UTR pre-pass]) --> count{untimed\nutterances?}
count -->|No| skip([Skip UTR])
count -->|Yes| ratio{untimed ratio\n< 50% AND\naudio > 60s?}
ratio -->|Yes: partial mode| windows[find_untimed_windows\nIdentify time regions]
ratio -->|No: full mode| full_cache{Full-file\ncache lookup}
windows --> seg_loop["For each window:"]
seg_loop --> seg_cache{Segment\ncache lookup}
seg_cache -->|Hit| seg_use[Use cached ASR]
seg_cache -->|Miss| seg_extract[extract_audio_segment\nffmpeg -ss/-to]
seg_extract --> seg_asr[infer_asr on segment]
seg_asr --> seg_store[Cache segment result]
seg_store --> seg_use
seg_use --> offset[Offset tokens by\nwindow start_ms]
offset --> seg_loop
full_cache -->|Hit| full_use[Use cached ASR]
full_cache -->|Miss, non-Rev| full_asr[infer_asr on full audio]
full_cache -->|Miss, Rev| rev_raw{Validated raw Rev\nevidence?}
rev_raw -->|Hit| rev_project[Project retained timed words]
rev_raw -->|Typed miss| rev_call[Authorized Rev request]
rev_call --> rev_commit[Validate + required durable commit]
rev_commit --> rev_project
rev_project --> full_store
full_asr --> full_store[Cache full result]
full_store --> full_use
full_use --> inject[inject_utr_timing\nDP-align → set utterance bullets]
offset -->|all windows done| inject
inject --> done([Continue to FA])
- Full-file mode: caches the entire
AsrResponsewith keyBLAKE3("utr_asr_v2|{utr_engine}|{audio_identity}|{lang}"). Default for mostly-untimed files or short audio. - Partial-window mode: activates when >50% of utterances are
timed and the audio exceeds 60 seconds. Each untimed window is
extracted via ffmpeg and cached independently with key
BLAKE3("utr_asr_segment_v2|{utr_engine}|{audio_identity}|{start_ms}|{end_ms}|{lang}"). Avoids processing already-timed regions on the first run. After the first run, the full-file cache makes the distinction moot.
Both modes respect CachePolicy: --override-media-cache skips lookups but
still stores results for future use. Concurrent identical forced refreshes in
one process share the first newly committed result instead of issuing
sequential duplicate service calls.
What Is NOT Cached
- Morphosyntax, utterance segmentation, translation: text-NLP tasks, removed from the cache for the benchmark reasons above.
- Standalone
diarizeoutput: the first speaker-evidence slice is wired to dedicated diarization insidetranscribe; standalone output is not yet replayed from this cache. - Non-Rev ordinary
transcribeASR: raw evidence caching currently covers the Rust-owned Rev boundary; other ASR engines still infer normally. - Coreference: document-level (not per-utterance); results depend on full document context.
- OpenSMILE features: fast enough to recompute.
- AVQI scores: fast enough to recompute.
Media Conversion Cache
MP4 video files are converted to WAV for alignment and cached at
~/.batchalign3/media_cache/ keyed by content fingerprint. MP3 and
WAV files are used directly (no conversion). Media resolution is
handled by crates/batchalign/src/media.rs.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Validation Cache
Status: Current Last updated: 2026-05-19 16:54 EDT
The CHAT-core validation cache, used by chatter validate. Distinct from the
audio-task cache used by Batchalign
for FA / UTR ASR / media conversion: this cache stores
parse + validate results keyed by file path + options.
crates/talkbank-transform/src/unified_cache/.
Architecture
flowchart TD
req["Validation request\n(path + options)"]
key["Cache key\n(path + check_alignment flag)"]
db["SQLite WAL\n~/.cache/talkbank-chat/\ntalkbank-cache.db"]
hit["Cache hit\n→ return stored result"]
miss["Cache miss\n→ parse + validate + store"]
req --> key --> db
db -->|found + version match| hit
db -->|not found or stale| miss
miss --> db
Configuration
| Config | Value | Why |
|---|---|---|
| Backend | SQLite via sqlx | Concurrent reads (WAL), atomic writes, zero-config |
| Pool size | 16 connections | Matches validation worker count |
mmap | 256 MB | Fast random access for 95k+ entries |
| Invalidation | Version field + 30-day TTL | Schema changes auto-invalidate; stale entries pruned |
| Bridge | Embedded single-threaded tokio runtime | Sync workers call rt.block_on() for async SQLite |
Schema
file_cache table (see
crates/talkbank-transform/migrations/20260101000000_initial.sql):
| Column | Role |
|---|---|
path_hash | BLAKE3 hash of the resolved path (part of the lookup key) |
file_path | Resolved file path, indexed for path-based maintenance ops |
content_hash | Hash of the file content; mismatch invalidates the entry |
version | Schema/code version, mismatch invalidates the entry |
cached_at | Insertion timestamp |
check_alignment | Whether alignment validation was requested |
is_valid | Cached validation outcome (0/1) |
roundtrip_tested | Whether roundtrip equivalence was checked |
roundtrip_passed | Roundtrip result when tested |
parser_kind | Parser backend (tree-sitter or re2c) |
The lookup key is the compound unique index
(path_hash, version, check_alignment, parser_kind); file_path is a
secondary index used by maintenance operations (orphan pruning, etc.).
Database location
| Platform | Path |
|---|---|
| macOS | ~/Library/Caches/talkbank-chat/talkbank-cache.db |
| Linux | ~/.cache/talkbank-chat/talkbank-cache.db |
| Windows | %LocalAppData%\talkbank-chat\talkbank-cache.db |
Invalidation
- Schema changes: bump the
versionfield; old entries become unreachable. - Time-based: entries older than 30 days are pruned.
- Manual: pass
--forceto bypass cache lookups for a particular validation run.
Per project policy, do not delete the cache directory without
explicit request, see the cache-policy section of
talkbank-tools/CLAUDE.md.
See also
- Audio-task cache, Batchalign’s per-utterance cache for FA / UTR ASR / media conversion.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Cache Policy Guide
Status: Current Last updated: 2026-09-15 19:40 EDT
When fixing a bug or changing behavior, ask two questions: does the run need
fresh inference (--override-media-cache), or must it prove that reusable
evidence is sufficient (--require-media-cache)? This guide provides the
mental model and decision matrix.
For what’s cached and how keys work, see Audio-Task Cache. This page is the complement: what sits inside vs outside the cache boundary, and what that means for deploying fixes.
Core Mental Model
Every cached command has a cache boundary: a line between what’s stored in the cache (raw ML output) and what’s computed fresh on every run (Rust post-processing). The rule is simple:
- Change inside the boundary (the cached value itself is wrong) →
--override-media-cacheneeded - Change outside the boundary (post-processing that runs after retrieval) → fix applies automatically, no override needed
Per-Command Cache Boundaries
Morphosyntax
Not cached. Stanza inference, retokenization, validation, and injection run on every invocation.
Utterance Segmentation (utseg)
Not cached. Model inference and boundary application run on every invocation.
Translation
Not cached. Provider/model inference, post-processing, and %xtra injection
run on every invocation.
Forced Alignment (FA)
| Stage | Inside/Outside | Code |
|---|---|---|
| Tier 1: check reusable %wor timing | Outside (bypasses cache entirely) | fa/mod.rs |
| Group utterances by time windows | Outside (pre-cache) | fa/mod.rs |
| Word extraction per group | Outside (pre-cache) | fa/mod.rs |
Cache key: BLAKE3(audio_identity | start_ms | end_ms | text | healing_flag | engine) | Boundary | chat_ops/fa/mod.rs |
Whisper/Wave2Vec inference → Vec<Option<WordTiming>> | Inside | Python fa.py |
postprocess_utterance_timings() | Outside | fa/postprocess.rs |
- WordGapHealing::Heal: backward end-time propagation, bounded by plausibility caps | Outside | fa/postprocess.rs |
- WordGapHealing::PreserveMeasured (--pauses): leave each word’s end alone | Outside | fa/postprocess.rs |
| - Clamp to utterance bullet range | Outside | fa/postprocess.rs |
update_utterance_bullet() (overwrite UTR hints; union with authoritative) | Outside | fa/orchestrate.rs |
| %wor tier generation | Outside | fa/orchestrate.rs |
| E362/E704 enforcement | Outside | validation layer |
UTR (Utterance Timing Recovery)
| Stage | Inside/Outside | Code |
|---|---|---|
Full-file key: BLAKE3(utr_asr_v2 | UTR engine | audio_identity | lang) | Boundary | chat_ops/fa/utr.rs |
Segment key: BLAKE3(utr_asr_segment_v2 | UTR engine | audio_identity | start_ms | end_ms | lang) | Boundary | chat_ops/fa/utr.rs |
Namespace: utr-asr-v1:<UTR engine>:<composition> then one |<role>=<id>@<revision> per model (UtrAsrCacheNamespace, the only namespace type cache::tasks::UTR_ASR accepts), the UTR engine’s own and its pinned models, never the FA engine’s. A plan that is not fully pinned is Floating and is neither read nor written | Boundary | cache/mod.rs |
ASR inference → Vec<AsrTimingToken> | Inside | Python asr.py |
| Global Hirschberg DP alignment (words ↔ ASR tokens) | Outside | runner/dispatch/utr.rs |
| Utterance bullet injection | Outside | runner/dispatch/utr.rs |
Coref
Not cached. Document-level scope requires full context.
Transcribe
Not cached at file level. Raw Rev.AI transcript evidence and dedicated speaker evidence are cached before local projection; speaker evidence has separate raw and normalized layers. Other ordinary ASR output is not cached. ASR post-processing (compound merging, number expansion, Cantonese normalization, retokenization) runs fresh every time.
--require-media-cache fails before a raw Rev or speaker miss can become an
inference authorization. A derived-speaker miss may still be rebuilt from a
validated raw hit. FA requires every unresolved group to be reusable or cached;
its worker batch accepts a typed authorization that required-cache misses
cannot construct. Rev-backed UTR may rebuild normalized UTR evidence from a
raw Rev hit; the raw resolver still refuses a provider call on a miss.
An align run resolves FA and UTR independently. FaParams carries only the
forced_alignment policy, while FaDispatchPlan::utr_cache_policy carries the
utr_asr policy into both the initial UTR pass and retry fallback. This split
is load-bearing: a selective UTR refresh must not refresh FA, and a selective
FA refresh must not change UTR evidence reuse.
Decision Matrix
| What I changed | Override needed? | Why |
|---|---|---|
| Post-processing logic (injection, bullet computation, %wor generation, retokenization after cache, terminator patching) | No | Runs after cache retrieval, cached value is still correct |
| Cache key computation | No | Old entries become orphans (different key = automatic miss). New keys miss and re-infer. |
| Word extraction logic (changes which words are sent to the model) | Yes | Cached result was computed from different input words |
| ML model/engine code (Python worker) | Automatic for FA if the reported FA engine identity changes; Yes if the identity string is unchanged, and for a UTR model change within one UTR engine | FA rows are namespaced by the reported FA engine (FaCacheNamespace); UTR ASR rows by the UTR engine, which does not yet distinguish model revisions |
| Serialization format of legacy FA/UTR derived values | Usually no | Normal mode may treat an unreadable derived value as work to recompute; required mode refuses the unresolved group. |
| Serialization format of raw Rev/speaker evidence | No automatic refresh | Corruption fails closed so local damage cannot authorize a paid call. Change the schema/revision deliberately. |
| Parse logic (changes how CHAT is parsed before extraction) | Depends | If extraction produces different words → yes (different key). If same words → no. |
Pre-cache text normalization (e.g., the script rendering in TranslationSource::render) | Yes | Key is computed from normalized text; same key now maps to wrong cached result |
Worked Example: The Bullet-Shrinking Bug
Bug (2026-03-16, a user, ACWT corpus): update_utterance_bullet() computed
the FA timing span from only the aligned words, then replaced the original
utterance bullet with it. Fillers, pauses, and gestures (which FA cannot align)
lost their timing coverage.
Analysis:
- What’s cached?
Vec<Option<WordTiming>>: the raw per-word timings from Whisper/Wave2Vec. - Where’s the bug? In
update_utterance_bullet(): post-processing that runs after cache retrieval. - Are the cached timings wrong? No, the word-level timings are correct. The bug was in how we used them to update the utterance bullet.
Fix: update_utterance_bullet() now uses BulletSource provenance to decide
whether to overwrite or union:
BulletSource::Authoritative(hand-linked, parsed from file, or FA-derived): union: never shrink. Preserves filler/gesture coverage.BulletSource::Utr(provisional UTR hint, set byBullet::utr_hint()): overwrite: FA word span is authoritative. The UTR window was a rough estimate; the FA alignment is more precise.
Verdict: No --override-media-cache needed. The source-aware update logic
applies automatically to cached FA results. Both behaviors are correct for their
respective bullet types.
Self-Correcting Cache Purges
When post-serialization validation detects an invalid result, the server
auto-deletes the cache entry that produced it and writes a bug report to
~/.batchalign3/bug-reports/. This means:
- Helps when: A cached value produces output that fails validation. Next run re-infers and (if the underlying model is correct) produces valid output.
- Does NOT help when: The cached value is wrong but valid, e.g., it passes validation but contains incorrect timings. Validation can’t catch semantic correctness.
- Does NOT help when: The post-processing is buggy, the cache entry will be deleted, but re-inference produces the same cached value, which the same buggy post-processing corrupts again. Fix the post-processing first.
Deserialization Failure Policy
Raw Rev and speaker evidence fails closed on cache read, envelope, provenance,
or validation errors. It does not become a miss, because a miss is the value
that can authorize a provider call. FA and the older normalized UTR cache may
recompute unreadable derived entries in ordinary use, but
--require-media-cache refuses any unresolved inference group. A deliberate
--override-media-cache is the explicit operator decision to replace evidence.
Deployment Checklist
When deploying a fix to the fleet (production server, worker hosts, etc.):
- Identify the change category using the decision matrix above.
- If override is NOT needed: Deploy the new binary. Cached results are reprocessed through the fixed post-processing automatically.
- If override IS needed: Deploy the new binary, then re-run affected
commands with
--override-media-cacheon the target corpora. For large corpora, consider running only on affected files rather than the full dataset. - If engine version changed: No action needed, version scoping automatically invalidates stale entries. Verify by checking cache stats in server logs (should show misses on first run).
- If unsure: inspect the raw/derived boundary first. Use
--require-media-cacheto prove an experiment can replay without inference; use--override-media-cacheonly when fresh inference is the intended and budgeted experimental variable.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Validation
Status: Current Last updated: 2026-06-21 23:23 EDT
CHAT validation runs at multiple points in the processing pipeline.
All validation logic is in Rust: talkbank-model::validation owns
CHAT-core validation, and talkbank_transform::validate
(crates/talkbank-transform/src/validate.rs) owns the Batchalign-side
pre/post validation gate functions (validate_to_level,
validate_output). This page covers validity levels, pre/post
validation gates, severity posture, the verification-gate set
(G0-G14), and how validation failures interact with caches and bug
reports.
For the CHAT-core error-code infrastructure (codes, sinks, severities, layers), now owned by chatter, see chat-core-errors. For the diagnostic UX standard, see error-diagnostics-ux.
Validity Levels
The ValidityLevel enum defines three cumulative validation levels.
Each level includes all checks from lower levels.
| Level | Name | Checks |
|---|---|---|
| L0 | Parseable | No parse errors (clean tree-sitter CST) |
| L1 | StructurallyComplete | @Participants and @Languages present, all speaker codes declared, every utterance has a terminator |
| L2 | MainTierValid | Well-formed words, valid timing bullets if present |
Pre-validation gates
Each command requires input to meet a minimum level before processing:
| Command | Required level |
|---|---|
morphotag | MainTierValid |
utseg | StructurallyComplete |
translate | StructurallyComplete |
coref | StructurallyComplete |
align | Parseable (lenient, must handle messy real-world files) |
validate_to_level() checks the file against the required level and
returns all failures found. Invalid files are rejected early with
diagnostics, before any compute is spent on inference.
Post-Serialization Validation
After an orchestrator injects results and serializes CHAT output, the
server runs validate_output():
- Alignment validation: checks that
%mor/%gra/%wortier word counts match the main tier. ParseHealth-aware: utterances flagged as unparseable during lenient parsing are excluded. - Semantic validation: full CHAT validation:
- E362: non-monotonic timestamps (utterance bullets must increase).
- E701 / E704: temporal constraints (overlap rules, same-speaker timing).
- Header correctness, required headers present and well-formed.
- Cross-utterance patterns, speaker code consistency.
Only blocks on severity="error", not warnings.
Severity Posture
Validation intentionally distinguishes errors from warnings:
- Errors block output. The server will not write CHAT with error-level validation failures.
- Warnings are reported but do not block. Legacy corpora contain widespread minor violations that must remain processable.
This distinction matters especially for %gra:
- Existing broken
%grain old corpora may be accepted with warnings so files remain processable. - Newly generated
%grafrom batchalign3 is validated more strictly before writeback.
Bug Reports and Cache Purges
When post-serialization validation fails:
- A structured bug report is written to
~/.batchalign3/bug-reports/. - Cache entries that produced the invalid output are purged (self-correcting cache).
This prevents broken results from being served on future runs.
Verification Gates (make verify)
The mandatory pre-merge / pre-push gate. All gates must pass before any code or doc change ships:
| Gate | What it checks |
|---|---|
| G0 | Parser signature guardrail |
| G1 | Rust workspace compile check |
| G2 | Spec tools compile check |
| G3 | Spec runtime tools compile check |
| G4 | CHAT manual anchor links |
| G5 | Generated parser corpus equivalence suite |
| G6 | Golden fragment validity (words + tiers) |
| G7 | Bare-timestamp regression gate |
| G8 | Reference corpus semantic equivalence |
| G9 | %wor tier parsing and alignment |
| G10 | Golden tier roundtrip (%mor, %gra, %pho, %wor) |
| G11 | Reference corpus node coverage |
| G12 | Generated artifacts match committed sources |
| G13 | Fuzz workspace isolation |
| G14 | Imported Batchalign Rust/PyO3 gate |
The reference corpus at corpus/reference/ is the sacred semantic
target, every file must be valid CHAT, and make verify runs each
gate against it.
The pre-push hook (make install-hooks) runs the fast subset (fmt,
affected compile, parser guardrail, generated-check, fuzz-check)
locally before push. CI runs the full set.
Validation at the PyO3 Boundary
There is no public Python validation API. The ParsedChat handle
that previously exposed validate() / validate_structured() /
validate_chat_structured() was retired in the 2026-03-21 PyO3
slimdown to worker-runtime-only. Validation now runs entirely on the
Rust side; when a worker invocation detects a failure it constructs
BatchalignBoundaryError::ChatValidation { entries, … } which the
PyO3 boundary lowers into a CHATValidationException carrying a
populated errors: list[ValidationErrorEntry] on the Python side.
Python callers that need structured validation results invoke
batchalign3 via subprocess and catch the exception:
from batchalign_core import CHATValidationException
try:
batchalign_core.execute_v2(request)
except CHATValidationException as exc:
for entry in exc.errors:
print(entry.code, entry.line, entry.message)
See Errors, Batchalign Runtime and Python ↔ Rust errors for the full boundary contract.
Known limitations
- Validation rules are intentionally permissive on legacy data.
Some checks emit warnings rather than errors so legacy corpora
remain processable while still surfacing the issue. Examples: pre-existing
malformed
%gra(warned, not blocked, so files that already shipped with bad%graround-trip cleanly); some bullet-format minor variants. Newly generated tiers from batchalign are validated more strictly before writeback. %worword counts are not validated against the main tier.%woris a timing-annotation tier with no downstream positional indexing; legacy files may havexxx, fragments, or nonwords in%worwithout producing alignment errors.- Cross-utterance quotation validation is gated off by default
(
enable_quotation_validationflag), the cross-utterance walker exists but is not yet wired into the standard validation gate. - Some error-spec / validator pairs are not yet implemented.
Tracked in
spec/errors/files markedStatus: not_implemented; these generate#[ignore]tests viamake test-genrather than failing CI. Rungrep -rl "Status.*not_implemented" spec/errors/to enumerate.
This page last changed: 2026-06-21 (commit 1de757d7). The whole book last changed: 2026-09-16 (commit 34d249d8).
Errors: Batchalign Runtime
Status: Current Last updated: 2026-06-21 23:23 EDT
How Batchalign produces, propagates, and surfaces errors specific to the ML runtime: parse modes, ML/IPC failures, network errors, ASR-API errors, worker-crash recovery, the Python-facing exception hierarchy, and the CLI failure summary. For the CHAT-core error infrastructure (codes, sinks, severities), now owned by chatter, see chat-core-errors. For the typed boundary between Python and Rust workers see python-rust-errors. For the diagnostic UX standard that applies workspace-wide, see error-diagnostics-ux.
Two Parse Modes
Strict (ParsedChat.parse())
Used by engines that require a valid AST to produce correct output:
add_morphosyntax_batched, extract_nlp_words, etc.
- Rejects on any error, raises
ValueErrorin Python. - Error string includes all error codes and locations:
Parse error: error[E316]: Could not parse content (line 5, bytes 100..120)
Lenient (ParsedChat.parse_lenient())
Used by engines that can tolerate partial results:
parse_and_serialize, add_forced_alignment, add_translation.
- Recovers from errors using tree-sitter error recovery.
- Tainted tiers are marked via
ParseHealthflags so downstream validation skips them. - Parse warnings are captured in
ParsedChat.warningsand available viaparse_warnings()(JSON array). - Only rejects when the file is completely empty after recovery.
Structured Error Access (PyO3)
Structured CHAT-validation errors reach Python through the typed
boundary, not via a ParsedChat method surface (the legacy
ParsedChat binding was removed in the 2026-03-21 PyO3 slimdown to
worker-runtime-only, see Python ↔ Rust Boundary).
Validation failures inside the Rust worker construct
BatchalignBoundaryError::ChatValidation { entries, … } (defined at
crates/batchalign-pyo3/src/error.rs); the boundary lowers that into
CHATValidationException on the Python side with a populated
errors: list[ValidationErrorEntry] field.
ValidationErrorEntry is a TypedDict (Python view of the Rust struct
at crates/batchalign-pyo3/src/error.rs:85) with these fields:
| Field | Type | Notes |
|---|---|---|
code | str | e.g. "E705" |
severity | str | "error" / "warning" |
line | Optional[int] | 1-based; None when unavailable |
column | Optional[int] | 1-based; None when unavailable |
message | str | Full diagnostic message |
suggestion | Optional[str] | Optional remediation hint |
Python callers inspect exc.errors[0].code and friends rather than
parsing message text. The boundary contract is enforced by
batchalign/tests/test_pyo3_error_typing.py.
Pre-Serialization Validation Gate
After Rust-owned processing stages have injected their results and
before final serialization, the production path validates the
generated ChatFile again to catch bugs in our own generation code
(MOR/GRA count mismatch, terminator identity errors). The check
lives in crates/talkbank-transform/src/validate.rs:
use talkbank_transform::validate::{validate_output, validate_to_level};
// Pre-validation: input must meet the command's required ValidityLevel.
validate_to_level(&chat_file, &parse_errors, hooks.validity)?;
// ... command body runs, mutates chat_file ...
// Post-validation: catch regressions introduced by command code.
validate_output(&chat_file, hooks.command)?;
Call sites: crates/batchalign/src/pipeline/text_infer.rs and
crates/batchalign/src/coref.rs import both functions from
talkbank_transform::validate. The exception message includes error
codes and line numbers:
Pre-serialization validation failed:
- E705: Main tier has 2 alignable items, but %mor tier has 1 items
- E716: Main tier terminator "." does not match %mor terminator "?" (line 23)
For full validation gates (G0-G14, validity levels L0-L2, post-serialization checks), see validation.
CHATValidationException
Defined in Rust at crates/batchalign-pyo3/src/error.rs via
pyo3::create_exception! and re-exported through Python:
// crates/batchalign-pyo3/src/error.rs
use pyo3::create_exception;
create_exception!(batchalign_core, BatchalignError, PyException);
create_exception!(batchalign_core, CHATValidationException, BatchalignError);
# batchalign/errors.py
from batchalign_core import (
BatchalignError,
CHATValidationException,
...
)
When BatchalignBoundaryError::ChatValidation { entries, .. } crosses
the PyO3 boundary, the From<BatchalignBoundaryError> for PyErr impl
constructs the Python exception with a populated errors: list[ValidationErrorEntry] and an optional bug_report_id. Code that
catches the exception inspects exc.errors[i].code,
exc.errors[i].line, etc. for programmatic access without parsing
the message string.
See Python ↔ Rust errors for the full boundary contract, including the typed-exception hierarchy and the internals-leakage scan.
Runtime Error Classification
Error category mapping is centralized in batchalign/errors.py
(classify_error(exc)) and used by server-side job accounting.
Exceptions classify into four categories:
| Category | Meaning | Examples |
|---|---|---|
input | Bad CHAT content | CHATValidationException, parse errors |
media | Missing audio/video files | FileNotFoundError |
system | Infrastructure failure | MemoryError |
processing | Unexpected errors during processing | Everything else |
Classification is done by classify_error(exc). Parse errors are
identified by CHATValidationException type or by the
"Parse error" prefix in ValueError messages.
CLI Failure Summary
Rust CLI dispatch aggregates failures per job/server and prints
structured summaries after polling. Error details shown to users
are derived from server FileStatusEntry fields (error,
error_category, and any structured validation metadata).
Error Flow
┌─────────────────────────────────────────────────────────────┐
│ Rust Parser │
│ parse_chat_file() ──► ParseError { code, line, message } │
│ parse_chat_file_streaming() ──► ErrorSink collects warnings│
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ batchalign_core (PyO3) │
│ parse_strict_pure() ──► format!("{}", e) ──► ValueError │
│ parse_lenient_pure() ──► (ChatFile, warnings) │
│ validate_structured()──► errors_to_json() ──► JSON string │
│ parse_warnings() ──► errors_to_json() ──► JSON string │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Python API / Legacy adapters │
│ batchalign/errors.py may wrap structured validation JSON │
│ into CHATValidationException(msg, errors=[...]) │
└──────────────────────┬──────────────────────────────────────┘
│
┌────────┴────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────────┐
│ CLI │ │ Processing Server │
│ polls file/job │ │ validates generated AST │
│ status, formats │ │ maps failures into │
│ failure summary │ │ FileStatusEntry metadata│
└──────────────────┘ └──────────────────────────┘
This page last changed: 2026-06-21 (commit 1de757d7). The whole book last changed: 2026-09-16 (commit 34d249d8).
Errors at the Python ↔ Rust Boundary
Status: Current Last updated: 2026-05-01 17:07 EDT
How errors crossing the PyO3 boundary between the Rust worker
runtime (batchalign_core) and the Python ML hosting layer
(batchalign/...) carry typed structure into the Python exception
hierarchy. For the boundary itself (what crosses, capability
discovery, executor layout) see
Python-Rust Boundary.
For Batchalign-side error flow (parse modes, runtime
classification, CLI failure summary) see
Errors, Batchalign Runtime.
Typed boundary error
Rust crossings emit BatchalignBoundaryError, defined in
crates/batchalign-pyo3/src/error.rs. Each variant maps to one
Python exception subclass and carries the structured fields that
subclass needs:
#[derive(Debug, thiserror::Error)]
pub enum BatchalignBoundaryError {
/// CHAT validation produced a structured error list.
/// Maps to `CHATValidationException`.
#[error("CHAT validation failed: {message}")]
ChatValidation {
message: String,
entries: Vec<ValidationErrorEntry>,
bug_report_id: Option<BugReportId>,
},
/// A non-CHAT document payload failed validation.
/// Maps to `DocumentValidationException`.
#[error("Document validation failed: {message}")]
DocumentValidation { message: String },
/// A required config file or value was missing on disk.
/// Maps to `ConfigNotFoundError`.
#[error("Config not found: {path}")]
ConfigNotFound { path: PathBuf },
/// Config syntactically present but semantically invalid.
/// Maps to `ConfigError`.
#[error("Config invalid: {message}")]
ConfigInvalid { message: String },
/// PyO3 boundary's body limit (or another payload-shape gate)
/// rejected the request. Maps to `PayloadTooLargeError`.
#[error("Payload too large: {limit_layer:?} limit {configured_bytes} bytes")]
PayloadTooLarge {
limit_layer: BodyLimitLayer,
configured_bytes: u64,
},
/// File should pass through unchanged with a warning logged.
/// Maps to `SkipFileWarning`; carries the raw CHAT text.
#[error("Skip with warning: {message}")]
SkipFileWarning {
message: String,
chat_text: Option<String>,
},
/// Any Rust-side failure not in the typed buckets above.
/// Maps to `BatchalignError` (the typed parent).
#[error("Internal Batchalign error: {message}")]
Internal { message: String },
}
BodyLimitLayer is Inner | Outer, shared with the HTTP-server
work in crates/batchalign/src/error.rs (the
PayloadTooLarge variant on ServerError lives there too).
From impl: typed exception construction
impl From<BatchalignBoundaryError> for PyErr {
fn from(error: BatchalignBoundaryError) -> Self {
Python::with_gil(|py| {
let module = match PyModule::import(py, "batchalign_core") {
Ok(m) => m,
Err(e) => return PyValueError::new_err(format!(
"failed to import batchalign_core for typed exception: {e}"
)),
};
let (class_name, kwargs) = match &error {
BatchalignBoundaryError::ChatValidation { entries, bug_report_id, .. } => (
"CHATValidationException",
pydict! { "errors": entries, "bug_report_id": bug_report_id },
),
BatchalignBoundaryError::DocumentValidation { .. } => (
"DocumentValidationException",
PyDict::new(py),
),
BatchalignBoundaryError::ConfigNotFound { path } => (
"ConfigNotFoundError",
pydict! { "path": path.to_string_lossy().into_owned() },
),
BatchalignBoundaryError::ConfigInvalid { .. } => (
"ConfigError",
PyDict::new(py),
),
BatchalignBoundaryError::PayloadTooLarge { limit_layer, configured_bytes } => (
"PayloadTooLargeError",
pydict! {
"limit_layer": format!("{limit_layer:?}"),
"configured_bytes": *configured_bytes,
},
),
BatchalignBoundaryError::SkipFileWarning { chat_text, .. } => (
"SkipFileWarning",
pydict! { "chat_text": chat_text },
),
BatchalignBoundaryError::Internal { .. } => (
"BatchalignError",
PyDict::new(py),
),
};
// Look up class_name on `module`, instantiate with
// (message, **kwargs), return as PyErr.
})
}
}
pydict! is a small helper macro in batchalign-pyo3/src/error.rs
that expands to repeated set_item calls followed by
kwargs.into_any().
Python exception hierarchy
The PyO3 module declares the exceptions:
use pyo3::create_exception;
create_exception!(batchalign_core, BatchalignError, PyException);
create_exception!(batchalign_core, CHATValidationException, BatchalignError);
create_exception!(batchalign_core, DocumentValidationException, BatchalignError);
create_exception!(batchalign_core, ConfigNotFoundError, BatchalignError);
create_exception!(batchalign_core, ConfigError, BatchalignError);
create_exception!(batchalign_core, PayloadTooLargeError, BatchalignError);
create_exception!(batchalign_core, SkipFileWarning, PyException);
batchalign/errors.py re-exports them so callers continue to
import from a single Python module:
from batchalign_core import (
BatchalignError,
CHATValidationException,
DocumentValidationException,
ConfigNotFoundError,
ConfigError,
PayloadTooLargeError,
SkipFileWarning,
)
BatchalignError is the typed parent, Python catch sites get one
ancestor for every typed exception that originates in
batchalign_core:
try:
handle = batchalign_core.execute_v2(request)
except BatchalignError as exc:
# Catches every typed exception above
...
SkipFileWarning does not route through Warning: Python
code raises and catches it as an exception, not a warning, to
preserve existing call-site semantics.
Round-trip example
sequenceDiagram
participant Py as Python application
participant Bridge as batchalign_core (PyO3)
participant Rust as Rust worker logic
participant Domain as talkbank-transform / talkbank-model
Py->>Bridge: execute_asr_request_v2(request)
Bridge->>Rust: parse + dispatch
Rust->>Domain: validate(payload)
Domain-->>Rust: Err(ValidationError { code: E312, ... })
Rust-->>Bridge: BatchalignBoundaryError::ChatValidation { entries, ... }
Bridge->>Bridge: From<…> for PyErr → CHATValidationException(message, errors=...)
Bridge-->>Py: raise CHATValidationException
Note over Py: catch CHATValidationException as exc<br/>exc.errors[0].code == "E312"<br/>exc.bug_report_id is None
Every error category that crosses the boundary already exists as a typed variant somewhere in the Rust call stack, the boundary is the only place where structure could get discarded, and it doesn’t.
Where the contract is enforced
PyO3 entry points across crates/batchalign-pyo3/src/worker_*.rs
construct BatchalignBoundaryError rather than
PyValueError::new_err(...). The conversion sites:
| File | Sites |
|---|---|
worker_asr_exec.rs | parse_execute_request, final serde_json mapping |
worker_media_exec.rs | 4 sites |
worker_text_results.rs | 16 sites |
worker_fa_exec.rs | 2 sites |
worker_artifacts.rs | 20 sites |
cantonese_asr_bridge.rs | 4 sites |
py_json_bridge.rs | 1 site |
worker_protocol.rs | (no PyValueError sites; not converted) |
rg PyValueError crates/batchalign-pyo3/src/ returns zero matches
in the worker-* files, the typed pathway is the only path.
bug_report_id is Python-populated for now
The Rust side has the MisalignmentBug shape but not the
bug-report-filing pipeline; filing happens server-side after the
exception crosses the boundary. The bug_report_id field on
CHATValidationException is therefore set by Python code that
catches the exception and files the report. Moving filing into Rust
and having the boundary populate bug_report_id directly is a
future change.
Internals-leakage scan
An operator-local internals-leakage scan runs against error-construction sites to confirm that no internal pattern values appear in error messages crossing the boundary. The pattern values themselves are not listed here, keeping a leak-detector’s needle list out of the public repo is itself a leak-prevention rule. Operators run the scan from their own private patterns file.
Tests
batchalign/tests/test_pyo3_error_typing.py asserts:
- Malformed requests raise
BatchalignError(or a subclass), notPyValueError. exc.errors[0].codeis populated when the failure is a validation error.- The exception is catchable by its typed parent class.
Out of scope
- Backwards-compatible string-prefix parsers in
batchalign/errors.py. The legacyclassify_errorhelper that matches on substrings ofstr(exc)is a fallback for non-PyO3 code paths only; it is not the contract for typed exceptions. - Re-using
talkbank-transform’sMorphosyntaxStrategy/DecisionRecordtypes directly across the boundary. These have richer structure than the Python side wants today; they’re flattened toValidationErrorEntryat the boundary. - Rust-side panics. The no-panic standard from
CLAUDE.mdapplies; this contract assumes panics never cross the boundary.
Related
crates/batchalign/src/error.rs: server-sideServerErrortyped-status mapping; thePayloadTooLargevariant lives here.- Errors, Batchalign Runtime, end-to-end error flow.
- INTERFACE_MAP.md , Rust ↔ Python boundary inventory.
crates/batchalign/CLAUDE.md: server-side error-handling rules.
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Graceful Failure Invariant
Status: Current Last updated: 2026-09-15 20:20 EDT
The rule
Every per-item runtime error from a Python worker, engine failure, network failure, model error, protocol violation, propagates to the caller as a typed failure. No code path silently drops a per-item error and emits an empty success-shaped response in its place.
This rule is system-wide. It applies to every command in batchalign3:
align, transcribe, morphotag, translate, utseg, coref,
benchmark, opensmile, avqi, compare.
Why this rule exists
The opposite shape, log a warning, push an empty response, continue,
turns failures into invisible data loss. A user runs the job, the
job exits “successfully”, the output file is shorter than it should
be, and the only signal lives in a tracing log nobody reads. We have
seen this pattern produce missing %xtra tiers when Google Translate
is GFW-blocked, missing %mor tiers when Stanza’s per-item output is
malformed, missing utseg assignments when a constituency tree is
malformed, and missing %xcoref annotations when the coref worker
fails on one document inside a cross-file batch. Each instance looks
like a successful job to the operator until somebody manually audits
the output.
The rule is therefore: if any item failed, the file fails. No partial output is written for a failed file. The user sees a typed error message that names the failing items, not a tracing-log forensics exercise.
The shape
flowchart TD
item["Per-item engine call"]
item -->|"success"| ok["Ok(Response)"]
item -->|"engine/network/model error"| err["Err(message)"]
item -->|"protocol violation\n(no error AND no payload)"| err
ok --> collect["Driver collects Vec\<Result\<R, ItemFailure\<S\>\>\>"]
err --> collect
collect --> attribute["Attribute per-item Err to source file\nvia per_file_info slice"]
attribute -->|"any Err\nfor this file"| fail["TextWorkflowFileError::ItemErrors\n(file marked failed, no output)"]
attribute -->|"all Ok\nfor this file"| apply["hooks.apply(chat_file, items, responses)\nfile serialized to output"]
A per-file batch sees each item as either Ok(R) (engine succeeded
and produced a typed payload) or Err(ItemFailure<S>): the engine
reported a runtime error, the worker returned neither error nor
payload, or the command itself refused what came back.
The driver, run_text_batch_pipeline in
crates/batchalign/src/pipeline/text_infer.rs: groups per-item
results back to their source file via per_file_info and writes one
of two outcomes per file:
TextBatchFileResult::ok(filename, chat_text): every item succeeded for this file; the file’s%xtra/%mor/ etc. tier is injected and the file is serialized to the output.TextBatchFileResult::err(filename, TextWorkflowFileError::ItemErrors { command, total, samples })one or more items failed; no output is written. The error carries the command label, total failure count, and up toMAX_ITEM_ERROR_SAMPLES(5) sample messages for diagnostics.
Other files in the same cross-file batch are unaffected, they follow their own outcome. This matches BA2’s per-file isolation semantics (BA2 ran each file in its own future; one file failing did not kill its neighbors).
The typed error
TextWorkflowFileError (in crates/batchalign/src/text_batch.rs)
has two variants:
pub(crate) enum TextWorkflowFileError {
/// A failure the control plane already classified, carried with its
/// verdict: worker spawn, IPC, schema, pre- or post-validation,
/// serialization. No per-item attribution.
Categorised(FailureCategory, String),
/// Per-item: one or more items failed. The first N samples are
/// retained inline (rendered); the rest are counted in `total`.
ItemErrors {
command: &'static str,
total: usize,
samples: Vec<ItemErrorSample>,
},
}
A per-item failure is typed, and parameterised by the command’s own failure so a command cannot represent one it does not have:
pub(crate) enum ItemFailure<S> {
/// The engine reported this item's failure, message verbatim.
EngineReported(String),
/// A failure this command defines.
Command(S),
}
/// utseg, coref and morphotag: `Infallible`, so `Command` is uninhabited.
pub(crate) type EngineItemFailure = ItemFailure<std::convert::Infallible>;
Translate’s S is EmptyTranslationFailure: the engine answered with
nothing that can be written to %xtra. The file-level error keeps the
failures rendered (ItemErrorSample), because it outlives the
command’s own type.
Every class we have is the provider’s settled answer about that item,
so ItemErrors reports ProviderTerminal: an identical request gets
an identical answer, and telling the control plane otherwise would buy
another full run. A class that genuinely could differ on a retry has
to say so where the category is decided.
Engine-class typing (NetworkError vs ModelError vs
ProtocolError as separate variants) is deliberately not yet
modelled. The Python worker’s error strings already carry the class
verbatim ("Translation failed: ConnectionResetError(...)",
"Failed to parse raw Stanza output ..."); a typed split is a
future change with a downstream consumer.
Where the rule applies in code
Today the rule is enforced at these seams:
| Layer | File | Function | Note |
|---|---|---|---|
| Driver (per-file) | crates/batchalign/src/pipeline/text_infer.rs | run_text_pipeline | Single-file flow; per-item Err collapses to one typed ServerError::Validation |
| Driver (cross-file) | crates/batchalign/src/pipeline/text_infer.rs | run_text_batch_pipeline | Cross-file flow; per-item Err attributed back to source file via per_file_info |
| Shared helper | crates/batchalign/src/text_batch.rs | unwrap_per_item_results | Collapses Vec<Result<R, ItemFailure<S>>> → Result<Vec<R>, TextWorkflowFileError> |
| translate worker | crates/batchalign/src/translate.rs | parse_translate_item_results | Per-item parsing; engine error, protocol violation and a translation with nothing to apply all → Err |
| utseg worker | crates/batchalign/src/utseg.rs | infer_admitted_batch | Same shape; the projection to bare responses (infer_batch) is gone, so the batch keeps each prediction’s evidence |
| coref worker | crates/batchalign/src/coref.rs | infer_batch | Per-document (one item per file) |
| morphotag worker | crates/batchalign/src/morphosyntax/worker.rs | infer_batch_single | Per-item; Stanza-parse-failure folded into per-item Err |
| Python worker (utseg) | batchalign/inference/utseg.py | _parse_tree_indices | Raises AttributeError on malformed tree (previously returned []) |
| transcribe (ASR) | crates/batchalign/src/pipeline/transcribe.rs | stage_asr_postprocess | The engine recognized no words: EmptyTranscription::Asr. This returned Ok(()) until 2026-09-16, and CHAT assembly then wrote a headers-only file that the job reported as a success |
| transcribe (post-processing) | crates/batchalign/src/pipeline/transcribe.rs | stage_asr_postprocess | Words came back and post-processing kept no utterance from them: EmptyTranscription::Postprocess |
| transcribe (CHAT assembly) | crates/batchalign/src/pipeline/transcribe.rs | stage_build_chat | Utterances reached assembly and none held content, which is what a punctuation-only monologue produces: EmptyTranscription::ChatBuild { described } |
The three transcribe seams share one typed error, EmptyTranscription, whose
variant names the stage that produced nothing: a reader of the failure does not
have to guess which of them came up empty, and an empty transcript is never
written as a completed job. It answers 422 (the request was fine and the server
worked; the media yielded no words) and classifies as
FailureCategory::Validation, so it is outside the retry set.
What’s NOT covered by this rule
Two patterns look superficially like silent failure but are intentional fallbacks that the rule does not cover:
-
L2|xxxmorphotag fallback for code-switches into Stanza- unsupported languages.infer_batch_per_itemmarks those itemsOk(UdResponse { sentences: vec![] }), and downstreaminject_resultsskips the empty UdResponse so the existingL2|xxxplaceholder stays in%mor. This is BA2-parity behavior for languages Stanza cannot analyze, the empty response is the feature, not a missing result. -
Dummy / empty-payload files passed through unchanged. A file with no eligible utterances (e.g. coref on a non-English file) serializes to the output unchanged. No worker call is made; the absence of
%xcorefis the correct result.
Both fallbacks emit Ok outputs, not Err. The graceful-failure
invariant covers only the Err path.
Capability advertisement vs runtime errors
Capability-probe failures (e.g. worker/_handlers.py::_capabilities
catching an exception when building stanza_capabilities) are
intentionally lenient: the worker advertises an empty capability
set and the controller picks an alternate worker if one exists.
Capability advertisement is not user-facing work; the runtime path
where actual job items get processed is the path this invariant
governs.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Command Contracts: Input Preconditions and Output Guarantees
Status: Current Last updated: 2026-09-15 20:20 EDT
This document specifies, for each batchalign3 command that operates on CHAT files, the minimum input validity required, what the command reads and writes, and what guarantees the output provides. Each command applies a pre-validation gate so that invalid input is rejected early rather than wasting compute and producing silently corrupt output.
Pre-validation and post-validation gates are enforced for the following commands:
morphotag(crates/batchalign/src/morphosyntax/)utseg(crates/batchalign/src/utseg.rs)translate(crates/batchalign/src/translate.rs)coref(crates/batchalign/src/coref.rs)align(crates/batchalign/src/fa/)
These commands call validate_to_level(...) before inference and
validate_output(...) before serialization. Media-path preflight
validation runs in crates/batchalign/src/runner/util/ for commands
that consume audio.
Motivation
This contract was introduced to prevent wasted compute and silent corruption caused by running expensive inference on structurally invalid CHAT content.
parse_lenient() still intentionally parses best-effort ASTs, but current server
commands now apply command-specific gates before inference. The remaining task is
to keep this document synchronized with implementation and close any uncovered
command paths.
Representative failure modes that motivated this contract:
- A file with missing terminators proceeds through forced alignment (92s of GPU), only to be rejected by the post-serialization validation gate.
- A file with a corrupted main tier (e.g.,
to+...parsed asto++.) gets silently mangled during roundtrip serialization. - No command validates that the parts of the file it reads are structurally sound before investing compute.
The fix: each command should validate exactly what it needs, no more, no less.
Validity Levels
We define four cumulative levels of CHAT validity:
Level 0: Parseable
The file parses without ERROR nodes in the tree-sitter CST.
@UTF8header present@Begin/@Endheaders present- No unrecognized line types (no
unsupported_linenodes) - All brackets balanced
- No tokenization errors (no ERROR nodes)
This is the minimum for any command. If a file doesn’t reach Level 0, it should be rejected outright with a diagnostic pointing to the parse errors.
Level 1: Structurally Complete
Level 0, plus:
@Participantsheader present with at least one participant@Languagesheader present with at least one language code- Every speaker code in utterances is declared in
@Participants - Every utterance has a terminator (E304)
- No empty main tiers
This is the minimum for commands that read main tier content.
Level 2: Main Tier Valid
Level 1, plus:
- No word-level structural errors (E2xx): balanced compound markers (E233), valid shortening notation, valid special form markers
- No annotation structural errors: balanced retracing/repetition markers, valid overlap notation
- Timing bullets (if present) are well-formed
This is the minimum for commands that extract words for NLP processing.
Level 3: Fully Valid
Level 2, plus all validation rules pass:
- Tier alignment (E7xx): %mor/%gra/%pho/%sin/%wor word counts match main tier
- Temporal validity (E362, E701, E704): monotonic timestamps, no self-overlap
- Cross-utterance validity: quotation linker pairing, completion annotations
- Header field formats: @Date, @ID fields, @Media format
This is the ideal state, but no command requires it as a precondition.
Command Contracts
morphotag: Morphosyntactic Analysis
Input: CHAT file with main tier utterances
Reads:
- Main tier words (via
collect_utterance_content()inTierDomain::Mor) @Languagesheader (determines Stanza model language)- Per-utterance language markers (
[- spa]) for multilingual filtering - Special form markers (
@s,@c,@b) on words for POS override - Existing
%mortiers (to skip already-tagged utterances, unless--override-media-cache)
Writes:
%mortier: morphological analysis (POS, lemma, features) per utterance%gratier: grammatical dependency relations per utterance- Main tier words (only when
retokenize=true): replaces with UD tokenization
Minimum input validity: Level 2
The main tier must be fully parseable with valid word structure. Invalid words
(e.g., malformed compound markers) produce garbage NLP input. Existing %mor
and %gra tiers may be absent or invalid, they will be replaced.
What may be invalid in the input:
%mor,%gratiers (will be overwritten)%pho,%sin,%wortiers (untouched, validity irrelevant)%xtra,%xcoreftiers (untouched)- Timing bullets (untouched, validity irrelevant)
- Temporal ordering (untouched)
@Date,@IDfield formats (not read)
Output guarantees:
- Every utterance with extractable words has a
%morand%gratier %morword count equals main tier alignable word count (enforced byvalidate_mor_alignment())- All other tiers and headers are preserved unchanged
- File remains at its input validity level or higher (no new errors introduced in parts the command doesn’t touch)
Invariants:
- Speaker codes are preserved exactly
- Utterance order is preserved exactly
- Terminators are preserved exactly
- Timing bullets are preserved exactly
- Annotations (retracing, repetition, overlap) are preserved exactly
utseg: Utterance Segmentation
Input: CHAT file with main tier utterances (typically a single long utterance per speaker turn from ASR output)
Reads:
- Main tier words (via
collect_utterance_content()inTierDomain::Mor)
Writes:
- Utterance structure: splits multi-word utterances into multiple utterances based on constituency-parse-derived sentence boundaries
- Each split utterance gets a period terminator (
.) - All dependent tiers on split utterances are dropped (fresh utterances
have no
%mor,%gra,%wor, etc.)
Minimum input validity: Level 1
Only needs structurally complete utterances with parseable words. Since all dependent tiers are dropped during splitting, their validity is irrelevant.
What may be invalid in the input:
- All dependent tiers (will be dropped on split utterances)
- Timing bullets (dropped on split utterances)
- Word-level details beyond basic parseability (NLP operates on cleaned text)
Output guarantees:
- Every output utterance has a terminator (period)
- Single-word utterances are never split (passed through unchanged with all tiers preserved)
- Non-utterance lines (headers, comments) are preserved in original positions
- A prediction whose assignments are not parallel to the request words is refused when the worker result is admitted, and the file fails: there is no path that quietly keeps the original utterance and reports success
Invariants:
- Speaker codes are preserved
- Header structure is preserved
- Utterance order is preserved (splits are in-place)
Caveats:
- Splitting destroys dependent tiers,
utsegshould typically be run beforemorphotagandalign, not after - Original terminators on split utterances are replaced with period (
.)
translate: Translation
Input: CHAT file with main tier utterances
Reads:
- Main tier words the speaker produced, in order, with the utterance’s terminator (retraced words and filled pauses included; omissions, nonwords, fragments and untranscribed markers left out)
Writes:
%xtratier: translated text as aUserDefineddependent tier- Replaces existing
%xtraif present
Minimum input validity: Level 1
Only needs parseable main tier text. Word-level structural validity is not critical since words are joined into a plain text string for the translation API.
What may be invalid in the input:
- All dependent tiers (untouched except
%xtra) - Timing bullets (untouched)
- Word-level markers (joined into text, model handles gracefully)
Output guarantees:
- Every utterance that produced words has a
%xtratier - An empty translation is refused when the worker result is admitted, so a written file never has an utterance silently missing its tier; the file fails instead, with a typed per-item failure that names the engine and the remedy. The verdict is terminal: an identical request gets an identical answer
- All other tiers and headers are preserved unchanged
Invariants:
- Main tier content is never modified
- All non-
%xtradependent tiers are preserved - Utterance order and structure preserved
coref: Coreference Resolution
Input: CHAT file with main tier utterances (English only)
Reads:
- Main tier words from all utterances (document-level context)
@Languagesheader (English-only gate)
Writes:
%xcoreftier: bracket-notation coreference annotations (sparse, only utterances with actual chains)- Replaces existing
%xcorefif present
Minimum input validity: Level 1
Only needs parseable main tier text. Non-English files pass through unchanged.
What may be invalid in the input:
- All dependent tiers (untouched except
%xcoref) - Everything else (untouched)
Output guarantees:
- English files get
%xcoreftiers on utterances with coreference chains - Non-English files are returned unchanged
- All other content is preserved
Invariants:
- Main tier content is never modified
- Not cached (document-level context makes per-utterance caching meaningless)
align: Forced Alignment
Input: CHAT file with main tier utterances + corresponding audio file
Reads:
- Main tier words (for transcript-to-audio alignment)
- Existing timing bullets on utterances (for audio window grouping)
- Audio file (resolved from same-stem sibling:
.wav,.mp3, etc.) - Audio duration via
ffprobe @Options: NoAlign(to skip files that opt out)
Writes:
- Word-level timing bullets on
Wordnodes in the main tier - Utterance-level timing bullet (first-word-start to last-word-end)
%wortier: regenerated from scratch (mirrors main tier words with individual timing bullets)
Minimum input validity: Level 2
The main tier must be structurally valid with correct word structure. Invalid
words produce incorrect alignment transcripts. Terminators must be present and
correct (the to+... bug demonstrated that terminator corruption propagates
through FA serialization). Existing timing bullets should be well-formed if
present (they’re used for audio window grouping).
Additional preconditions:
- Audio file must exist and be accessible
- Audio file must be a known format (
.wav,.mp3,.mp4) - Audio file must be non-empty
What may be invalid in the input:
%mor,%gratiers (untouched)%xtra,%xcoreftiers (untouched)- Existing
%wortier (will be regenerated) @Date,@IDfield formats (not read)
Output guarantees:
- Every alignable word has a timing bullet (or
Noneif alignment failed) - Utterance-level bullets span first-to-last word timing
%wortier mirrors main tier words 1:1 with timing- Temporal monotonicity enforced (
enforce_monotonicity()strips timing from backwards utterances) - Same-speaker self-overlap stripped (
strip_e704_same_speaker_overlaps()) - Untimed words get interpolated timing (proportional fill)
Invariants:
- Main tier word content is never modified (only timing added)
- Speaker codes preserved
- Annotations preserved
- Non-
%wordependent tiers preserved - Utterance order preserved
transcribe: ASR Transcription
Input: Audio file (NOT a CHAT file)
Reads:
- Audio file content
Writes:
- Creates an entirely new CHAT file from scratch:
@UTF8,@Begin,@Endheaders@Languages,@Participants,@IDheaders@Mediaheader referencing the audio file- Main tier utterances with speaker codes and timing bullets
%wortiers with word-level timing- Utterance terminators
Minimum input validity: N/A (no CHAT input)
Preconditions:
- Audio file must exist, be non-empty, have a known extension
- For Rev.AI: valid API key configured
Output guarantees:
- Output is a complete, valid CHAT file
- Pre-serialization validation runs both alignment and semantic gates
- If validation fails, the file is not written and a bug report is filed
benchmark: ASR Evaluation
Input: Audio file (NOT a CHAT file)
Same contract as transcribe plus evaluation metrics output.
opensmile: Audio Feature Extraction
Input: Audio file
Reads: Audio content only Writes: JSON metrics (not CHAT)
No CHAT contract applies.
avqi: Acoustic Voice Quality Index
Input: Paired .cs/.sv audio files
Reads: Audio content only Writes: JSON metrics (not CHAT)
No CHAT contract applies.
Pre-Validation Gate Design
Current Implementation
Each command declares its minimum validity level. Before dispatching to the
orchestrator, the runner validates the parsed CHAT file to that level
(call sites: coref.rs, pipeline/text_infer.rs, pipeline/morphosyntax.rs,
morphosyntax/mod.rs):
Runner receives file
→ parse_lenient() (always, for error recovery diagnostics)
→ check parse errors (Level 0)
→ if command needs Level 1+: check structural completeness
→ if command needs Level 2+: check main tier word validity
→ if preconditions fail: reject with diagnostics, skip file, continue job
→ if preconditions pass: dispatch to orchestrator
Command → Validity Level Mapping
| Command | Min Level | Additional Preconditions |
|---|---|---|
morphotag | Level 2 | @Languages present |
utseg | Level 1 | , |
translate | Level 1 | , |
coref | Level 1 | English language |
align | Level 2 | Audio file exists |
transcribe | N/A | Audio file exists |
benchmark | N/A | Audio file exists |
opensmile | N/A | Audio file exists |
avqi | N/A | Paired audio exists |
Rejection Behavior
When a file fails pre-validation:
- The file is marked as
errorin the job status with category"validation" - The specific validation errors are reported (e.g., “E304: Missing terminator on line 15”, “E233: Empty compound trailing part on line 22”)
- Processing continues with the next file in the job (partial job completion)
- No compute is wasted on the invalid file
- A bug report is filed if the errors suggest a parser/pipeline bug rather than input data quality
Lenient vs Strict Parsing
We keep parse_lenient() as the parsing mode, it provides better error
recovery and diagnostics than parse_strict() (which just fails on first
error). The pre-validation gate inspects the parse errors and the resulting
AST to determine if the file meets the command’s minimum validity level.
This is different from switching to parse_strict(): we parse leniently but
validate strictly against the command’s requirements.
Post-Processing Validation
All server-side orchestrators run a post-processing validation gate before returning the serialized CHAT. The gate checks:
- Alignment validation: tier word counts match (for commands that write dependent tiers)
- Temporal validation: monotonic timestamps, no self-overlap (for commands that write timing)
- Structural validity: the output file meets at least its input validity level (no degradation)
Call sites: crates/batchalign/src/coref.rs:193, :371;
crates/batchalign/src/pipeline/text_infer.rs:95, :291;
crates/batchalign/src/pipeline/morphosyntax.rs:461;
crates/batchalign/src/morphosyntax/mod.rs:208. The underlying
functions (validate_to_level, validate_output) live in
../chatter/crates/talkbank-transform/src/validate.rs.
On failure: file a bug report, mark the file as error, return the original input file unchanged (do not write corrupt output).
Appendix: What Each Command Preserves
A command’s preservation set is every part of the CHAT file it does not modify. The pre-validation gate does NOT check the preservation set, those parts can be invalid without affecting the command’s operation.
| Command | Preservation Set |
|---|---|
morphotag | Headers, timing, annotations, %pho/%sin/%wor/%xtra/%xcoref, %com |
utseg | Headers, non-utterance lines (dependent tiers on split utterances are NOT preserved) |
translate | Headers, main tier, timing, all tiers except %xtra |
coref | Headers, main tier, timing, all tiers except %xcoref |
align | Headers, main tier words/annotations, %mor/%gra/%pho/%sin/%xtra/%xcoref |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
NLP Pipeline Decision Architecture
Status: Current Last updated: 2026-09-16 08:18 EDT
This chapter documents how batchalign3’s four NLP pipelines (morphotag,
utseg, coref, forced alignment) represent per-utterance decisions, how
those decisions can flow through a shared reporting vocabulary, and how the
eval harness reads output back post-hoc. Every pipeline has typed outcomes,
a single place to add a new variant, compile-time errors for typos, and loud
typed diagnostics when invariants break. Durable decision persistence is
complete for forced alignment. Transcribe can also retain both of its
utterance-segmentation passes as schema-versioned debug evidence; standalone
utseg, morphotag, and coref do not yet have equivalent production sinks. The
Decision Evidence chapter records that
boundary precisely. For the
morphotag-specific deep-dive into the 1-to-1 invariant that motivated
this architecture, see
Morphotag Reconciliation Invariants.
Motivation
Every NLP pipeline has invariants a bug can silently break, Stanza
returning fewer tokens than the CHAT main tier contained, a worker
responding with the wrong number of assignments, a retokenize pass
losing a clitic. When one of those invariants is expressed as a raw
Result<_, String> or as a per-utterance continue that silently
skips a bad case, a single upstream regression can strip annotations
across thousands of utterances without any operator-visible signal.
The architecture below exists to make that class of failure harder to absorb
silently. Each pipeline defines typed outcomes and can convert anomalies into a
shared DecisionRecord. Forced alignment retains those records in structured
evidence; morphotag traces anomaly records but does not yet persist its
collection; utseg and coref do not yet have complete production sinks. Count
mismatches at invariant boundaries return typed diagnostics carrying enough
context to triage without re-running.
Four pipelines participate, each with its own natural shape:
flowchart LR
subgraph Morphotag
MP["collect_payloads<br/>(talkbank-transform/morphosyntax/payload.rs)"] --> MD["Stanza worker"]
MD --> MI["inject_results<br/>(talkbank-transform/morphosyntax/injection.rs)"]
end
subgraph Utseg
UP["collect_utseg_payloads<br/>(talkbank-transform/utseg.rs)"] --> UD["TalkBank boundary model<br/>or opt-in Stanza fallback"]
UD --> UA["Admit source, cardinality,<br/>policy, and assignments"]
UA --> UI["apply_utseg_results<br/>(talkbank-transform/utseg.rs)"]
UA --> UE["Optional atomic evidence sink<br/>(transcribe pre/post CHAT)"]
end
subgraph Coref
CP["collect_coref_payloads<br/>(talkbank-transform/coref.rs)"] --> CD["Stanza coref worker"]
CD --> CI["apply_coref_results_with_outcomes<br/>(talkbank-transform/coref.rs)"]
end
subgraph "Forced Alignment"
FU["fa::utr<br/>(batchalign/chat_ops/fa/utr.rs)"] --> FA["FA worker"]
FA --> FP["fa::orchestrate<br/>(batchalign/chat_ops/fa/orchestrate.rs)"]
end
Each pipeline’s invariant is different, but all four define typed outcomes that
can map to one reporting vocabulary, DecisionRecord.
Only FA currently carries that vocabulary through a durable evidence sink.
No CHAT projection. Decisions are recorded and traced, but current BA3 never serializes them into
%xalignor%xrev. LegacyReviewLevelvalues remain accepted only for compatibility. See the Review Tiers guide.
Per-task outcome vocabulary
Morphotag
Each utterance produces exactly one
MorOutcome
with one of three kinds:
classDiagram
class MorOutcome {
+line_idx: usize
+speaker: SpeakerCode
+kind: MorOutcomeKind
+to_decision_record() Option~DecisionRecord~
}
class MorOutcomeKind {
<<enumeration>>
NotApplicable(reason)
Aligned(n_words)
MisalignmentBug(diagnostic)
}
class NotApplicableReason {
<<enumeration>>
Empty
FillerOnly
FragmentOnly
NonwordOnly
UntranscribedOnly
AllRetraced
MixedNonLinguistic
}
class MisalignmentDiagnostic {
+chat_words: Vec~String~
+stanza_tokens_after_mapping: Vec~String~
+expected: MorAlignableWordCount
+actual: MorItemCount
+suspected_class: MisalignmentClass
}
class MisalignmentClass {
<<enumeration>>
RealignmentSkipped
MwtReassemblyBug
TerminatorFilterBug
LanguageDispatchIssue
Unknown
}
MorOutcome *-- MorOutcomeKind
MorOutcomeKind ..> NotApplicableReason
MorOutcomeKind ..> MisalignmentDiagnostic
MisalignmentDiagnostic *-- MisalignmentClass
NotApplicable is the common correct-by-construction case (filler-only
utterances, untranscribed, all-retraced). Aligned is the happy path.
MisalignmentBug is always a pipeline bug, never an expected
divergence, because the 1-to-1 invariant is deterministic by
construction when extraction, Stanza realignment, and MWT reassembly
cooperate. The MisalignmentClass classifier points a developer at
the most likely failing stage; see
Morphotag Reconciliation Invariants.
Utterance segmentation
Utseg’s invariant is simpler: the Python classifier must return exactly one segment assignment per input word. The outcome space reflects that:
classDiagram
class UtsegOutcome {
+utt_ordinal: usize
+speaker: SpeakerCode
+kind: UtsegOutcomeKind
+to_decision_record(line_idx) Option~DecisionRecord~
}
class UtsegOutcomeKind {
<<enumeration>>
NotApplicable(reason)
Aligned(n_words, n_segments)
MisalignmentBug(diagnostic)
}
class UtsegNotApplicableReason {
<<enumeration>>
SingleWord
Empty
}
class UtsegMisalignmentDiagnostic {
+expected_assignments: usize
+actual_assignments: usize
+words: Vec~String~
}
UtsegOutcome *-- UtsegOutcomeKind
UtsegOutcomeKind ..> UtsegNotApplicableReason
UtsegOutcomeKind ..> UtsegMisalignmentDiagnostic
NotApplicable::SingleWord is the one that matters most for clarity:
previously single-word utterances were silently dropped from the batch
(they trivially segment to one segment, so dispatch is wasteful). The
typed outcome records that as a deliberate decision rather than silence.
The production boundary model carries a richer, independently replayable state than the transform outcome alone. Rust refuses to construct an admitted prediction unless the response payload is exclusive, all vectors match the request, the worker’s applied actions follow its declared adjacency policy, and assignments can be rederived from those actions.
classDiagram
class AdmittedUtsegPrediction {
<<enumeration>>
BoundaryModelWorkerDeclared
BoundaryModelLocallyReapplied
UnobservedAssignments
Constituency
}
class UtsegBoundaryModelEvidenceV2 {
+model_id: String
+model_revision: HubCommitV2
+normalization_revision
+adjacency_policy_revision
+word_evidence: Vec
+validate_assignments()
+reapply_adjacency_policy()
}
class UtsegWordBoundaryEvidenceV2 {
<<enumeration>>
Classified(raw, applied, probability)
NormalizationOmission
ModelShortCircuit
}
class LocalUtsegDecisionReceipt {
+worker_policy
+local_policy
+worker_assignments
+suppressed_split_indices
}
AdmittedUtsegPrediction *-- UtsegBoundaryModelEvidenceV2
UtsegBoundaryModelEvidenceV2 *-- UtsegWordBoundaryEvidenceV2
AdmittedUtsegPrediction *-- LocalUtsegDecisionReceipt
The raw action and fixed-point boundary probability are model evidence. The applied action is policy output. A locally replayed policy receives its own receipt, including exact-retrace protections, so an experiment never presents a heuristic decision as a fresh model prediction.
Coreference
Coref has a different shape because it is document-level and sparse: the worker receives all sentences at once and returns annotations only for the subset that actually participates in a chain. Most utterances legitimately produce no annotation.
classDiagram
class CorefOutcome {
+line_idx: usize
+speaker: SpeakerCode
+kind: CorefOutcomeKind
+to_decision_record() Option~DecisionRecord~
}
class CorefOutcomeKind {
<<enumeration>>
NotApplicable
NoChainsForSentence
ChainsInjected(annotation)
SentenceIndexOutOfBounds(sentence_idx, resolved_line_idx)
InjectionFailed(error)
}
CorefOutcome *-- CorefOutcomeKind
NoChainsForSentence is named explicitly so eval reports don’t
misread a sparse-but-correct run as a high-anomaly run.
SentenceIndexOutOfBounds is the worker-contract violation,
always a real bug, and InjectionFailed covers CHAT validation
failures during %xcoref tier construction.
Forced alignment
FA is intentionally different. Unlike morphotag/utseg/coref, a single
utterance passes through three independent decision points (UTR
pre-pass, the FA call itself, the bullet-repair post-pass), any of
which may emit decisions. Collapsing into one variant per utterance
would lose that temporal structure, so FA keeps per-stage typed
records and routes all of them through the shared DecisionRecord:
flowchart TD
U["Utterance"] --> UTR
UTR{"UTR pre-pass<br/>fa::utr::inject_utr_timing"}
UTR -->|"timed"| FA
UTR -->|"unmatched"| UD1["DecisionRecord<br/>Utr::Unmatched"]
UTR -->|"zero-duration skip"| UD2["DecisionRecord<br/>Utr::ZeroDurationSkipped"]
FA{"FA call<br/>alignment::parse_fa_response"}
FA -->|"Ok"| Rep
FA -->|"JsonParse"| FE1["FaAlignmentError::JsonParse"]
FA -->|"IndexedCountMismatch"| FE2["FaAlignmentError::IndexedCountMismatch"]
Rep{"Optional repair post-pass<br/>fa::repair::repair_bullets"}
Rep -->|"gap filled"| FD1["DecisionRecord<br/>Fa::GapFilled"]
Rep -->|"boundary averaged"| FD2["DecisionRecord<br/>Fa::BoundaryAveraged"]
Rep -->|"LIS removal"| FD3["DecisionRecord<br/>Fa::LisRemoval"]
Rep --> Mono{"Typed monotonicity<br/>fa::orchestrate"}
Mono -->|"monotonicity strip"| FD4["DecisionRecord<br/>Monotonicity::TimingStripped"]
Mono -->|"coverage-only clamp"| FD5a["DecisionRecord<br/>Monotonicity::EndClampedCoverageOnly"]
Mono -->|"hull-boundary clamp"| FD5b["DecisionRecord<br/>Monotonicity::EndClampedBoundaryFromWords"]
Mono -->|"word-conflict clamp"| FD5c["DecisionRecord<br/>Monotonicity::EndClampedInterleavedWords"]
Rep -->|"narrow bullet"| FD6["DecisionRecord<br/>Fa::NarrowBulletRescued"]
Rep -->|"words timing dropped"| FD7["DecisionRecord<br/>Fa::WordsTimingDropped"]
FaAlignmentError is a typed error (not a decision record, it’s
returned up the call stack). All other FA events are emitted as
DecisionRecords with typed DecisionStrategy tags. See
fa/outcome.rs
for the single-import bring-in of the FA decision vocabulary.
The DecisionRecord surface
An anomaly outcome can converge on one type, DecisionRecord. FA retains and
traces it; morphotag traces anomaly records; the remaining production sinks are
not complete. No command serializes it into CHAT.
classDiagram
class DecisionRecord {
+line_idx: usize
+speaker: String
+strategy: DecisionStrategy
+reason: String
+needs_review: bool
+evidence_summary() String
+trace() void
}
class DecisionStrategy {
<<enumeration>>
Fa(FaStrategy)
Utr(UtrStrategy)
Monotonicity(MonotonicityStrategy)
Morphosyntax(MorphosyntaxStrategy)
Coref(CorefStrategy)
Utseg(UtsegStrategy)
+module() DecisionModule
+strategy_name() &static str
}
class FaStrategy {
<<enumeration>>
GapFilled
BoundaryAveraged
LisRemoval
TimingStripped
WordsTimingDropped
NarrowBulletRescued
}
class UtrStrategy {
<<enumeration>>
ZeroDurationSkipped
Unmatched
}
class MonotonicityStrategy {
<<enumeration>>
EndClampedCoverageOnly
EndClampedBoundaryFromWords
EndClampedInterleavedWords
TimingStripped
}
class MorphosyntaxStrategy {
<<enumeration>>
NotApplicable
MisalignmentBug
MappingFailed
RetokenizationFailed
InjectionFailed
NlpNoSentences
}
class UtsegStrategy {
<<enumeration>>
NotApplicable
MisalignmentBug
}
class CorefStrategy {
<<enumeration>>
SentenceIndexOutOfBounds
InjectionFailed
}
DecisionRecord *-- DecisionStrategy
DecisionStrategy ..> FaStrategy
DecisionStrategy ..> UtrStrategy
DecisionStrategy ..> MonotonicityStrategy
DecisionStrategy ..> MorphosyntaxStrategy
DecisionStrategy ..> UtsegStrategy
DecisionStrategy ..> CorefStrategy
Why this shape:
- Typos are compile errors. Before,
strategy: "narow_bullet_rescud"would compile and produce a novel label consumers couldn’t match. NowFaStrategy::NarowBulletRescudfails to compile. - Adding a new strategy requires exactly one declaration. The enum
variant and its
as_str()label live in one place; serialization, tracing, and all match arms derive from that single source. - Exhaustive matching is possible. Consumers can write
match strategy { DecisionStrategy::Utseg(s) => … }and trust the compiler to flag missing cases when a new variant is added. - Duplicates across modules are OK by construction.
TimingStrippedexists under bothFaStrategyandMonotonicityStrategybecause both modules legitimately emit it; the outerDecisionStrategydiscriminator distinguishes them. Same forInjectionFailed(Morphosyntax and Coref) andNotApplicable/MisalignmentBug(Morphosyntax and Utseg). - Stable display format retained.
DecisionRecord::evidence_summary()formats"{module}:{strategy} {reason}"for evidence/reporting consumers; it no longer implies CHAT-tier generation.
Outcome → DecisionRecord lifecycle
The per-task outcome is the pipeline-internal vocabulary; DecisionRecord
is the cross-task reporting surface. One canonical flow, traced here
for morphotag, applies by analogy to utseg and coref:
sequenceDiagram
participant U as "Utterance<br/>(main tier)"
participant E as "extract::collect_utterance_content<br/>(Mor domain)"
participant P as "morphosyntax/payloads.rs"
participant W as "Stanza worker<br/>(Python)"
participant I as "morphosyntax/inject.rs"
participant O as "MorOutcome"
participant D as "DecisionRecord"
participant T as "Structured tracing"
U->>E: walk_words(Some(Mor))
E-->>P: N alignable words
alt N == 0
P->>O: NotApplicable { classify_not_applicable() }
else N > 0
P->>W: dispatch batch item
W-->>I: UdResponse with M tokens
alt M == N (aligned after MWT reassembly)
I->>O: Aligned { n_words: N }
else M != N
I->>O: MisalignmentBug(diagnostic with suspected_class)
end
end
O->>D: to_decision_record() (None for Aligned)
D->>T: trace anomaly record
Aligned outcomes produce None from to_decision_record(): the happy
path does not add a decision record. NotApplicable and MisalignmentBug both
produce records, with needs_review=false and
true respectively.
This diagram ends at tracing on purpose. Morphotag’s InjectionResult carries
the records, but the command currently discards that collection after
injection. It must gain a typed command result before this diagram may grow a
durable-evidence participant.
Eval harness observation model
The 19-pair L2 morphotag eval extends this architecture by adding an
external-observation variant
(UtteranceOutcome)
that reads a post-morphotag CHAT file and classifies every utterance
without access to the pipeline’s internal MorOutcome. This is
deliberately asymmetric, the eval sees only what’s written to the
file:
flowchart TD
U["Post-morphotag utterance"] --> C1{"alignable_count == 0<br/>(utt.mor_alignable_word_count)"}
C1 -->|"yes"| C2{"mor_tier present?"}
C2 -->|"no"| NA["NotApplicable<br/>(correct)"]
C2 -->|"yes, items == 0"| NA
C2 -->|"yes, items > 0"| CM1["CountMismatchInFile<br/>(anomaly, %mor in empty utt)"]
C1 -->|"no"| C3{"mor_tier present?"}
C3 -->|"no"| PAF["PipelineAbsorbedFailure<br/>(anomaly: MisalignmentBug absorbed)"]
C3 -->|"yes, items == N"| AL["Aligned<br/>(happy path)"]
C3 -->|"yes, items != N"| CM2["CountMismatchInFile<br/>(anomaly, count mismatch in file)"]
PipelineAbsorbedFailure is the most informative variant: it surfaces
every utterance the pipeline received but silently produced nothing
for. It is visible in the eval’s anomaly_rate column per pair, so
any systemic increase shows up as a corpus-wide regression signal
rather than as a hard-to-spot drop in individual @s-word metrics.
per-pair.csv from the eval now includes five new columns:
outcome_not_applicable, outcome_aligned, outcome_count_mismatch_in_file,
outcome_pipeline_absorbed_failure, anomaly_rate. summary.md
surfaces a dedicated “Per-utterance outcome distribution” section.
Typed counts at the invariant boundary
The morphotag invariant check is written against typed newtypes rather
than usize so that a refactor cannot accidentally swap
“Mor-alignable CHAT word count” and “%mor item count”:
MorAlignableWordCount, whatUtterance::mor_alignable_word_count()returns.MorItemCount, whatmor_tier.items.len()measures.
Both live in talkbank-model::alignment::helpers::count alongside the
existing count_tier_positions walker. The canonical N lives on
Utterance itself so every caller (morphotag injector, eval harness,
CHAT validators) consults the same source, enforced by an integration
test that walks the full 98-file reference corpus and asserts
agreement between the method and the extract::collect_utterance_content
walker.
Source pointers
Core outcome types:
crates/batchalign-transform/src/morphosyntax/outcome.rs:MorOutcome,MisalignmentDiagnostic,classify_not_applicablecrates/batchalign-transform/src/utseg.rs:UtsegOutcome,validate_utseg_responsecrates/batchalign-transform/src/coref.rs:CorefOutcome,apply_coref_results_with_outcomescrates/batchalign/src/chat_ops/fa/outcome.rs: FA decision vocabulary (re-exports)crates/batchalign/src/chat_ops/fa/alignment.rs:FaAlignmentError(typed error for FA response parsing)crates/batchalign-transform/src/decisions.rs:DecisionRecord,DecisionStrategy, all per-module strategy enums
Invariant enforcement:
crates/batchalign-transform/src/inject.rs,inject_morphosyntax(returnsResult<(), MisalignmentDiagnostic>)batchalign/inference/morphosyntax.py, realignment-skipped WARN at the Python boundarychatter/crates/talkbank-model/src/alignment/helpers/count.rs,MorAlignableWordCount/MorItemCountnewtypes andcount_tier_positionswalker
Tests that pin the architecture:
crates/batchalign/tests/mor_count_parity_reference_corpus.rs, cross-walker count parity across the 98-file reference corpusbatchalign/tests/inference/test_morphosyntax_realignment_contract.py, Python contract test pinningtok_ctx.original_wordssequencingcrates/batchalign-transform/src/morphosyntax/outcome.rs#[cfg(test)]: per-variant classification testscrates/batchalign/src/eval_cmd/l2_morphotag/tests.rs,UtteranceOutcomeclassifier truth table
Deep-dive pages:
Morphotag Reconciliation Invariants, the 1-to-1 invariant in full: whatcounts_for_tierdefines as alignable, and why the three stages produce it by construction.
How to investigate a morphotag misalignment decision
When logs report module=morphosyntax strategy=misalignment_bug, use this
flow:
- Read the
suspected_classfield in thereason. Five values (RealignmentSkipped,MwtReassemblyBug,TerminatorFilterBug,LanguageDispatchIssue,Unknown) each point at a different stage. - Compare
chat_wordsandstanza_tokens_after_mappingalso in thereason. The word/token sequences usually show where they diverged, e.g. a comma dropped, an MWT split wrongly reassembled. - Check the Python worker log for a realignment-skipped WARN for
the same file/language, if present, the dispatch-side context
wasn’t set and the
RealignmentSkippedclass is concrete. - Use the typed line index and speaker in the trace to locate the
utterance.
--review-levelis a legacy compatibility option and does not create a CHAT diagnostic tier.
If the repro is reliable, add a failing regression test in
batchalign using chatter trim to produce a minimal
fixture from the affected real file. The trace’s typed strategy name maps
directly to the enum variant for pattern matching in the assertion.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Morphotag Reconciliation Invariants
Status: Current Last updated: 2026-08-30 19:35 EDT
This page documents the 1-to-1 invariant that the morphotag pipeline relies on, the three stages that together make it hold deterministically, the two legitimate modes that intentionally skip it, and the typed outcome model that replaces the old silent-skip pattern.
The invariant
For every CHAT utterance the pipeline visits:
Post-mapping, the number of
%moritems equals the number of Mor-alignable words on the main tier.
“Mor-alignable” is defined by
counts_for_tier(word, TierDomain::Mor)
in talkbank-model. It is the authoritative CHAT policy: regular words
count, replacement words count, tag-marker separators (comma ,,
tag „, vocative ‡) count; fillers (&-hmm), nonwords (&~uh),
phonological fragments (&+le), untranscribed material (xxx, yyy,
www), omissions, retrace content, and utterance terminators do not.
The canonical count is available as
Utterance::mor_alignable_word_count() on the
talkbank_model::model::Utterance type. Any pipeline stage that
validates “dependent tier count matches main tier content” must call
this method. Duplicating the walk locally risks drift from CHAT
policy and from sibling implementations: two copies of a word-counting
rule are two places the rule can silently drift out of sync.
Why this can hold by construction
Three independent pipeline stages cooperate to make the invariant
deterministic. When each does its job, |mors| == N without any
post-hoc alignment.
flowchart TD
U["Utterance U<br/>(main tier)"] --> E
E["Stage 1: Extract<br/>collect_utterance_content(Mor)<br/>(../chatter/crates/talkbank-transform/src/extract.rs)"] --> Nlabel
Nlabel{{"N Mor-alignable words<br/>(counts_for_tier rule)"}}
Nlabel -->|"N == 0"| NA["MorOutcome::NotApplicable<br/>no %mor produced (correct)"]
Nlabel -->|"N > 0"| D
D["Stage 2: Dispatch<br/>tok_ctx.original_words = word_lists<br/>(_realignment_applied in batchalign/inference/morphosyntax.py)"] --> S
S["Stanza neural tokenizer<br/>re-tokenizes text, realigning<br/>to word_lists boundaries"] --> P
P["Stage 3: Project<br/>map_ud_sentence + MWT Range reassembly<br/>(crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs)"] --> M
M{{"|mors| =?= N"}}
M -->|"yes"| OK["MorOutcome::Aligned<br/>inject %mor + %gra"]
M -->|"no"| BUG["MorOutcome::MisalignmentBug<br/>typed diagnostic; loud, investigable"]
Stage 1 produces N from the CHAT side. Stage 2 tells Stanza “use
these N word boundaries when you re-tokenize the combined text.” Stage 3
takes Stanza’s UD output and reassembles MWT ranges (don't → do + n't)
back into one %mor chunk per CHAT word.
Each stage’s correctness is independently testable:
- Stage 1: the parity test at
crates/batchalign/tests/chat_ops_mor_count_parity_reference_corpus.rsasserts thatUtterance::mor_alignable_word_count()andextract::collect_utterance_content(..., Mor, ...).len()agree on every utterance in the 98-file reference corpus. - Stage 2: the contract test at
batchalign/tests/inference/test_morphosyntax_realignment_contract.pyasserts thattok_ctx.original_words = word_listshappens before everynlp()call in normal mode, and is empty under--retokenize. - Stage 3: unit tests in
nlp/mapping/mod.rsexercise MWT reassembly across French (du → de + le), English (don't → do + n't), German (im → in + dem), Italian, Portuguese, Dutch, and a comma regression test that locks the mid-utterance-comma handling.
When a count mismatch nevertheless surfaces at the injection boundary, it is always a bug in one of those three stages: never an expected divergence class to be resolved by after-the-fact alignment.
The two legitimate non-realignment modes
Two documented modes intentionally skip the realignment step because they want Stanza to own tokenization:
| Mode | Trigger | Why skip realignment |
|---|---|---|
| CJK retokenize | Mandarin (zho/cmn) with retokenize=True; use_retok_pipeline=True in morphosyntax.py | Chinese has no whitespace word boundaries; Stanza’s neural segmenter produces the correct word boundaries for Chinese, and CHAT’s main tier is rewritten to match. |
| Generic retokenize | Any language with --retokenize CLI flag; req.retokenize=True | The user has asked the pipeline to expand MWTs (gonna → going + to) and rewrite the CHAT main tier accordingly. Stanza must own tokenization for that. |
In both modes, the 1-to-1 invariant is not violated, it simply
operates at the Stanza-token level instead of the CHAT-word level.
The main tier gets rewritten to match Stanza’s output, and the
resulting %mor count equals the rewritten word count by construction.
(The current implementation of this rewrite lives in
crates/batchalign-transform/src/retokenize.rs plus the
crates/batchalign-transform/src/retokenize/ sub-modules.)
MorOutcome: the typed outcome vocabulary
Every utterance the pipeline visits produces exactly one
MorOutcome
with one of three kinds. This replaces the previous silent-skip
behavior that previously let an upstream regression mask itself as
silent %mor loss.
| Kind | Meaning | Surfaces as |
|---|---|---|
NotApplicable { reason } | The utterance had zero Mor-alignable words. No %mor is produced, and that is correct. Reasons: FillerOnly, FragmentOnly, NonwordOnly, UntranscribedOnly, AllRetraced, MixedNonLinguistic, Empty. | Typed morphosyntax:not_applicable record. |
Aligned { n_words } | N CHAT words, N %mor items; happy path. | No anomaly record. |
MisalignmentBug(diag) | ` | mors |
No review_level value writes %xalign or %xrev. The typed outcomes exist
and anomaly records are traced, but morphotag does not yet persist the collected
records in a per-file evidence sidecar. See
Decision Evidence for the current sink
matrix and the Review Tiers guide for the
CHAT policy.
The MisalignmentClass classifier (best-effort) points developers at
the most likely failing stage:
RealignmentSkipped: Stanza’s tokenizer-realignment context wasNonefor the dispatch language; Stanza ran without boundary hints.MwtReassemblyBug: the UD→Mor projection consumed the wrong number of tokens during MWT Range expansion.TerminatorFilterBug:is_terminator_punctdropped too many or too few PUNCT tokens, e.g. dropping mid-utterancecm|cmseparators that CHAT counts as alignable.LanguageDispatchIssue: per-language chunk of a code-switched utterance disagreed with the CHAT main-tier count.Unknown: diagnostic alone is insufficient; developer must inspect.
What this architecture explicitly does not do
- It does not add a DP alignment layer between Stanza output and
CHAT words. That would paper over bugs in the three stages rather
than fix them. Batchalign2 did this (character-level DP inside
Stanza’s
tokenize_postprocessor) and still silently skipped residual mismatches; the new architecture replaces both halves with a built-in Stanza realignment + typed outcomes. - It does not change the CHAT Mor-alignable policy. If a future
CHAT-manual-approved decision includes fillers in
%mor, that is a change tocounts_for_tierintalkbank-model, propagated throughmor_alignable_word_count()to every caller. Pipeline-internal heuristics are banned. - It does not reduce observability on the happy path.
Alignedoutcomes do not produce decision tiers by default, morphotag users see exactly the same output as before for every successful utterance.
See also
crates/batchalign-transform/src/morphosyntax/outcome.rs: outcome typescrates/batchalign-transform/src/inject.rs: invariant check & outcome emissioncrates/batchalign-transform/src/morphosyntax/payload.rs: NotApplicable classificationbatchalign/inference/morphosyntax.py: realignment stagebatchalign/tests/inference/test_morphosyntax_realignment_contract.py: Stage 2 testscrates/batchalign/tests/chat_ops_mor_count_parity_reference_corpus.rs: Stage 1 teststalkbank-tools/../chatter/crates/talkbank-model/src/alignment/helpers/rules.rs:counts_for_tier
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Type-Driven Design
Status: Current Last updated: 2026-09-15 18:27 EDT
Batchalign uses Rust’s type system to encode domain invariants at compile time. This document catalogs the patterns in use, explains when to reach for each one, and records the serde techniques that keep the wire format stable while the internal types evolve.
Patterns
1. Domain Identifier Newtypes (string_id! / numeric_id!)
Problem: Functions with signatures like fn submit(job_id: &str, command: &str, lang: &str, filename: &str) are impossible to read at a glance, every parameter is &str. Swapping arguments compiles silently.
Solution: Zero-cost newtypes generated by two macros in
crates/batchalign-types/src/macros.rs.
// crates/batchalign-types/src/domain.rs
string_id!(
/// Server-assigned UUID (v4) for a job.
pub JobId
);
numeric_id!(
/// Duration measured in milliseconds.
pub DurationMs(u64) [Eq]
);
string_id! generates: Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, Display, From<String>, From<&str>, Into<String>, Deref<Target=str>, AsRef<str>, PartialEq<&str>, Borrow<str>, Default. The Deref<Target=str> impl enables gradual migration, callers that need &str auto-coerce.
numeric_id! generates the same set adapted for numeric inner types. Append [Eq] for integer types that need Eq + Hash.
All generated types use #[serde(transparent)]: the wire format stays as bare strings or numbers.
Complete inventory:
| Type | Inner | Defined in | Domain |
|---|---|---|---|
JobId | String | crates/batchalign-types/src/domain.rs | Job identity |
CommandName | String | crates/batchalign-types/src/domain.rs | Batchalign command ("morphotag", "align") |
ReleasedCommand | enum | crates/batchalign-types/src/domain.rs | Closed released command vocabulary |
LanguageCode3 | String | crates/batchalign-types/src/domain.rs | Validated ISO 639-3 code (3 ASCII alpha, lowercased) |
LanguageSpec | enum | crates/batchalign-types/src/domain.rs | Auto or Resolved(LanguageCode3): language at job boundary |
DisplayPath | String | crates/batchalign-types/src/domain.rs | Display-oriented file path within a job ("sample.cha", "subdir/sample.cha") |
NodeId | String | crates/batchalign-types/src/domain.rs | Server/fleet node identity |
BuildOwnedNamespace | Cow<'static, str> | crates/batchalign/src/cache/mod.rs | The one representation behind the cache namespace and revisions this build owns: UtrAsrCacheNamespace, RevAsrModelRevision, SpeakerEvidenceModelRevision and SpeakerNormalizationRevision. Built as a literal (const fn literal) or derived from literals and files compiled into the binary (derived). Each wrapper keeps its own newtype, so a cache task constant still refuses one where another belongs. It replaced EngineVersion, a worker-version type deleted from batchalign-types once nothing used it |
StampSafeText | Cow<'static, str> | crates/batchalign-types/src/domain.rs | Text that cannot change a provenance stamp’s structure. Refuses blank text, surrounding whitespace (StampSafeText::WHITESPACE, the Unicode White_Space set) and the stamp structure characters (StampSafeText::STAMP_STRUCTURE: vertical bar, semicolon, closing bracket, newline, carriage return), with InvalidStampSafeText. Routes in: TryFrom (also serde), const fn from_static (a literal checked at compile time inside const { }) and join with a StampJoiner (Concat, Plus, Colon, At). Its JSON Schema pattern is generated from the same two lists. Provenance’s StampFieldValue wraps it |
ReportedEngineName | StampSafeText | crates/batchalign-types/src/domain.rs | An engine identity a worker reported, in a capability report or on a result. A wrapper over StampSafeText; the only constructor is TryFrom, shared by deserialization, which applies the StampSafeText check; there is no infallible From |
CorrelationId | String | crates/batchalign-types/src/domain.rs | Cross-service tracing ID |
NumSpeakers | u32 | crates/batchalign-types/src/domain.rs | Speaker count for diarization |
DurationSeconds | f64 | crates/batchalign-types/src/domain.rs | Duration in seconds |
UnixTimestamp | f64 | crates/batchalign-types/src/domain.rs | Epoch timestamp |
DurationMs | u64 | crates/batchalign-types/src/domain.rs | Duration in milliseconds |
MemoryMb | u64 | crates/batchalign-types/src/domain.rs | Memory amount in megabytes |
WorkerPid | u32 | crates/batchalign-types/src/worker.rs | OS process ID |
AsrTimestampSecs | enum | crates/batchalign-transform/src/asr_postprocess/asr_types.rs | An ASR provider’s endpoint in seconds: Observed(f64), including a real zero, or Absent. Serialized untagged, so it crosses the wire as a number or as null; an absent endpoint becomes an untimed word rather than time zero |
SpeakerIndex | usize | crates/batchalign-transform/src/asr_postprocess/asr_types.rs | Zero-based speaker index in a recording |
When to use: Any String or number that identifies a domain concept. If a parameter name is needed to understand what the type represents, it should be a newtype.
2. Validated Newtypes and Sentinel Enums
Problem: LanguageCode3 was a string_id! newtype that accepted any string, including "auto", "", "lol". When the CLI passed --lang auto, the sentinel leaked through the entire pipeline and produced @Languages: auto in the CHAT header (job 696870c7-02b).
Solution: Two complementary types that make the sentinel impossible to confuse with a real value.
LanguageCode3: a validated newtype (not string_id!). Construction rejects anything that isn’t exactly 3 ASCII letters:
impl LanguageCode3 {
pub fn try_new(s: &str) -> Result<Self, InvalidLanguageCode> {
if s.len() == 3 && s.bytes().all(|b| b.is_ascii_alphabetic()) {
Ok(Self(s.to_ascii_lowercase()))
} else {
Err(InvalidLanguageCode(s.to_string()))
}
}
}
From<&str> keeps a debug_assert! for test-time safety. Deserialize validates and rejects bad codes.
LanguageSpec: a sentinel enum at the CLI/job boundary:
pub enum LanguageSpec {
Auto,
Resolved(LanguageCode3),
}
Serde: "auto" → Auto (case-insensitive), any valid 3-letter code → Resolved. Invalid strings are rejected at deserialization.
Where each type lives:
| Type | Used by | Not used by |
|---|---|---|
LanguageSpec | JobSubmission.lang, JobDispatchConfig.lang, RunnerDispatchConfig.lang, JobInfo.lang, JobListItem.lang | Worker IPC, cache keys, MorphosyntaxParams, TranscribeOptions |
LanguageCode3 | Worker IPC, cache keys, MorphosyntaxParams, FA params, all domain-internal language references | , |
Resolution: LanguageSpec::Auto is resolved to a concrete LanguageCode3 at two points:
- Dispatch layer:
resolve_or(&fallback)for commands that need a known language (FA, morphotag, compare). - Transcribe pipeline:
stage_build_chatusesAsrResponse.lang(the ASR engine’s detected language) whenopts.lang == "auto".
When to use this pattern: Any domain identifier that has a sentinel/wildcard value ("auto", "all", "*") that must not leak into output. Split the sentinel into an enum variant and validate the concrete type on construction.
3. Provenance Newtypes
Problem: Bare String fields don’t say where a value came from or what transformations it underwent. Swapping “cleaned text” for “raw text” compiles silently and corrupts output.
Solution: Wrap each provenance in a zero-cost newtype.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[repr(transparent)]
pub struct ChatCleanedText(String);
| Type | Source | Lives in |
|---|---|---|
ChatRawText | Word::raw_text() | ../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction) |
ChatCleanedText | Word::cleaned_text() | ../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction) |
SpeakerCode | Utterance.speaker | ../chatter/crates/talkbank-model/src/model/header/codes/speaker.rs (CHAT direction) |
AsrRawText | ASR provider output | crates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction) |
AsrNormalizedText | After 8-stage pipeline | crates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction) |
ChatWordText | ASR-to-CHAT boundary | crates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction) |
Attributes:
#[repr(transparent)]: zero runtime cost, identical layout to innerString.#[serde(transparent)]: serializes as a plain JSON string, invisible at the wire boundary.- Simple newtypes, not phantom-type generics.
When to use: Any String field where two values of different provenance could be confused.
Path provenance: The path newtypes (ClientPath, ServerPath, RepoRelativePath, MediaMappingKey) extend this pattern to encode machine boundaries – which side of the client/server divide a value lives on. ClientPath deliberately omits AsRef<Path> to prevent accidental I/O. See the typed path provenance page for the full design.
4. Flattened Struct Composition
Problem: Command option structs tend to accumulate duplicated common fields, which makes request types noisy and encourages copy/paste drift between command families.
Solution: Factor the shared fields into a dedicated struct and flatten it at the wire boundary so JSON stays ergonomic while Rust keeps explicit grouping.
pub struct MorphotagOptions {
#[serde(flatten)]
pub common: CommonOptions,
pub retokenize: bool,
pub skipmultilang: bool,
pub merge_abbrev: bool,
}
Serialized JSON stays flat even though the Rust model is grouped:
{"command":"morphotag","clean":false,"verbose":false,"retokenize":false}
This pattern now shows up across command option structs in
crates/batchalign/src/types/options.rs, where one CommonOptions payload
is reused by multiple command-specific types.
align applies the same pattern at two boundaries. The CLI groups UTR
selection/compatibility flags separately from UTR algorithm tuning and groups
word-boundary policies separately from unrelated command flags. Lowering then
produces persisted AlignUtrOptions and AlignBoundaryOptions values. Serde
flattening preserves the existing flat job JSON, so the internal type model can
be made harder to misuse without breaking stored jobs or clients.
flowchart LR
A[AlignArgs] --> S[AlignUtrSelectionArgs]
A --> T[AlignUtrTuningArgs]
A --> B[AlignBoundaryArgs]
S --> U[AlignUtrOptions]
T --> U
B --> P[AlignBoundaryOptions]
U --> O[AlignOptions]
P --> O
O --> F[Typed FA dispatch and projection policies]
This is more than cosmetic organization: constructors must supply each policy group explicitly, while compatibility tests verify that CLI spelling and the serialized wire shape remain unchanged.
When to use: Shared wire fields that belong together semantically but should not introduce extra nesting in request/response JSON.
5. State Machine Enums
Problem: Job and file status are strings with implicit transition rules. Typos like "compelted" or illegal transitions (cancelled → running) are only caught by downstream code.
Solution: A closed enum with predicate methods that encode the state machine.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Queued,
Running,
Completed,
Failed,
Cancelled,
Interrupted,
}
impl JobStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
pub fn is_active(self) -> bool {
matches!(self, Self::Queued | Self::Running)
}
pub fn can_cancel(self) -> bool {
self.is_active()
}
pub fn can_restart(self) -> bool {
matches!(self, Self::Failed | Self::Cancelled | Self::Interrupted)
}
}
FileStatusKind follows the same pattern with Queued, Processing, Done, Error, Interrupted.
Serde note: #[serde(rename_all = "lowercase")] maps Completed → "completed" for Python compatibility. Display and FromStr impls mirror this for logs and CLI output.
When to use: Any status/phase field with a finite set of legal values and transition rules.
6. Configuration Enums
Problem: A boolean flag like force_cpu: bool cannot express a third
state, and every knob that starts as two values tends to grow a third.
Solution: A small enum with serde rename.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryTierKind {
Small,
Medium,
Large,
Fleet,
}
Each variant names a real host class and carries its own bootstrap decision, so
adding a fifth tier forces every match over it to state an answer. A boolean
constrained: bool would have collapsed Small and Medium, which differ in
exactly the way that matters (task bootstrap versus lazy-profile bootstrap).
When to use: Any configuration knob with more than two values, or where the two-value case may grow.
Macro Boundaries
Macros are part of the type story, but they are not the architecture.
The current policy is:
- keep macros only for stable mechanical boilerplate
- keep one canonical macro definition for shared domain newtypes in
batchalign-types/src/macros.rs - do not duplicate macro layers in app crates just because it is convenient
- do not use macros to hide validation, sentinel semantics, or workflow policy
This is why:
string_id!/numeric_id!are appropriate for transparent newtype boilerplateLanguageCode3is not astring_id!because it needs real validationLanguageSpecandWorkerLanguageare enums, not macro-generated string wrappers, because they encode sentinel semantics
The broader rule is simple: macros should compress repetition after the domain shape is already correct. They should not be used as a substitute for deciding what the correct domain shape is.
Boundary Conversion Patterns
Newtypes are enforced in domain code. At system boundaries where external types are required, explicit conversion happens once.
Route Handlers (HTTP → Domain)
Axum path extractors produce String. Convert immediately at the handler entry point:
pub(crate) async fn get_job(
State(state): State<Arc<AppState>>,
Path(job_id): Path<String>, // axum gives String
) -> Result<Json<JobInfo>, ServerError> {
let job_id = JobId::from(job_id); // convert once
state.store.get(&job_id).await // domain code uses &JobId
.map(Json)
.ok_or_else(|| ServerError::JobNotFound(job_id))
}
Database Boundary (Domain → SQL)
SQLite bindings need &str. The Deref<Target=str> impl on string_id! types makes this transparent, pass &job_id and deref coercion handles it:
sqlx::query("UPDATE jobs SET status = ? WHERE job_id = ?")
.bind(&status_str)
.bind(&*job_id) // JobId → &str via Deref
.execute(pool).await
IPC / JSON Serialization (Domain → Python Worker)
Python workers communicate via JSON-lines. File paths use Path::to_string_lossy() at the serialization boundary:
let audio_path_str = audio_path.to_string_lossy(); // &Path → Cow<str>
let item = serde_json::json!({
"audio_path": &*audio_path_str,
"lang": &*lang, // LanguageCode3 → &str via Deref
});
File Paths (Path / PathBuf vs String)
Audio paths use std::path::Path/PathBuf in Rust domain code. Since Path derefs to OsStr (not str), conversion to strings requires explicit .to_string_lossy() or .display() at boundaries:
| Context | Pattern |
|---|---|
| JSON/IPC serialization | path.to_string_lossy().into_owned() |
| Tracing/logging | audio_path = %path.display() |
| Format strings | format!("{}", path.display()) |
Passing to &str functions | path.to_str().unwrap_or("") |
HashMap Key Lookups
string_id! generates Borrow<str>, so HashMap<JobId, Job>::get(job_id) works directly when job_id: &JobId. For maps keyed by String that receive a newtype, use deref: map.get(&*filename) or map.get::<str>(&filename).
CLI Flags → Enums (From<bool>)
Boolean CLI flags convert to domain enums once at the dispatch layer:
// dispatch layer
let cache_policy = CachePolicy::from(opts.override_media_cache); // bool → enum
let wor_tier = WorTierPolicy::from(opts.write_wor);
// orchestrator: never sees booleans
process_fa(chat_text, audio, services, &FaParams {
cache_policy,
wor_tier,
..
})
Serde Techniques Reference
| Goal | Attribute | Example |
|---|---|---|
| Newtype as plain value | #[serde(transparent)] | ChatCleanedText → "hello" |
| Flatten inner struct/enum | #[serde(flatten)] on field | MorphotagOptions.common |
Skip None | #[serde(skip_serializing_if = "Option::is_none")] | Optional fields |
| Lowercase variants | #[serde(rename_all = "lowercase")] | JobStatus |
| Default on missing | #[serde(default)] or #[serde(default = "fn")] | worker/timing fields |
Key constraint: All IPC types in crates/batchalign-types/src/worker.rs and
the crates/batchalign-types/src/worker_v2/ module (re-exported via
crates/batchalign/src/types/worker.rs and crates/batchalign/src/types/worker_v2.rs)
must produce JSON identical to the Python Pydantic models. Snapshot tests in
crates/batchalign/tests/json_compat.rs (with fixtures under
crates/batchalign/tests/snapshots/json_compat__*.snap) enforce this, if a
serde attribute changes the wire format, the snapshot diff will catch it.
Decision Record
| Date | Change | Pattern | Rationale |
|---|---|---|---|
| 2026-02 | Text provenance newtypes | Provenance | Bugs from mixing raw/cleaned text; CHAT-direction types now in ../chatter/crates/talkbank-model/src/text_types.rs, ASR-direction types in crates/batchalign-transform/src/asr_postprocess/asr_types.rs |
| 2026-02 | JobStatus / FileStatusKind enums | State machine | Replaced stringly-typed status fields; commit cbe0f873 |
| 2026-02 | Flattened command option groups | Struct composition | Reduced duplication while keeping flat JSON across command request types |
| 2026-03 | string_id! / numeric_id! macros | Domain identifier | Eliminated primitive obsession across server codebase; 13 newtypes |
| 2026-03 | CachePolicy, WorTierPolicy enums | Boolean blindness | Replaced ambiguous override_media_cache: bool, write_wor: bool |
| 2026-03 | MorphosyntaxParams, FaParams, AudioContext, PipelineServices | Parameter grouping | Reduced orchestrator signatures from 14-16 params to 3-6 |
| 2026-03 | &Path/PathBuf for audio paths | Path types | Replaced &str/String for file paths; explicit conversion at IPC boundaries |
| 2026-03 | Boundary conversion patterns | All | Codified convert-once-at-boundary: HTTP→JobId, DB→deref, IPC→to_string_lossy, CLI→From<bool> |
| 2026-03 | LanguageSpec enum + validated LanguageCode3 | Sentinel enum | "auto" sentinel leaked into @Languages header (job 696870c7); split into Auto/Resolved enum with validated construction |
| 2026-03 | ClientPath, ServerPath, RepoRelativePath, MediaMappingKey | Path provenance | Untyped paths allowed mixing client/server filesystems; ClientPath omits AsRef<Path> to prevent accidental I/O; details |
Guidelines
- Default to newtypes for any
Stringor number that has domain meaning beyond “some text” or “some count.” Usestring_id!ornumeric_id!: never hand-roll the boilerplate. - Use
Path/PathBuffor file system paths, neverString/&str. Convert to strings only at IPC/JSON boundaries viato_string_lossy(). - Prefer enums over booleans when a third option is plausible or the boolean’s name doesn’t clearly convey both states.
- Convert at the boundary, once. Raw
Stringfrom HTTP extractors →JobId::from()immediately.boolfrom CLI flags →CachePolicy::from()in the dispatch layer. Interior code never handles raw primitives for typed values. - Use
#[serde(untagged)]+#[serde(flatten)]to introduce ADTs without breaking wire format. Always verify with snapshot tests. - Add predicate methods (
is_terminal(),can_cancel()) on state enums, they centralize transition logic and makematchexhaustiveness work for you. - Keep newtypes simple:
#[serde(transparent)]struct with a single field. No phantom types, no generics. Thestring_id!macro generatesDeref<Target=str>for zero-friction migration. - Boundary rule: Newtypes live inside Rust. At JSON/Python boundaries, they serialize transparently (newtypes via
#[serde(transparent)]) or structurally (enums via#[serde(untagged)]/rename_all). Python never sees the Rust type names. - Function signatures must be self-documenting through types. If you need to read the parameter name to understand what a
&strargument represents, it should be a newtype.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Typed Path Provenance
Status: Current Last updated: 2026-05-19 20:22 EDT
Paths in batchalign3 cross machine boundaries: a client submits paths from
their filesystem, the server resolves media on its own filesystem (potentially
different mount points), and media mappings translate logical repo names to
physical volume roots. Untyped String/PathBuf allowed mixing client and
server paths, causing repeated media resolution failures. The path newtype
system makes provenance explicit so the compiler prevents these bugs.
The Problem
The batchalign3 server accepts job submissions from both local daemons (same
machine, shared filesystem) and remote clients (different machines, different
mount points). Before path newtypes, both cases used bare String or PathBuf:
#![allow(unused)]
fn main() {
// BEFORE: which machine is this path on?
pub struct JobSubmission {
pub source_dir: String, // Client's input dir, but server can't read it
pub media_mapping: String, // Logical name, not a path at all
pub source_paths: Vec<String>, // Client paths, must not be opened on server
}
}
This caused bugs where the server attempted filesystem I/O on a client’s path (which did not exist on the server), or where a logical mapping key was accidentally used as a filesystem path.
The Solution: Four Path Newtypes
All defined in crates/batchalign-types/src/paths.rs:
classDiagram
class ClientPath {
-String inner
+as_str() &str
+assume_shared_filesystem() ServerPath
+contains_component(component) bool
+suffix_after_component(component) Option~&str~
}
note for ClientPath "Does NOT impl AsRef~Path~\nPrevents accidental filesystem I/O"
class ServerPath {
-PathBuf inner
+as_path() &Path
+join(component) ServerPath
+as_str() &str
}
note for ServerPath "Impls AsRef~Path~\nSafe for tokio::fs, std::fs"
class RepoRelativePath {
-String inner
+resolve_on_server(root: &ServerPath) ServerPath
+join(sub) RepoRelativePath
+as_str() &str
}
note for RepoRelativePath "Relative to a data repo root\nMachine-independent"
class MediaMappingKey {
-String inner
+as_str() &str
}
note for MediaMappingKey "Logical name, not a path\ne.g. 'slabank-data'"
ClientPath ..> ServerPath : assume_shared_filesystem()
RepoRelativePath ..> ServerPath : resolve_on_server()
MediaMappingKey ..> ServerPath : lookup in config
ClientPath
A path on the submitting client’s filesystem. The server receives this as metadata but must not do filesystem I/O on it directly.
Key invariant: ClientPath deliberately does NOT implement AsRef<Path>.
This means passing a ClientPath to tokio::fs::read_to_string() or
std::fs::metadata() is a compile error. The compiler enforces the boundary.
The only sanctioned conversion to ServerPath is:
pub fn assume_shared_filesystem(&self) -> ServerPath
This asserts that the server shares the client’s filesystem (true when the
server is a local daemon on the same machine). Callers must verify this
precondition – using it on a remote client’s path produces a ServerPath
that points to a nonexistent location.
ClientPath also provides string-level inspection methods for media mapping
inference:
contains_component("slabank-data")– checks if a repo name appears as a path componentsuffix_after_component("slabank-data")– extracts the repo-relative portion (e.g.,"French/Newcastle/Photos")
These are pure string operations that never touch the filesystem.
ServerPath
A path on the server’s filesystem, safe for I/O. Implements AsRef<Path>
so it can be passed directly to filesystem operations:
let server_path: ServerPath = /* ... */;
let contents = tokio::fs::read_to_string(&server_path).await?;
Created from:
ClientPath::assume_shared_filesystem()(shared-filesystem assertion)RepoRelativePath::resolve_on_server(&root)(combining a relative path with a server root)ServerPath::new(pathbuf)(direct construction from a known server path)- Media mapping config deserialization (volume roots in
server.yaml)
RepoRelativePath
A path relative to a data repository root (e.g., "French/Newcastle/Photos/13").
This is machine-independent – it is valid on any machine that has the
repository cloned.
Must be combined with a ServerPath root to produce an absolute server path:
let root = ServerPath::new("/srv/talkbank/slabank");
let rel = RepoRelativePath::new("French/Newcastle/Photos/13");
let abs = rel.resolve_on_server(&root);
// → /srv/talkbank/slabank/French/Newcastle/Photos/13
MediaMappingKey
A logical name that maps to a ServerPath via the server’s media_mappings
configuration. Not a filesystem path at all – it is an index into a
BTreeMap<MediaMappingKey, ServerPath> in ServerConfig.
Examples: "slabank-data", "childes-eng-na-data", "aphasia-data".
Data Flow: Media Resolution
The FA pipeline (runner/dispatch/fa_pipeline.rs) resolves audio files through
a multi-step cascade. The path newtypes make each step’s provenance explicit.
flowchart TD
subgraph Client["Client Machine"]
submit["JobSubmission\n(source_dir: ClientPath)"]
end
subgraph Server["Server (fa_pipeline.rs)"]
step1{"--media-dir\nprovided?"}
step2{"paths_mode?\n(shared filesystem)"}
step3{"media_mapping key\nin config?"}
step4{"auto-infer mapping\nfrom ClientPath?"}
step5["media_roots\nfallback search"]
convert["assume_shared_filesystem()\nClientPath → ServerPath"]
infer["infer_media_mapping()\n(paths.rs)"]
resolve["resolve_on_server()\nRepoRelativePath + ServerPath"]
found["ServerPath\n(safe for I/O)"]
end
submit --> step1
step1 -->|"yes"| found
step1 -->|"no"| step2
step2 -->|"yes"| convert --> found
step2 -->|"no"| step3
step3 -->|"yes"| resolve --> found
step3 -->|"no"| step4
step4 -->|"match"| infer --> resolve
step4 -->|"no match"| step5 --> found
Auto-Inference: infer_media_mapping()
When no explicit media_mapping key is provided, the server auto-infers the
mapping from the client’s source directory path. This is the function in
crates/batchalign-types/src/paths.rs:
pub fn infer_media_mapping<'a>(
client_dir: &ClientPath,
mappings: impl IntoIterator<Item = (&'a MediaMappingKey, &'a ServerPath)>,
) -> Option<(MediaMappingKey, ServerPath, RepoRelativePath)>
It checks whether any key in mappings appears as a path component in
client_dir. Example:
client_dir:/Users/operator/chat-data/slabank-data/French/Newcastle/Photosmappings:{"slabank-data" → "/srv/talkbank/slabank"}- Returns:
("slabank-data", "/srv/talkbank/slabank", "French/Newcastle/Photos")
This is a pure string operation on ClientPath – it never touches the
filesystem. The suffix_after_component() method extracts the repo-relative
portion, which becomes a RepoRelativePath.
Where Path Types Are Used
| Type | Used in | Field / Parameter |
|---|---|---|
ClientPath | JobSubmission | source_dir, source_paths, output_paths, before_paths |
ClientPath | JobMetadata | source_dir |
ClientPath | JobFilesystemConfig | source_dir |
MediaMappingKey | JobSubmission | media_mapping |
MediaMappingKey | ServerConfig | media_mappings (key) |
ServerPath | ServerConfig | media_roots, media_mappings (value) |
RepoRelativePath | JobSubmission | media_subdir |
RepoRelativePath | FA pipeline | Inferred corpus subdirectory |
Design Decisions
Why ClientPath stores String, not PathBuf
Client paths arrive as JSON strings over HTTP. They may reference Windows paths
(C:\Users\...) on a macOS server. PathBuf on the server would normalize
path separators, potentially corrupting the client’s path. String preserves
the exact bytes the client sent.
Why ServerPath stores PathBuf, not String
Server paths are used for actual filesystem I/O. PathBuf integrates with
std::fs, tokio::fs, and the Path trait ecosystem. The AsRef<Path>
implementation makes ServerPath a drop-in for any function that accepts paths.
Why From<&str> on ClientPath is not TryFrom
ClientPath has no validation invariants – any string a client sends is a
valid client path (it might not exist, but that is discovered at resolution
time). The From<&str> impl is genuinely infallible.
MediaMappingKey follows the same reasoning: any string is a valid key
(it might not match any config entry, but that is a lookup miss, not a
construction error).
Relationship to type-driven-design.md
The path newtypes are an instance of Pattern 3 (Provenance Newtypes) from the
type-driven design catalog. The difference from text
provenance types (ChatRawText, AsrNormalizedText) is that path types also
encode machine boundaries – which side of the client/server divide a value
lives on – and enforce this through the presence or absence of AsRef<Path>.
Key Source Files
| File | Role |
|---|---|
crates/batchalign-types/src/paths.rs | All four newtypes + infer_media_mapping() |
crates/batchalign/src/types/request.rs | JobSubmission uses ClientPath, MediaMappingKey, RepoRelativePath |
crates/batchalign/src/types/config.rs | ServerConfig.media_mappings: BTreeMap<MediaMappingKey, ServerPath> |
crates/batchalign/src/runner/dispatch/fa_pipeline.rs | Media resolution cascade using all four types |
crates/batchalign/src/submission.rs | ClientPath to PathBuf bridge in materialize_submission_job() |
crates/batchalign/src/store/job/types.rs | JobFilesystemConfig stores ClientPath for source_dir |
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
Stanza Capability Registry
Status: Current Last updated: 2026-05-06 21:30 EDT
The Stanza capability registry replaces 7 scattered hardcoded language tables
with a single runtime-authoritative data structure built from Stanza’s own
resources.json. It answers questions like “does Dutch have constituency
parsing?” and “what is the alpha-2 code for Norwegian?” without any hardcoded
assumptions about Stanza’s feature matrix.
Why This Exists: One Authority for “Can Stanza Do X For Lang Y?”
There are several places in the pipeline that need to ask the language-capability question, in two separate dimensions:
- What ISO-639-3 codes does Stanza support, and with which processors?
(e.g. does
marhavetokenize/pos/lemma/depparse? doesnldhavemwt? doesenghaveconstituency?) - For a given ISO-639-3 code, what alpha-2 catalog key does Stanza
use? (e.g.
mar→mr,yue→zh-hans,nor→nb).
Both questions must always have the same answer at every site that
asks them. Two answers diverging is the recurring failure mode this
registry exists to prevent. Every drift incident has the same shape:
one component (Rust validator, Python preflight, capability exporter)
says “yes, lang X is supported”; another component (Stanza’s actual
Pipeline() constructor, the model loader, an alpha-2 lookup) says
“no, lang X has no models.” A worker dies during bootstrap, the user
sees a generic IPC error, and the linguistic root cause is buried in
stderr.
Drift incident history
The bug pattern keeps recurring. Each entry below is a real incident where the registry’s invariant, “every lookup of language capability goes through the same authority”, was violated.
| Date | What drifted | Symptom | Root cause |
|---|---|---|---|
| 2026-04-15 | MWT_LANGS (Python) said Swedish (sv) had MWT, but Stanza’s catalog had dropped the Swedish MWT model. | Every Swedish worker spawn raised UnsupportedProcessorError; an overnight 500-file morphotag run lost an entire language batch. | Hand-edited mirror of Stanza’s catalog drifted on upgrade. |
| 2026-05-06 | iso3_to_alpha2() (Python) had a hardcoded ISO-3 → ISO-1 mapping that did not include Marathi (mar → mr); the capability table built via pycountry did include it. The bootstrap preflight said “yes, supports Marathi”, then iso3_to_alpha2 returned "mar" verbatim. | Worker for [- mar] whole-utterance utterance crashed with ValueError: Language mar is currently unsupported because Stanza catalog keys are alpha-2; mar is not a key. The worker never emitted its ready signal; the file failed with a generic worker-bootstrap error. | A second hardcoded language dict that needed to agree with the registry but didn’t. |
| (older) Dutch utseg crash | CONSTITUENCY_LANGS (Python utseg) included Dutch, but Stanza shipped no Dutch constituency model. | Every Dutch utseg request crashed Stanza. | Hand-edited mirror of Stanza’s catalog drifted. |
The pattern across all three: a hand-edited list of “which langs have processor X” or “which iso3 maps to which alpha-2” got out of sync with Stanza’s installed catalog or with another component’s view of the same question.
Former hardcoded tables
Pre-registry, processor availability and code mapping were scattered across independent tables in Python and Rust. The registry is the single replacement for all of them.
| Former table | Location | What it hardcoded | Now |
|---|---|---|---|
MWT_LANGS | Python inference | Languages with multi-word token expansion | Replaced by StanzaRegistry::has_mwt(). |
SUPPORTED_STANZA_CODES | stanza_languages.rs | ISO-639-3 codes Stanza could process | Hardcoded fallback only; live registry overrides. |
iso3_to_alpha2 (hardcoded dict) | Python worker | ISO-639-3 → ISO-639-1 code mapping for Stanza model loading | Reduced to a small _ISO3_OVERRIDES (Stanza-specific deviations only) shared with the capability table builder. Standard 1-to-1 cases now go through pycountry. |
CONSTITUENCY_LANGS | Python utseg | Languages with constituency parsing | Replaced by StanzaRegistry::has_constituency(). |
STANZA_SUPPORTED_ISO3 | request.rs | Was a duplicate of SUPPORTED_STANZA_CODES | Removed; submission validation delegates to the chat-ops list. |
Inline checks in _stanza_loading.py | Python worker | Ad hoc feature gating | Replaced by StanzaRegistry queries via the capability table. |
Inline checks in batch.rs | Rust morphosyntax | Language filtering | Replaced by registry queries. |
The principle (re-derive when in doubt)
- Stanza’s installed catalog (
resources.json) is the only source of truth for which languages have which processors. pycountryis the only source of truth for the standard ISO-639-3 ↔ ISO-639-1 mapping._ISO3_OVERRIDESin_stanza_capabilities.pyis the only place to record Stanza-specific deviations from the standard mapping (yue/cmn/zho → zh-hans,nor → nb, etc.).- Every iso3↔alpha2 conversion in the worker imports
_ISO3_OVERRIDESfrom that one site rather than redefining it.iso3_to_alpha2does this; do not reintroduce a second override dict. - Every “is lang X supported” check at submission, dispatch, or
load time goes through the live
StanzaRegistry(with the chat-opsSUPPORTED_STANZA_CODESlist as a conservative fallback only when the registry has not yet been populated by the first worker).
Architecture: Data Flow
The registry is built once at worker startup and flows from Python to Rust through the worker capabilities IPC protocol.
flowchart TD
subgraph Python["Python Worker (batchalign/worker/)"]
resources["Stanza resources.json\n(installed package data)"]
builder["build_stanza_capability_table()\n(_stanza_capabilities.py)"]
pycountry["pycountry\n(ISO-639-3 to alpha-2)"]
overrides["_ISO3_OVERRIDES\n(nor→nb, yue→zh-hans, etc.)"]
table["StanzaCapabilityTable\n(dict: iso3 → StanzaLanguageCapability)"]
handlers["handle_capabilities()\n(_handlers.py)"]
response["CapabilitiesResponse\n(.stanza_capabilities field)"]
end
subgraph Rust["Rust Server (crates/batchalign/)"]
pool["WorkerPool\n(worker/pool/mod.rs)"]
oncelock["OnceLock<StanzaRegistry>\n(populated on first worker)"]
registry["StanzaRegistry\n(stanza_registry.rs)"]
validation["validate_language_with_registry()\n(types/request.rs)"]
dispatch["morphosyntax batch dispatch\n(morphosyntax/batch.rs)"]
utseg_dispatch["utseg dispatch\n(utseg.rs)"]
end
subgraph Fallback["Hardcoded Fallback"]
hardcoded["SUPPORTED_STANZA_CODES\n(stanza_languages.rs)\n~50 languages"]
end
resources --> builder
pycountry --> builder
overrides --> builder
builder --> table
table --> handlers --> response
response -->|"JSON-lines IPC\nWorkerCapabilities"| pool
pool -->|"record_capabilities()\nfirst worker only"| oncelock
oncelock --> registry
registry --> validation
registry --> dispatch
registry --> utseg_dispatch
hardcoded -.->|"used ONLY before\nfirst worker spawns"| validation
Python Side: Building the Table
batchalign/worker/_stanza_capabilities.py is the single source of truth.
build_stanza_capability_table() reads stanza.resources.common.load_resources_json(),
which returns the full Stanza resource dictionary keyed by alpha-2 language
codes. For each language entry that has a "tokenize" key (filtering out
non-language entries like "default"), it records which processors are
available:
| Processor | Stanza key | What it enables |
|---|---|---|
tokenize | "tokenize" | Basic tokenization |
pos | "pos" | Part-of-speech tagging |
lemma | "lemma" | Lemmatization |
depparse | "depparse" | Dependency parsing |
mwt | "mwt" | Multi-word token expansion (~45 languages) |
constituency | "constituency" | Constituency parsing (~11 languages) |
coref | "coref" | Coreference resolution |
ISO-639-3 mapping is built in two passes:
_ISO3_OVERRIDES: a small dict of codes where pycountry is wrong or Stanza uses non-standard identifiers (e.g.,nor→nb,yue→zh-hans,cmn→zh-hans,zho→zh-hans,msa→ms).pycountry.languages: for all remaining standard mappings.
The result is a StanzaCapabilityTable with languages keyed by ISO-639-3
code and iso3_to_alpha2 for the code mapping. This is cached with
@functools.lru_cache(maxsize=1) for the process lifetime.
IPC: Capabilities Response
When the Rust server queries a worker’s capabilities (_handlers.py:handle_capabilities()),
the handler converts the StanzaCapabilityTable into a
dict[str, StanzaLanguageProcessors] (keyed by ISO-639-3, each value
containing an alpha2 string and a processors list). This is serialized
as the stanza_capabilities field of CapabilitiesResponse
(batchalign/worker/_types.py).
On the Rust side, WorkerCapabilities in crates/batchalign-types/src/worker.rs
mirrors this structure with stanza_capabilities: BTreeMap<String, StanzaLanguageProcessors>.
Rust Side: StanzaRegistry
crates/batchalign/src/stanza_registry.rs stores the deserialized
capabilities and provides typed query methods:
| Method | What it checks | Used by |
|---|---|---|
supports_morphosyntax(iso3) | tokenize + pos + lemma + depparse | Submission validation, batch dispatch |
has_mwt(iso3) | mwt processor available | Morphosyntax pipeline (includes MWT step only when available) |
has_constituency(iso3) | constituency processor available | Utseg dispatch (falls back to sentence-boundary without it) |
alpha2(iso3) | ISO-639-3 to Stanza alpha-2 | Model loading configuration |
supported_languages() | All ISO-639-3 codes | Error messages, help text |
is_populated() | Non-empty registry | Fallback gating |
Storage: OnceLock in WorkerPool
The registry is stored in WorkerPool.stanza_registry: OnceLock<Box<StanzaRegistry>>.
record_capabilities() populates it from the first worker that reports
non-empty stanza_capabilities. The OnceLock ensures this is a one-shot
operation even under concurrent worker spawning.
Access is via WorkerPool::stanza_registry() -> Option<&StanzaRegistry>.
None means no worker has reported yet.
Two-Phase Validation
Submission validation runs in two phases:
-
Pre-filter (
validate_language_support()inrequest.rs): delegates tochat_ops::stanza_languages::is_stanza_supported, which consults the hardcodedSUPPORTED_STANZA_CODESset (~50 languages). This runs even before any worker has started, so it catches obviously unsupported languages immediately. -
Authoritative check (
validate_language_with_registry()inrequest.rs): uses the liveStanzaRegistrywhen available. This is called frommaterialize_submission_job()inroutes/jobs/mod.rswhere the registry is accessible viaAppState.
Once the registry is populated, it supersedes the hardcoded table. The hardcoded table exists only as a conservative safety net for the window between server startup and the first worker’s capability report.
Per-Command Processor Requirements
| Command | Required processors | Optional processors |
|---|---|---|
| morphotag | tokenize + pos + lemma + depparse | mwt (if available) |
| utseg | tokenize + pos | constituency (degrades to sentence-boundary without it) |
| coref | English-only, uses Stanza coref | – |
| translate | Does not use Stanza | – |
| align/transcribe | Does not use Stanza directly | – |
Graceful Degradation
The registry enables graceful degradation rather than hard failures:
- No MWT: Morphotag works without contraction expansion. The pipeline
simply omits the
mwtprocessor from the Stanza pipeline configuration. - No constituency: Utseg falls back to sentence-boundary segmentation
(
_stanza_loading.py:UtsegConfigBuilderchecks the capability table). - No depparse: Morphotag is rejected at submission time with a clear error message listing supported languages.
- Unknown language: Rejected at submission with a formatted list of all supported ISO-639-3 codes.
Maintaining the Fallback Table
After upgrading Stanza, regenerate the hardcoded fallback table:
uv run scripts/generate_stanza_language_table.py
This reads the installed Stanza’s resources.json. The historically
hardcoded SUPPORTED_STANZA_CODES constant is no longer maintained;
the authoritative table is built dynamically from the Python worker’s
live Stanza resources and sent to Rust via the capability response.
Delegation paths in request.rs and the transcribe plan-time gate consult
the live table (with chat-ops fallback if the registry is unavailable).
The Python worker also runs a typed UnsupportedLanguageError
preflight in _stanza_loading.py::load_stanza_models that consults
the live capability table, this is the authoritative drift safety
net regardless of how stale the Rust fallback gets.
Key Source Files
| File | Role |
|---|---|
batchalign/worker/_stanza_capabilities.py | Reads resources.json, builds StanzaCapabilityTable |
batchalign/worker/_handlers.py | Serializes table into CapabilitiesResponse.stanza_capabilities |
batchalign/worker/_types.py | CapabilitiesResponse, StanzaLanguageProcessors Pydantic models |
batchalign/worker/_stanza_loading.py | Utseg config builder queries table for constituency/MWT |
crates/batchalign-types/src/worker.rs | WorkerCapabilities, StanzaLanguageProcessors Rust types |
crates/batchalign/src/stanza_registry.rs | StanzaRegistry with typed query methods |
crates/batchalign/src/worker/pool/mod.rs | OnceLock<StanzaRegistry> storage, record_capabilities() |
crates/batchalign/src/types/request.rs | Single Rust fallback via is_stanza_supported_language(); delegates to chat-ops |
crates/batchalign/src/types/request.rs | validate_language_with_registry(); submission-time gate delegates to chat-ops |
crates/batchalign/src/pipeline/transcribe.rs | Plan-time gate (registry first, chat-ops fallback) |
batchalign/worker/_stanza_loading.py | UnsupportedLanguageError preflight before stanza.Pipeline |
crates/batchalign/src/morphosyntax/batch.rs | Queries registry for language filtering |
scripts/generate_stanza_language_table.py | Regenerates Rust fallback tables from installed Stanza |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Stanza Defect Mitigation Map
Status: Current Last updated: 2026-09-10 13:54 EDT
Stanza is a third-party NLP library whose defects surface at different pipeline stages depending on the root cause. batchalign3’s mitigation strategy is patch at the stage where the defect originates: not earlier (would mask the signal), not later (would require re-deriving state). This page maps every tracked Stanza defect to its patch-point so a contributor debugging a new Stanza quirk can find similar precedents by pipeline stage rather than by grepping the registry.
The authoritative list of defects with version pinning, reproducers, and re-evaluation criteria lives at Stanza Limitations. This page is a view over that list, organized by where each defect is patched in the pipeline.
Pipeline stages and their patch-points
flowchart TD
cha["CHAT main-tier words"] --> construct
subgraph construct["1. Pipeline construction"]
d5["Defect 5: MWT capability table\n(batchalign/worker/_stanza_loading.py\n::should_request_mwt)"]
end
construct --> tokenize
subgraph tokenize["2. Tokenize (tokenize_postprocessor hook)"]
dp["align_tokens char-DP merge\n(crates/batchalign-transform/src/tokenizer_realign.rs\nalways on, no defects patched here)"]
d2["Defect 2: MWT hint-tuple overlay\n(batchalign/inference/_tokenizer_realign.py\n::_realign_sentence)"]
end
tokenize --> pos
subgraph pos["3. POS / MWT / lemma / depparse"]
pos_note["(Stanza internals, no hook)"]
end
pos --> postdep
subgraph postdep["4. Post-depparse, pre-map-UD"]
d10["Defect 10: CHAT contraction expansion\n(crates/batchalign-transform/src/morphosyntax/invariants/\nenglish_contractions.rs)"]
d11["Defect 11: isolated communicator from transcriber evidence\n(crates/batchalign-transform/src/morphosyntax/evidence.rs,\ninvariants/discourse_marker.rs)"]
d9["Defect 9: lexicon-licensed category constraint\n(crates/batchalign-transform/src/morphosyntax/invariants/\nlexicon_category.rs, data/eng_lexicon_verdicts.json)"]
d1["Defect 1: finite-verb-main-clause rewrite\n(crates/batchalign-transform/src/morphosyntax/invariants/\nfinite_verb_main_clause.rs)"]
d10 --> d11 --> d9 --> d1
end
postdep --> ingress
subgraph ingress["5. Post-infer ingress (Python→Rust)"]
ingress_note["(Defect 4 retired in Stanza 1.12.0;\nno active patch-point at this stage)"]
end
ingress --> mapud["6. UD → %mor mapping"]
mapud --> chaout["CHAT %mor output"]
subgraph outside["Outside the per-utterance pipeline"]
d3["Defect 3: CJK accuracy\n(PyCantonese POS; unified Stanza\nHKCanCor+UD training)"]
d6["Defect 6: IT POS-layer junk verb+clitic for clitic-shaped words\n(parla, arancione, piccolo, gomitolo, …)\n(assemble_mors collapses Range, \ninjection ok, %mor content junk)"]
d7["Defect 7: IT sentence-initial la→il+i junk MWT\n(assemble_mors collapses Range, \ninjection ok, %mor content junk)"]
end
classDef defect fill:#fdd,stroke:#900,color:#000
classDef dp fill:#dfd,stroke:#060,color:#000
class d1,d2,d3,d5,d6,d7,d9,d10,d11 defect
class dp dp
Order within stage 4. Tokens first: the contraction expansion (Defect
10) turns a whole hafta into the range Stanza should have returned, so
every later rule sees have + to. Then the transcriber’s evidence
(Defect 11), then the lexicon constraint (Defect 9), then the finite-verb
rescue (Defect 1): the rescue may promote an -ing word the lexicon
licenses only as a noun to the clause’s verb, and the clause-level
invariant outranks the word-level ones. The evidence (UtteranceEvidence)
is computed once per utterance in injection.rs from the CHAT AST, beside
the payload words, and passed down the chain; Stanza never sees it.
Note on Defects 6 and 7. Both are content-quality defects,
not injection-gate failures. The %mor 1-to-1 count invariant holds
for both because nlp/mapping/mod.rs::assemble_mors correctly
collapses Stanza’s MWT Range tokens into a single compound %mor
entry using ~/+. The problem is what goes INSIDE that compound
entry: Stanza’s POS/MWT layer produces linguistically wrong analyses
(verb|par-Inf-S~pron|la-Prs-S3 for the bare imperative parla;
det|il-Masc-Def-Art-Sing~det|il-Masc-Def-Art-Plur for the
feminine-singular article la), and Stage 3 faithfully serializes
them. No pipeline gate rejects junk content.
Cross-reference table
| Defect | Stage | Mitigation file | Test pointer | Stanza version confirmed |
|---|---|---|---|---|
| 1 | Post-depparse, pre-map-UD | crates/batchalign-transform/src/morphosyntax/invariants/finite_verb_main_clause.rs | test_preserve_mwt_end_to_end.py; finite_verb_main_clause.rs #[cfg(test)] (14 tests) | 1.10.1, 1.11.1, 1.12.0, 1.12.1, 1.13.0, 1.14.0 |
| 2 | Tokenize (postprocessor hook) | batchalign/inference/_tokenizer_realign.py::_realign_sentence | test_stanza_mwt_copula_observations.py; golden_l2_morphotag_* (4 tests) | 1.10.1, 1.11.1, 1.12.0, 1.12.1, 1.13.0, 1.14.0 |
| 3 | Dedicated engines (not a pipeline patch) | batchalign/inference/languages/cantonese/* (PyCantonese); unified Stanza training (out-of-tree) | test_cantonese_*, test_stanza_cantonese_*, test_mandarin_* | 1.10.x, 1.11.x |
| 4 | Retired in Stanza 1.12.0 (no active patch-point) | , (mitigation files removed in commit cea8f082) | , | retired upstream in 1.12.0 |
| 5 | Pipeline construction | batchalign/worker/_stanza_loading.py::should_request_mwt | test_stanza_loading.py::TestShouldRequestMwt; test_stanza_config_parity.py::TestMwtCapabilityDriven; test_stanza_he_el_mwt_splits.py; test_he_el_mwt_end_to_end.py | every 1.x through 1.14.0 |
| 6 | Unpatched content quality (POS layer, no hook; injection succeeds with junk content) | (none) | test_stanza_mwt_probe_matrix.py::test_stanza_mwt_probe_with_postprocessor[ita__dell_opera_in_context], [ita__parla_imperative_forte], [ita__parla_imperative_piu_forte], [ita__arancione_noun_bogus_verb], [ita__piccolo_adj_bogus_verb] (xfail, UD-level pins) | 1.11.1, 1.12.0, 1.12.1, 1.13.0, 1.14.0 |
| 7 | Unpatched content quality (MWT processor, no hook; injection succeeds with junk content) | (none) | test_stanza_mwt_probe_matrix.py::test_stanza_mwt_probe_with_postprocessor[ita__parla_3sg_storia_context] (xfail, UD-level pin) | 1.11.1, 1.12.0, 1.12.1, 1.13.0, 1.14.0 |
| 9 | Post-depparse, pre-map-UD | crates/batchalign-transform/src/morphosyntax/invariants/lexicon_category.rs, lexicon.rs, data/eng_lexicon_verdicts.json | lexicon_category.rs and lexicon.rs #[cfg(test)] | 1.11.1, 1.14.0 |
| 10 | Post-depparse, pre-map-UD (first in the English chain) | crates/batchalign-transform/src/morphosyntax/invariants/english_contractions.rs | english_contractions.rs #[cfg(test)] | 1.14.0 |
| 11 | Post-depparse, pre-map-UD (evidence computed at injection) | crates/batchalign-transform/src/morphosyntax/evidence.rs, invariants/discourse_marker.rs | evidence.rs and discourse_marker.rs #[cfg(test)] | 1.14.0 |
Stages without defects (today)
Two stages currently carry no defect mitigations and exist in the diagram for completeness:
- Char-DP merge in
align_tokens: always-on, language-agnostic. The 2026-04-21 per-language MWT-override audit confirmed that the DP alone satisfies the morphotag 1-to-1 invariant for every previously patched language (French, Italian, Portuguese, Dutch). Per-language override tables were retired; see the per-language chapters for the audit records. - Stanza internals (POS / MWT / lemma / depparse): no hook
exists. Defects originating here are either (a) mitigated
downstream (Defect 1), (b) handled by swapping engines (Defect 3),
(c) retired upstream when the underlying library was fixed (Defect 4
, fixed in Stanza 1.12.0), or (d) left as xfail-pinned UD-level
observations with linguistically wrong
%morcontent flowing through unimpeded (Defects 6 and 7).
When to add a new patch-point
Use this procedure for any newly discovered Stanza defect:
- Identify the stage where the defect originates. A hint-tuple
loss originates at tokenize; a wrong POS tag originates at POS;
a control-token leak in
Documentoutput originates post-infer. Patch at the origin, not earlier or later. - If an existing defect already patches that stage, extend its
module. Prefer coalescing over sprawl: the
crates/batchalign-transform/src/morphosyntax/invariants/directory is the natural home for any future post-depparse UD-invariant rewrite. - If no existing defect patches that stage, update this diagram before adding code. Adding a stage to the diagram without a patch-point node is also valid, it documents where a future mitigation would live (e.g., “post-POS reassembly” is currently a named gap behind Defect 6).
- Register the defect in
stanza-limitations.mdwith the full format (version, reproducer, correct output, mitigation pointer, tests, re-evaluation criteria). - Cross-link back to this diagram from the defect’s BA3 mitigation section in the registry.
Architectural gaps
One known gap today: no %mor content-quality gate. The pipeline
validates only the %mor COUNT invariant (N items == N Mor-alignable
CHAT words); there is no corresponding check on the linguistic
content of each %mor entry. As a result, Stanza’s POS/MWT
pseudo-analyses flow through to the emitted %mor tier unchallenged:
- Defect 6 (
parla → verb|par-Inf-S~pron|la-Prs-S3): Stanza gives up on lemmatizingparand echoes the surface fragment as the lemma. The%morchunk has the right count but the wrong content. Candidate signal:head.lemma == head.texton an MWT expansion. - Defect 7 (
la → det|il-...~det|il-...): Stanza’s MWT processor emits a 2-word expansion whose inner words don’t reconstruct the token surface (il + i ≠ la) and whose lemmas both collapse toil. The%morchunk has the right count but the wrong content. Candidate signal:concat(inner_word_texts) != token_text.
A content-quality gate would either (a) reject the utterance
(convert to MisalignmentBug-class absorption) when these signals
fire, (b) substitute a plain-POS %mor using the CHAT surface text,
or (c) route Italian through a different engine entirely. None of
these are in place today. Designing them is separate architectural
work, blocked on (i) deciding which of (a)/(b)/(c) fits the
succession target, and (ii) a content-quality test oracle (CLAN’s
Italian MOR is a candidate).
An ita-corpus scan surfaced 73 main-tier parla occurrences across
43 files, but there is no automated %mor-content assertion yet.
Building one is a prerequisite to any principled fix, candidates
include a curated expected-%mor fixture per case, or CLAN’s
Italian MOR output as an oracle.
This page last changed: 2026-09-10 (commit 0bbd998d). The whole book last changed: 2026-09-16 (commit 34d249d8).
Observability Architecture
Status: Current Last updated: 2026-09-15 18:27 EDT
Release boundary
This page describes the current source tree, not whichever build happens to be
running on a particular host. Treat /health runtime identities and the
reported build hash as the authority for a live server. A health response is
evidence about that executable and its admitted Python workers; it is never
evidence that an unverified checkout, wheel, or documentation tree was
deployed.
Overview
The batchalign3 server processes jobs through a unified runner shared by
direct mode and the embedded/local server. Both modes produce the
same FileStatus records, use the same error classification, and persist
to the same SQLite store. Fixing observability in the runner fixes it for
all modes.
The current experiment architecture has two deliberately distinct entry
lanes. Live execution resolves raw paid-service evidence before projecting it.
Offline transcribe replay admits fingerprinted projected artifacts; it does
not mislabel an older _asr_response.json as raw Rev evidence.
flowchart TB
subgraph LIVE["Live paid-evidence lane"]
M["Inference media"] --> Q["Typed Rev and speaker requests"]
Q --> C{"Validated durable cache lookup"}
C -->|"hit"| E["Completed raw evidence"]
C -->|"miss"| A["Single-use inference authorization"]
C -->|"miss + require-cache"| R["Typed precondition refusal<br/>Rev / speaker / FA identity retained"]
C -->|"corrupt"| F["Fail closed"]
A --> S["Provider or model service"]
S --> V["Validate + required durable commit"]
V --> E
V -->|"invalid or commit failure"| F
E --> P["Deterministic ASR / speaker projection"]
E --> CE["Causal Rev / speaker evidence sidecars"]
P --> PA["Projected ASR + exact turns artifacts<br/>(when debug evidence is enabled)"]
end
subgraph REPLAY["Fingerprint-admitted offline lane"]
PA --> MF["Immutable replay manifest"]
MF --> AD{"Verify media and artifact digests"}
AD -->|"valid"| LR["AdmittedLegacyTranscribeReplay"]
AD -->|"drift or malformed"| RF["Refuse batch before output or model load"]
end
P --> LP["Local speaker projection + two-pass utseg + CHAT construction"]
LR --> LP
LP --> UTE["Pre-CHAT and post-CHAT decision evidence"]
LP --> CHAT["Final CHAT + replay receipt"]
The layers answer different questions: raw caches prevent duplicate paid work; causal sidecars prove what request and cache resolution produced a result; projected artifacts support the present offline replay boundary; segmentation evidence supports decoder-policy analysis; and final CHAT is the user-visible product. None can silently stand in for another.
Current source-tree behavior
Selected-worker engine identity
Task availability and execution identity are different facts. A pool-wide capability snapshot can say that forced alignment is installed, but it cannot say which model served an engine-specific worker key. Current dispatch therefore queries capabilities from the exact worker selected by command, language, and typed engine recipe. Cache lookup and commit use that selected worker’s live engine version.
Lazy-profile workers retain the engine recipe in WorkerKey even though they
load the model on demand. Wave2Vec and Whisper requests therefore cannot share
a task-only worker or reuse its already_loaded state. The lazy load completes
before the selected worker reports the version used for cache identity. Shared
stdio and TCP workers serialize control operations across the entire
request/response round trip, so an ensure_task response cannot be delivered
to a concurrent capability request.
The pinned ASR composition is NOT part of WorkerKey. It is injected at the
single spawn-argv site instead, and the reason is the key’s own contract: the
composition is a pure function of the target, the language and the engine
overrides, which are exactly the three things the key already carries. Adding
it to the key would fragment the key without distinguishing anything, because
two keys that agreed on those three fields could never disagree on the
composition. Deriving it separately for the capability probe and for execute
would be worse still: those two derivations could drift, and the invariant that
the capability key equals the execute key would hold only by coincidence.
Injecting once at spawn keeps that equality a consequence of where the value is
computed rather than a property somebody has to remember, and the worker then
loads the composition once per process rather than re-reading it per request.
flowchart LR
J[Typed command options] --> K[WorkerKey<br/>target + language + engine recipe]
K --> W[Exact selected worker]
W -->|lazy profile| L[ensure_task for this recipe]
W -->|eager profile or task| C[capabilities]
L --> C
C --> I[Selected engine version]
I --> R[PipelineServices]
I --> H[Cache lookup and commit]
P[Pool-wide availability snapshot] -.->|never cache identity| H
This distinction matters for experiments: a cache row labeled with another worker’s engine version is false provenance even if payload validation later prevents the wrong engine from consuming it.
The one-time SQLite compatibility migration follows the same rule. Schema-2
FA evidence embeds the selected-worker version and may repair its row label
from that owned fact. Schema-1 evidence does not; a row whose requested engine
family contradicts its stored version family is copied byte-for-byte into
cache_quarantine with reason
legacy_fa_raw_evidence_engine_namespace_unprovable, then removed from live
lookup and never relabeled. Cache statistics therefore stop reporting the
known historical misnamespace without claiming a producer version the
evidence did not record, while the original row remains available for audit.
batchalign3 cache stats exposes the quarantined total and counts grouped by
that stable reason separately from live-entry counts.
Rev paid-boundary identity
The current source tree makes the media identity used for a raw Rev cache
decision the same identity used at upload. PreparedRevProviderMedia records a
BLAKE3 digest, revisioned preparation recipe, and normalized upload filename.
RevAsrEvidenceRequest combines those with multipart MIME, language, expected
speaker count, request-policy revision, model alias, and request-identity
revision.
A cache miss is not merely a Boolean. It owns an inference lease and becomes a
single-use RevAsrInferenceAuthorization; consuming that authorization yields
one AuthorizedRevEvidenceRun and one private evidence-commit permit. The run
rereads and verifies the bytes before either Rev language ID or Rev
transcription can see them. Auto language legitimately makes both requests
inside that one run. A changed file fails as ProviderMediaDrift. The
old parallel pre-submission module and its optional provider-job-ID plumbing
have been removed, so no second path can submit before cache authorization.
For Rev transcribe and Rev-backed align UTR runs, --debug-dir now exports
that identity as a versioned, fail-closed *_rev_evidence.json causal record.
It joins source and prepared digests, recipe, exact multipart presentation,
request/model revisions, raw evidence key, cache outcome, transcript fidelity,
and ASR projection revision. It deliberately omits credentials and
machine-local source paths. UTR records carry their own named projection
revision and a stable
raw-key-derived logical identity, so multiple partial windows cannot overwrite
each other.
Dedicated speaker inference uses the same single-use shape. A validated
speaker-cache miss becomes SpeakerInferenceAuthorization; consuming it yields
one privately constructible AuthorizedSpeakerEvidenceRun plus one durable
commit permit. The resolver rereads the source and proves its digest before
constructing VerifiedSpeakerEvidenceRun; SpeakerEvidenceInference accepts
that verified run by value and Rust prepares worker PCM from its owned bytes.
This makes both the no-duplicate-paid-call rule and request/upload identity
part of the Rust API rather than adapter conventions, while the commit permit
retains the request identity and single-flight lease until validated evidence
is durable.
With --debug-dir, the same resolver-bound trace seed becomes a versioned
*_speaker_evidence.json causal receipt. It records the source digest,
preparation revision, backend, expected-speaker count, model revision, raw and
derived cache identities, normalization revision, cache outcome, and named
segment-projection revision. It also carries the segment count and a
versioned BLAKE3 digest over the validated segment timing and speaker labels,
so the causal receipt identifies the exact semantic projection rather than
only the cache slot that supplied it. It contains no source path. The companion
<stem>.turns.json remains the exact normalized segment set used for CHAT
projection. Both writes are fail-closed when requested, so causal identity and
the consumed turns cannot silently disappear from an experiment run.
The trace type separates semantic projection from causal origin. A cold run and
durable replay should have the same media/request identity, transcript
fidelity, named projection revision, and exact projected-segment digest, but
they must not claim the same cache outcome. The full transcribe regression compares that typed semantic projection
and requires byte-identical final CHAT and ASR debug output, while separately
requiring inferred_not_found then replayed. This avoids both extremes of
ignoring provenance and normalizing debug JSON with an untyped field-deletion
hack.
Utterance-boundary decision evidence
Utterance segmentation has two observably different locations in transcribe.
Supported languages run a boundary model over timed, speaker-projected chunks
before CHAT construction; the optional utseg stage can run again over CHAT
main-tier words. UtsegEvidencePhase::{PreChat, PostChat} keeps those states
distinct, and the debug directory uses separate filenames for them.
Python’s typed evidence preserves raw and applied semantic actions plus a fixed-point sentence-end probability for every classified word. Omitted and short-circuited words are explicit variants. The worker result carries model ID, optional exact revision, and a vector parallel to the request words. Rust admits the result into one of three source states only after checking payload exclusivity and all vector lengths: boundary model with evidence, direct assignments without evidence, or constituency projection. Only admitted predictions can be applied.
An enabled UtsegEvidenceSink serializes a complete schema-2 trace before
opening its destination, writes through a same-directory temporary file,
fsyncs file and directory, and publishes atomically. It returns a typed error
instead of allowing a research run to succeed after losing requested evidence.
The trace keeps exact input words and assignments together with the source and
model evidence. This permits policy replay and confidence analysis without
another model invocation, while keeping the final CHAT free of dependent-tier
debug clutter.
UTR word-alignment evidence
UtrResult retains the exact typed alignment plan used by UTR. Each
utterance is matched, unmatched, excluded_marked_overlap, or
no_alignable_words; a matched state owns a nonempty ordered match collection
and either a positive or nonpositive timing proposal. Deliberate first-pass
exclusion cannot masquerade as failure to match. Each word match records stable
utterance/word and ASR-token addresses plus an exact, case-insensitive, or
fixed-point fuzzy relation. Global and two-pass results are different variants,
so a timing-only overlap recovery cannot masquerade as a global word match.
UtrResult fields are read through accessors rather than public construction.
UTR evidence construction lives in a dedicated module whose address
constructors remain private to the UTR implementation. Distinct utterance,
word, and ASR-token ordinal types prevent cross-domain index substitution.
The no-run constructor can represent only zero injected, zero unmatched, and
the observed already-timed count. Two-pass completion derives final counts
from a population-checked before/after bullet transition instead of balancing
increments and decrements. A population change is a typed refusal that makes
two-pass select the separately computed global result. A successful recovery
also removes the superseded pass-one unmatched decision. The serialized
summary and decision evidence therefore cannot disagree through those
construction paths.
The regular _utr_result.json debug artifact serializes this plan. The
offline eval utr-alignment action can reconstruct the same global plan from
an exact _utr_input.cha and _utr_tokens.json pair without model or provider
inference. Its report fingerprints both inputs and records the build identity.
This is intentionally separate from CHAT: BA3 does not restore %xalign, and
an observation sidecar does not authorize a production bullet or %wor
change.
flowchart LR
CHAT["Clean typed CHAT"]
TOK["Retained UTR tokens"]
PLAN["UtrAlignmentPlan"]
MATCH["Nonempty word matches<br/>and lexical relations"]
PROP["Positive / nonpositive proposal"]
RESULT["UtrResult debug evidence"]
EVAL["eval utr-alignment<br/>offline replay"]
CHAT --> PLAN
TOK --> PLAN
PLAN --> MATCH --> PROP --> RESULT
CHAT --> EVAL
TOK --> EVAL
EVAL --> PLAN
Forced-alignment decision evidence
When align --debug-dir DIR is enabled, BA3 writes a versioned, fail-closed
<stem>_fa_evidence.json causal trace. Version 0.3.0 writes schema 2. Version
0.4.0 writes schema 3,
which adds stable utterance ordinals to numeric monotonicity decisions while
retaining the schema-2 input-line coordinates for debugging.
The two coordinates deliberately name different spaces. line_idx addresses
the exact input ChatFile.lines collection. It must never be used to index the
final document: provenance serialization may insert an @Comment header and
shift every following line without changing any utterance. The utterance
ordinal survives that header-only transformation. Research tooling therefore
derives and corroborates the ordinal from the exact input, then resolves it in
the output while checking speaker identity and normalized spoken tokens.
flowchart LR
I["Exact input CHAT"] --> L["Input line index<br/>debug coordinate"]
I --> O["Input-derived utterance ordinal<br/>stable coordinate"]
L --> D["Typed FA decision"]
O --> D
D --> E["Schema-3 evidence sidecar"]
I --> P["Alignment + provenance projection"]
P --> H["Possible @Comment insertion"]
H --> F["Final CHAT"]
E --> C{"Corroborate ordinal,<br/>speaker, spoken tokens"}
F --> C
C -->|"all agree"| R["Resolved output utterance"]
C -->|"drift"| X["Refuse the evidence join"]
This is an observability contract, not a claim that the trace is a complete repair history. Current FA evidence retains pre-injection timings, scores, origin chains, and typed decisions, but final post-processing still lowers word timings into CHAT before a group-shaped evidence value can own them. The resulting CHAT remains necessary when evaluating final word boundaries.
Submit-path retries
BatchalignClient::submit_job (crates/batchalign/src/cli/client.rs)
goes through the shared request_with_retry helper. The contract is narrow on
purpose and load-bearing for fleet-scale runs:
- Retry class: transient
reqwest::Error::is_connect()oris_timeout()only. These cover the daemon’s accept-gap class, the brief window during job finalization when the local server is restarting and a new submission getsConnection refused. - No retry on HTTP 4xx/5xx. A deterministic server rejection (413
payload too large, 400 validation failure, 409 conflict, 5xx panic-catch)
is surfaced immediately as
CliError::ServerHttp { status, detail }. Re-sending the same payload cannot fix it, and retrying would hide real configuration bugs (e.g. a payload that genuinely exceedsmax_body_bytes_mb). - Attempts and backoff:
RETRY_ATTEMPTS = 3total, exponential backoff starting atRETRY_BACKOFF = 2.0 swith0.5×, 1.5×multiplicative jitter. - Per-attempt timeout: submission passes 120 s (large request bodies);
health and result GETs pass 30 s. This is a parameter on
request_with_retry, so the choice is explicit at the call site.
Regression tests live next to the implementation in client.rs::tests:
submit_job_retries_transient_connect_errors (points at reserved port 1,
asserts elapsed ≥ one retry × backoff × min-jitter) and
submit_job_does_not_retry_413_length_limit_exceeded (raw TCP listener
answering 413, asserts exactly one connection attempt).
The following sequence shows a submission against a daemon with a transient accept pause, and the alt branch for a deterministic 413 rejection:
sequenceDiagram
participant Cli as "BatchalignClient::submit_job\n(batchalign/src/cli/client.rs)"
participant Retry as "request_with_retry\n(batchalign/src/cli/client.rs)"
participant Daemon as "axum POST /jobs\n(routes/jobs/mod.rs::submit_job)"
Cli->>Retry: method=POST, url=/jobs, body=JobSubmission\nper_attempt_timeout=120s
Note over Daemon: accept-gap at finalize_job\n(store/queries/execution.rs)
Retry->>Daemon: attempt 1 (reqwest send)
Daemon--xRetry: ECONNREFUSED\nis_connect() = true
Retry->>Retry: tokio::time::sleep(2.0s × jitter)
Retry->>Daemon: attempt 2
Daemon--xRetry: ECONNREFUSED\nis_connect() = true
Retry->>Retry: tokio::time::sleep(4.0s × jitter)
Retry->>Daemon: attempt 3
Daemon-->>Retry: 201 Created\nJobInfo JSON
Retry-->>Cli: Ok(Response)
alt Deterministic rejection (no retry)
Retry->>Daemon: attempt 1
Daemon-->>Retry: 413 Payload Too Large
Retry->>Retry: read_http_error_detail(resp)
Retry-->>Cli: Err(CliError::ServerHttp{status=413, detail})
Note over Retry: 4xx/5xx never retried, \ndeterministic rejection
end
Diagram verified against:
crates/batchalign/src/cli/client.rs (submit_job, request_with_retry,
read_http_error_detail, constants RETRY_ATTEMPTS/RETRY_BACKOFF),
crates/batchalign/src/routes/jobs/mod.rs (submit_job handler),
crates/batchalign/src/routes/mod.rs (RequestBodyLimitLayer),
crates/batchalign/src/types/config/server.rs
(default_max_body_bytes_mb).
Per-file progress
Each file tracks: status (queued/processing/done/error), stage, current/total
counters. Published via RunnerEventSink::set_file_progress() to the store
and broadcast over WebSocket to the dashboard.
Batch-inference progress
For the batched-text commands (morphotag today; utseg / translate / coref have
the same shape and are not wired yet), the Python backend reports utterance
counts as it works, and those counts reach the UI as per-file progress on
the same channel every other command uses: progress_current /
progress_total on the file’s status entry, under FileStage::Analyzing.
There is deliberately no job-level aggregate. One existed
(BatchInferProgress, with a REST field, a dashboard panel and a CLI summary
line) and it was retired on 2026-07-30 because it could not be made honest.
| Concern | Where it lives |
|---|---|
| Provenance-carrying report from a backend | BackendProgress (runner/util/batch_progress.rs) |
| Declared work + completions per file | BatchProgressLedger, projected as SourceProgress |
| Publishing cadence and shutdown | BatchProgressReporter (execution/morphotag/progress.rs) |
| Per-file handle threaded to the dispatch site | BackendProgressPort |
Two scope rules, both learned by getting them wrong.
- A group DECLARES its work before dispatch; the denominator is never
inferred from what has reported.
infer_batch_homogeneouscallsdeclare_group_total(lang, items.len())before chunking. Without it, a file whose first chunk finished (375/375) looked complete while three chunks had not started, so the publisher skipped it as done and no per-file update was ever sent. Every unit test passed; a live run caught it. - Completions are keyed (source, group, chunk) and summed. A language group
is split into up to
max_workers_per_keychunks, each its own request with its own counts, so aggregating them by language alone displayed453/274(165%) before this feature was removed in May. The chunk index is the key, not the request id, because a retry reissues the same chunk under a fresh id.
Why the job-level aggregate went. Its denominator only covered files that
had already been dispatched, and files are dispatched num_workers at a time.
So “1250/1500 utterances (83%)” meant “83% of the handful of files in flight”,
rendered next to 0/740 files, drifting up toward truth over hours. Under the
pooled batching it was written for, the same field was honest: everything was
pooled up front, so the denominator really was the job. Making it honest again
would require parsing every file before dispatch, which is exactly the up-front
work that was deliberately removed for interfering with per-file processing. A
file’s own total, by contrast, is exact the moment its payloads exist.
If a job-level view is wanted later, the honest form is a rate or ETA computed from COMPLETED files, not a mid-flight utterance percentage.
Provenance comes from the dispatch site, which knows the file, the language and
the chunk for certain. The wire event’s stage field is ignored:
batchalign/worker/_protocol.py::write_progress_event hard-codes
stage="stanza_processing" on every event, so it identifies nothing.
sequenceDiagram
participant Py as "Python backend\n(worker/_text_v2.py, 1 event/s/request)"
participant Port as "BackendProgressPort\n(one per input file)"
participant Drain as "Reporter drain task"
participant Ledger as "BatchProgressLedger"
participant Store as "Job store"
Port->>Drain: GroupTotal {source_id, group, total}
Drain->>Ledger: record()
Drain->>Store: set_file_progress(0 / total)
Py->>Port: progress_v2 {completed, total}
Port->>Drain: Chunk {source_id, group, chunk, completed}
Drain->>Ledger: record()
Note over Drain,Ledger: publishes on a 2s cadence when changed,\nand republishes after 120s of silence
Drain->>Store: set_file_progress(completed / total)
Shutdown is an explicit CancellationToken, not “the channel closed”: every
port holds a sender clone, so inferring shutdown from the channel would make
finish() hang whenever a caller held a port a moment too long.
Verified against: batchalign/worker/_text_v2.py,
crates/batchalign/src/execution/morphotag/progress.rs,
crates/batchalign/src/morphosyntax/worker.rs,
crates/batchalign/src/runner/util/batch_progress.rs.
History, because this surface misled readers for months
15c88de2(2026-04-28): a working drain loop existed in the legacy batched-text dispatch, plus a designed replacement that nothing constructed.e8235c13(2026-05-03): the working loop, the channel andRunnerEventSink::set_batch_progresswere all removed. From then until 2026-07-29 the type, the REST field, the dashboard panel and the CLI line all existed with no producer:batch_progresswas permanentlynull.c2236ab3(2026-05-06): morphotag stopped accepting a job-level--lang. The golden tests covering this feature still sent one, so every one of them failed at submission with HTTP 400 and the coverage went dark three days after the feature did.- An earlier revision of this page claimed a per-language “tagger” had FIXED the
453/274overflow. It had not; the tagger addressed a different bug (all languages collapsing onto the sharedstagelabel) and could not address the overflow, which comes from aggregating CHUNKS by language. - 2026-07-30: producer restored, then the aggregate retired for the scope reason above. What survives is one honest per-file number.
Job status authority: the local store
The observability contract for job status is now simpler: the local SQLite store is authoritative for the local server control plane.
A file or utterance transitions through Queued → Running → Completed
inside one daemon’s runner, and the store is mutated synchronously with the
transition. Conflict detection on resubmission reads that same store, so
status freshness depends on normal shutdown/recovery behavior rather than on a
second orchestration system.
When the server restarts mid-job, persisted rows can move through
Running → Interrupted → Queued during recovery. That state machine is the
observable source of truth for whether work should resume.
Worker capability admissions
GET /health.worker_capability_admissions lists, per worker key that has
reported, the latest outcome of admitting its capability report
(WorkerPool::record_capabilities): admitted with the infer tasks the worker
supports, or refused with a typed reason naming the task:
missing_engine_report (an advertised task with no entry),
unadvertised_engine_report (an entry for a task that was not advertised) or
engine_named_for_non_fa_task (an engine name for a task other than forced
alignment, whose entries must be null). A refused worker is not used for
dispatch until it reports again and is admitted, and this list is where an
operator sees why, rather than only in a log line.
Refused registry daemons
GET /health.refused_registry_workers lists the registry daemons the latest
discovery sweep found alive and refused to adopt because their registry entry
names another build, or no build (worker/registry.rs). Each entry gives the
daemon’s worker key as its registry entry names it, its pid, and a typed
reason:
{"worker_key": "profile:stanza:eng", "pid": 4242,
"reason": {"kind": "foreign_build", "reported_build": "<their build>", "server_build": "<this build>"}}
{"worker_key": "profile:gpu:eng", "pid": 4343,
"reason": {"kind": "unreported_build", "server_build": "<this build>"}}
A refused daemon is neither reaped nor removed from the registry, because it
may belong to a server of its own build. The remedy is to restart it with this
server’s build: batchalign3 worker stop, then batchalign3 worker start for
its profile and language. Each sweep replaces the list.
Content-addressed Python worker identity
GET /health.worker_runtime_identities reports the one Python runtime pinned
by this server process. A current stdio worker computes the identity once
before its ready signal and reports:
- Python semantic version;
- SHA-256 of the resolved Python executable bytes;
- SHA-256 of the executable
batchalignpackage tree (relative names and file bytes, excluding bytecode caches, hidden/generated files, and the package’s test subtree); - SHA-256 of the exact loaded
batchalign_corenative extension bytes; and - SHA-256 of the sorted installed-distribution name/version inventory.
The ready envelope contains no executable, environment, or package paths. Rust
parses it into WorkerRuntimeIdentity, whose private representation can exist
only after schema-version, nonempty-version, and lowercase full-digest checks.
The pool pins the first admitted identity rather than deriving identity from
the instantaneous idle-worker list, so the evidence remains visible while a
worker is checked out and after a crash. A later worker with a different
identity is destroyed before it can receive a job and produces a terminal
RuntimeIdentityMismatch; the server cannot silently mix code identities.
The websocket health snapshot carries the same field. Consequently the array
has exactly zero or one element, never a history of incompatible workers.
Package-tree admission is a typed operation rather than an open-ended rglob
inside the digest loop. Each admitted RuntimePackageFile owns both its
filesystem path and relative digest identity. Parallel-test scratch is outside
that type, so xdist can create or remove test files without changing or
crashing the worker identity handshake. If an admitted runtime file itself
vanishes while being read, observation fails with a controlled
RuntimeIdentityError; it does not publish a digest over a partial tree.
Changing an admitted runtime source or native/data file still changes the
identity. A regression test covers both halves of this policy.
The PyO3 extension has a separate admitted state, LoadedNativeExtension.
Construction requires the loaded module’s __file__ to name an existing file
with one of Python’s recognized native-extension suffixes. A worker therefore
cannot become ready with only a package-version proxy for its Rust execution
path, and it cannot substitute an arbitrary file for the extension receipt.
An empty list has a precise meaning: this server has not yet observed a local Python worker. It is normal before the first Python-hosted task, and a Rust-owned Rev.AI-only workload may never start a Python worker. The current stdio ready schema requires the identity, so a spawned local worker cannot silently turn “identity missing” into an apparently complete health snapshot. External TCP workers have their own registry and transport boundary and are not represented as local stdio runtimes.
Implementation boundaries:
- Python observation and process-local freeze:
batchalign/worker/_runtime_identity.py; - ready emission:
batchalign/worker/_protocol.py; - validating Rust wire type:
crates/batchalign/src/worker/runtime_identity.rs; - ready retention:
worker/handle/{protocol,mod,lifecycle}.rs; - server-lifetime registry:
worker/pool/mod.rs; and - HTTP/websocket projection:
routes/health.rs,types/response.rs, andwebsocket.rs.
Worker crash diagnostics
When a Python worker crashes, stderr is captured via an mpsc channel
and attached to WorkerError::ProcessExited { code, stderr }. The
user-facing error message includes the last 500 chars of stderr (the
Python traceback tail). Persisted to FileStatus.error in SQLite.
Heartbeat gap detection
The drain task warns if no progress heartbeat arrives for 120 seconds, naming the stalled language groups. This catches stuck workers without needing external orchestration.
Stall naming depends on the per-language tagger described above: without
the rewrite from event.stage = "stanza_processing" to the real language
code, every group would roll up under one key and incomplete_groups()
would always return []. The per-language stage rewrite is a
load-bearing prerequisite for heartbeat-gap diagnostics.
Language group timeouts
Each language group dispatch is wrapped in tokio::time::timeout
(default: audio_task_timeout_s, minimum 1800s). Timed-out groups
produce empty responses and a clear error, the batch continues with
other languages.
Semaphore diagnostics
The bounded-concurrency semaphore in batch.rs logs:
- Total groups vs max concurrent before
join_all - Available permits on each acquire
- Language and item count per group
Daemon log persistence
Daemon logs are appended on restart (not truncated). Previous session diagnostics survive across daemon restarts.
CLI failure hints
The failure summary shows the last 5 lines of worker stderr per file and hints at the daemon log path.
Known Observability Gaps
Model loading is invisible
When a worker spawns, it loads ML models (Stanza, Whisper, Wave2Vec)
which can take 30-120 seconds. During this time, the job shows
“processing” with no progress. The worker emits a ready signal when
done, but this doesn’t propagate to job-level progress.
Needed: A “loading models” stage at the job level. The worker pool
already knows when workers are spawning vs ready. This state should be
surfaced through FileStatus.progress_stage or a new job-level field.
Files: worker/handle/mod.rs (ready signal), worker/pool/mod.rs
(spawn tracking), runner/util/file_status.rs (stage reporting)
Parse/validate phase is invisible
For batched commands, run_morphosyntax_batch_impl parses ALL files
sequentially before dispatching any workers. On 500 files with
validation warnings, this can take minutes. The job shows “0/N
processing” throughout.
Needed: A “parsing” stage with per-file progress during the parse
phase. Emit set_file_progress with FileProgressStage::Parsing as
each file is parsed.
Files: morphosyntax/batch.rs (parse loop at lines 49-83)
Parsing is sequential
The parse/validate loop in batch.rs processes files one at a time.
For 500 files, this is slow. Parsing is CPU-bound (tree-sitter) and
could be parallelized with rayon or tokio::spawn_blocking.
Architectural note: Parallelizing parsing requires thread-safe
TreeSitterParser handles or per-thread instances. The parser is
not Send (tree-sitter limitation), so rayon with thread-local
parsers is the right approach.
Files: morphosyntax/batch.rs (parse loop)
Source File Inventory
| File | What it observes |
|---|---|
runner/util/file_status/ | RunnerEventSink trait, set_file_progress (split: event_sink.rs, file_stage.rs, supervision.rs, tracker.rs, tests.rs) |
runner/util/batch_progress.rs | ProgressReport, BackendProgress, BatchProgressLedger and its SourceProgress projection |
execution/morphotag/progress.rs | BatchProgressReporter (owns the ledger, publishing cadence, stall republish) and BackendProgressPort (per-file handle) |
runner/dispatch/infer_batched.rs | Drain task, heartbeat gap, progress publishing |
morphosyntax/batch.rs | Language group dispatch, semaphore, timeouts |
worker/handle/lifecycle.rs | Stderr capture, ready signal |
worker/error.rs | ProcessExited { code, stderr } |
runner/util/error_classification.rs | Error → user-facing message translation |
store/queries/file_state.rs | Store methods for progress + WS broadcast |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Evidence, Replay, and Experiment Topology
Status: Current Last updated: 2026-09-15 21:24 EDT
This chapter is the visual map for BA3’s evidence architecture. Version 0.3.0
has raw-evidence caching and FA evidence schema 2. Version 0.4.0 additionally
has the experimental
rebuild-from-evidence and preserve-cross-speaker projections plus schema 3
stable utterance ordinals; those additions are not claims about the deployed
v0.3 service. Detailed
contracts remain in Audio-Task Cache,
Observability, and the developer references for
transcribe and
align.
The central design rule is that acquiring model evidence, projecting that evidence through local algorithms, and judging transcript quality are separate operations. BA3 now constrains the first two. Human or corpus-specific adjudication remains an experiment-layer responsibility.
For forced-alignment reruns, --existing-wor-boundaries is one such local
projection dimension. It is intentionally downstream of raw evidence and
absent from the cache key. preserve is the compatibility default;
rebuild-from-evidence keeps fresh word extents and reconstructs main-tier
coverage from their hull. A valid or structurally self-contained result still
requires acoustic adjudication, especially when adjacent utterances overlap
and the later monotonicity pass intervenes.
--end-overlap-policy is an independent local projection dimension. Its
compatibility default clamps all adjacent end overlap. The experimental
preserve-cross-speaker arm keeps overlap only when adjacent speaker codes
differ; same-speaker clamps and start-regression stripping remain unchanged.
Both policies travel inside one FaProjectionPolicy, so full, incremental,
all-reusable, and empty-group paths cannot silently apply different
combinations. A second phase type, FaFinalized, requires optional bullet
repair to run before that policy’s monotonicity projection on every path.
A cache-only ten-file development experiment held every other typed option fixed and changed only this policy. All 547 FA groups replayed from cache. Nine normalized outputs were identical; the tenth retained exactly one cross-speaker overlap that the compatibility arm had clamped. The ten same-speaker clamps and seven start-regression removals were unchanged. This proves causal isolation of the local projection, not that either boundary is acoustically preferable; a sealed listening experiment remains the promotion gate.
Implemented evidence lanes
flowchart TB
MEDIA["Media bytes"]
CHATIN["Existing CHAT"]
subgraph PAID["Remote or model inference boundaries"]
REV["Rev raw ASR evidence"]
SPK["Raw speaker evidence"]
FAW["Raw FA worker evidence"]
UTW["Boundary-model evidence"]
end
subgraph LOCAL["Versioned local projections"]
ASRP["ASR cleanup and timed chunks"]
SPKP["Normalized turns and speaker projection"]
FAP["Word timings, %wor policy,<br/>and typed end-overlap projection"]
UTP["Pre-CHAT and post-CHAT boundaries"]
end
subgraph OUTPUTS["Durable experiment products"]
CACHE["Content-addressed raw/derived cache"]
SIDE["Causal evidence sidecars<br/>schema 3: line + utterance identity<br/>utseg evidence: schema 4"]
REPLAY["Fingerprint-admitted replay bundle"]
OUT["Validated CHAT"]
end
MEDIA --> REV --> ASRP
MEDIA --> SPK --> SPKP
MEDIA --> FAW --> FAP
CHATIN --> FAP
ASRP --> UTW --> UTP
ASRP --> SPKP --> UTP --> OUT
FAP --> OUT
REV -.-> CACHE
SPK -.-> CACHE
FAW -.-> CACHE
REV -.-> SIDE
SPK -.-> SIDE
FAW -.-> SIDE
UTW -.-> SIDE
ASRP -.-> REPLAY
SPKP -.-> REPLAY
OUT -.-> REPLAY
Solid arrows are semantic processing. Dashed arrows are retained evidence or
replay products. Sidecars are files, not CHAT dependent tiers: current BA3 does
not generate %xalign or %xrev.
Utterance-segmentation sidecars carry their own version, and this build writes
schema 4, in which the boundary model’s pinned revision is a required part of
the evidence. eval utseg-replay admits only that version and refuses any
other by name rather than migrating it, so every utseg sidecar retained before
this build is schema 3 and cannot be replayed; regenerating it with the current
build is the remedy.
Inference authorization is a state transition
A cache miss is not permission to call a provider. The resolver must consume the miss into a single-use authorization, and successful evidence must be validated and committed durably before projection succeeds.
stateDiagram-v2
[*] --> WorkerRecipe: derive exact engine recipe
WorkerRecipe --> SelectedWorker: select/load recipe-specific worker
SelectedWorker --> RequestIdentity: obtain that worker's live engine identity
RequestIdentity --> CompletedEvidence: admitted durable hit
RequestIdentity --> CacheMiss: absent or deliberate refresh
RequestIdentity --> Refused: corrupt or incompatible evidence
CacheMiss --> Refused: RequireCache
CacheMiss --> AuthorizedRun: UseCache or SkipCache
AuthorizedRun --> VerifiedRun: media digest reverified
VerifiedRun --> CompletedEvidence: provider/model result validated and committed
VerifiedRun --> Refused: media drift, invalid result, or commit failure
CompletedEvidence --> CurrentProjection
CurrentProjection --> [*]
Refused --> [*]
This is a typestate boundary. Provider adapters cannot manufacture
AuthorizedRun, and offline replay cannot accidentally acquire provider-call
capability. A per-job cache key uses the capability of the exact worker selected
for that request. A process-wide availability snapshot is not evidence of which
engine handled a job and cannot enter cache identity. Lazy workers are keyed by
their engine recipe, so requests for different ASR or forced-alignment engines
cannot reuse one process and silently inherit whichever model loaded first.
Replay has two deliberately different meanings
flowchart LR
subgraph CACHE_REPLAY["Raw-evidence replay"]
CR["Validated raw cache envelope"] --> CA["Re-admit against current request"]
CA --> CP["Run current Rust projection"]
end
subgraph BUNDLE_REPLAY["Offline transcribe replay"]
BM["Immutable manifest"] --> BF{"Verify media and artifact fingerprints"}
BF -->|match| BA["Admitted projected ASR and turns"]
BF -->|drift| BX["Refuse before output/model load"]
BA --> BP["Run current downstream CHAT logic"]
end
Raw-evidence replay can test a changed local normalizer or aligner projection. The current offline transcribe bundle begins from retained projected ASR and turn artifacts, so it tests downstream speaker projection, segmentation, CHAT construction, and postprocessing without claiming that those artifacts are raw Rev or raw pyannote evidence.
FA schema 3 keeps the input-AST line index for debugging and adds an ordinal
among utterances only for every structured monotonicity effect and its
neighbour. Command provenance can insert an @Comment before final
serialization, so a line index alone is not a stable final-output address.
Experiment admission cross-checks the recorded ordinal against the exact input
CHAT and then resolves the same speaker/token identity in output CHAT; a
header-only rewrite succeeds, while an utterance insertion, deletion, reorder,
or lexical drift refuses.
Global UTR has a third, narrower offline replay seam. The
eval utr-alignment action consumes an exact clean CHAT document and retained
UTR timing tokens, then emits the typed global word-to-token plan without
inference or CHAT mutation. The plan keeps proposals for already timed lines
even though current production projection preserves their bullets. This makes
joint-boundary and word-prior research possible without confusing observed
alignment evidence with a production policy.
flowchart LR
UCHAT["Fingerprint UTR input CHAT"]
UTOK["Fingerprint UTR token JSON"]
UDP["Global monotone alignment"]
UM["Typed word matches"]
UP["All-line timing proposals"]
UR["Immutable JSON report"]
POLICY["Separate research policy"]
UCHAT --> UDP
UTOK --> UDP
UDP --> UM --> UP --> UR --> POLICY
The replay report is not raw provider evidence and does not establish final
%wor timing quality. A downstream experiment must admit input identity,
coverage, lexical relation, and proposal validity before comparing policies.
Reports are serialized completely before a destination is touched, staged in
the destination directory, fsynced, and atomically published without
replacing an existing evidence artifact.
Utterance segmentation has a reproduction seam
The eval utseg-replay action is a fourth seam, and the only one that
reproduces rather than explores. It reapplies the boundary evidence a run
retained and asks whether the current build still produces the document that
run wrote. The evidence and the output are both retained artifacts, so the
answer isolates the local segmentation projection: nothing infers, and the
boundary model never loads.
flowchart LR
subgraph RETAINED["One run's retained artifacts"]
UEV["Utseg evidence sidecar<br/>pre-CHAT or post-CHAT"]
USRC["Input CHAT, or retained ASR response"]
UOUT["Output CHAT the run wrote"]
end
UEV --> UADM{"Admit: schema, pass,<br/>per-item invariants"}
USRC --> UCOL["Collect requests with<br/>the current build"]
UADM --> UBIND{"Bind one-to-one"}
UCOL --> UBIND
UBIND --> UAPP["Reapply boundaries<br/>through the production transform"]
UAPP --> UCMP["Compare CHAT semantics,<br/>generated comments set aside"]
UOUT --> UCMP
UCMP --> UVERD["Reproduced, or a typed difference"]
Two properties make the verdict mean something. The evidence passes the same admission a live worker result passes, so a sidecar is reapplied only while it still describes applicable work, and binding proves the retained items describe the very requests this build collects rather than some other population. What a run generates to say a run happened, its stamp and the unchecked-ASR warning, is recognized through the provenance codec that writes it and set aside on both sides: a timestamp can never match by equality, and comparing it would report every replay as a difference.
A difference is an outcome, not an error, and what it implicates depends on the pass. The post-CHAT pass holds everything else fixed: the document is given, the boundaries are retained, and only the local boundary-application projection runs, so a difference there does say that projection changed between the build that wrote the artifact and the build replaying it. The pre-ASR pass rebuilds the document from the retained response, so ASR post-processing, utterance retokenization and CHAT construction all run again, and a difference there implicates any of them until a post-CHAT replay or a narrower probe separates them. Neither is by itself a verdict about which segmentation is better.
Both passes compare one basis, the AST of the serialized CHAT text, so a difference always refers to what a run would have written rather than to an in-memory shape no reader sees.
Reproducible comparative experiment loop
flowchart TD
Q["Narrow quality question"] --> C["Frozen troublesome clips<br/>and reference annotations"]
C --> B["Capture baseline identities,<br/>requests, raw outputs, and CHAT"]
B --> M["Fingerprint manifest"]
M --> V1["Projection/policy variant A"]
M --> V2["Projection/policy variant B"]
V1 --> CMP["Machine comparison:<br/>words, speakers, boundaries, timings"]
V2 --> CMP
CMP --> H["Blind human adjudication<br/>with uncertainty/notes"]
H --> D{"Evidence supports change?"}
D -->|yes| T["Regression test + implementation + docs"]
D -->|no| N["Record negative or inconclusive result"]
T --> R["Code review and exact-commit gates"]
R --> M2["New versioned projection identity"]
M2 --> Q
N --> Q
This loop is the basis for precise comparisons with the upstream BA3 fork and for
segmentation, diarization, Rev-media, and %wor studies. A system-level claim
requires the whole chain; a few plausible transcripts do not establish
universal superiority.
Boundary with IISRP and MichiganChild merge work
The following is the intended downstream research topology, not a feature the BA3 v0.3 CLI currently performs:
flowchart LR
MAN["Imperfect child-only<br/>manual CHAT"]
FULL["BA3 full-audio candidates<br/>words, speakers, boundaries, timings"]
ACOU["Acoustic signals<br/>pitch, overlap, pauses"]
SEM["Semantic signals<br/>fuzzy match, echo, lexical context"]
REC["Typed reconciliation candidates"]
SCORE["Auditable holistic scoring"]
AUTO{"Confidence / ambiguity state"}
MERGE["Merged transcript candidate"]
REVIEW["Targeted human review"]
FINAL["Validated delivery CHAT"]
MAN --> REC
FULL --> REC
ACOU --> SCORE
SEM --> SCORE
REC --> SCORE --> AUTO
AUTO -->|high confidence| MERGE
AUTO -->|ambiguous| REVIEW --> MERGE
MERGE --> FINAL
The manual child transcript is evidence, not an oracle: it may contain xxx,
miss adult interruptions, or choose different but defensible utterance
boundaries. The merge layer should therefore preserve competing candidates and
ambiguity until a typed decision is made. BA3 supplies replayable full-audio
evidence; project tooling performs the corpus-specific reconciliation.
Diagram maintenance rule
When a new cache state, evidence artifact, inference capability, or projection revision is added, update the smallest detailed diagram and this overview in the same change. A diagram is part of the contract: if it cannot distinguish raw evidence from a derived artifact or implemented behavior from planned research, it is misleading and must not be marked current.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Incremental Processing
Status: Current Last updated: 2026-05-19 20:10 EDT
Incremental processing allows batchalign to reprocess only the utterances that
changed after a user edits a CHAT file, preserving cached dependent tiers
(%mor, %gra, %wor, bullets) for unchanged content. This is the key
enabler for the transcribe → manual review → re-align workflow.
Motivation
The standard workflow is:
- Run
transcribeon audio → initial CHAT with ASR output - User reviews: fixes words, splits/merges utterances, corrects speakers
- Run
morphotagand/oralignon the edited file
Without incremental processing, step 3 reprocesses every utterance even if only 3 out of 50 changed. For morphosyntax, this means redundant Stanza inference calls. For forced alignment, this means re-aligning audio for groups whose words and existing timing are still structurally trustworthy.
The diff engine solves this by comparing the “before” version (pre-edit, with existing dependent tiers) against the “after” version (post-edit) and computing a precise per-utterance change classification.
Architecture
Before CHAT (with %mor/%gra/%wor/bullets)
│
├── parse_lenient() → ChatFile₁
│
After CHAT (user-edited)
│
├── parse_lenient() → ChatFile₂
│
▼
diff_chat(before, after) → Vec<UtteranceDelta>
│
├── Unchanged → copy dependent tiers from before
├── SpeakerChanged → copy dependent tiers (words identical)
├── TimingOnly → copy %mor/%gra, re-align FA group
├── WordsChanged → reprocess NLP, re-align FA group if timing changed
├── Inserted → process from scratch
└── Deleted → absent from output
Layer 1: Diff Engine (crates/batchalign-transform/src/diff/)
The diff engine lives in talkbank-transform and has no server
dependencies. It operates purely on ChatFile ASTs.
Algorithm:
- Extract Mor-domain words per utterance from both files using
extract_words(). - Compute fingerprints: space-joined cleaned word text per utterance.
- Run Hirschberg DP alignment on the fingerprint sequences (reusing the
existing
dp_align::align()infrastructure). - Post-process the alignment to detect substitution pairs (adjacent
ExtraPayload+ExtraReferencefrom the DP aligner). - Classify each result into an
UtteranceDelta.
For matched pairs (same fingerprint), the classifier checks speaker codes and
bullet timing to distinguish Unchanged, TimingOnly, and SpeakerChanged.
Files:
| File | Purpose |
|---|---|
diff/types.rs | UtteranceDelta enum, DiffSummary |
diff/classify.rs | diff_chat(): DP alignment + classification |
diff/preserve.rs | copy_dependent_tiers(): tier transfer between files |
Layer 2: Selective Orchestrators (batchalign)
Each orchestrator has an _incremental variant that accepts both before_text
and after_text, runs the diff, and selectively reprocesses.
Morphosyntax (process_morphosyntax_incremental)
1. Parse before and after
2. diff_chat(before, after) → deltas
3. For Unchanged/SpeakerChanged/TimingOnly:
copy_dependent_tiers(%mor, %gra) from before → after
4. For WordsChanged/Inserted:
collect payloads, check cache, infer, inject
5. Serialize
Only the utterances that need NLP reprocessing are sent to the Stanza worker. Cache hits are still checked for changed utterances (the new content might match a previous cache entry).
Forced Alignment (process_fa_incremental)
FA operates on groups (time windows containing multiple utterances), but the incremental path now preserves stable utterance-level timing before it decides which groups need worker or cache work:
1. Parse before and after
2. diff_chat(before, after) → deltas
3. For Unchanged / SpeakerChanged / TimingOnly utterances:
copy %wor from before → after
refresh main-tier word timing and utterance bullet from %wor
4. Group utterances in the refreshed "after" file
5. For each group:
if every utterance in the group was refreshed successfully:
→ reuse current main-tier timing directly
else:
→ check cache, then send misses to the FA worker
6. Inject remaining timings and serialize
This gives align --before three tiers of reuse:
- full-file
%worrefresh when the whole file is already reusable - per-utterance
%worpreservation for unchanged regions in an edited file - cache lookup and worker FA only for the remaining changed groups
A single changed utterance still causes its containing FA group to be re-aligned when that group cannot be reconstructed from preserved timing. But stable groups no longer have to go back through audio alignment just because the file contains edits elsewhere.
Layer 3: Dispatch Integration (runner/dispatch/)
The dispatch layer reads optional before_paths from the job and routes to
incremental variants when a “before” file is available:
let fa_result = if let Some(ref bt) = before_text {
process_fa_incremental(bt, &chat_text, &audio, services, fa_params, progress).await
} else {
process_fa(&chat_text, &audio, services, fa_params, progress).await
};
For morphosyntax, the batched dispatch similarly checks before_texts:
if !before_texts.is_empty() {
// Per-file incremental path
process_morphosyntax_incremental(before, after, services, ¶ms).await
} else {
// Batch path
process_morphosyntax_batch(&files, services, ¶ms).await
}
UtteranceDelta Type
pub enum UtteranceDelta {
Unchanged { before_idx, after_idx },
WordsChanged { before_idx, after_idx, timing_changed: bool },
TimingOnly { before_idx, after_idx },
SpeakerChanged { before_idx, after_idx },
Inserted { after_idx },
Deleted { before_idx },
}
Helper methods:
| Method | Returns true for |
|---|---|
needs_nlp_reprocessing() | WordsChanged, Inserted |
affects_timing() | WordsChanged (with timing), TimingOnly, Inserted, Deleted |
before_idx() | All except Inserted |
after_idx() | All except Deleted |
Dependent Tier Preservation
copy_dependent_tiers() in diff/preserve.rs transfers specified tiers from
a “before” utterance to an “after” utterance using the existing
replace_or_add_tier() injection function. It’s idempotent, safe to call
multiple times.
copy_dependent_tiers(
&before_file, before_idx,
&mut after_file, after_idx,
&[TierKind::Mor, TierKind::Gra],
);
“Before” File Sources
| Context | Before source | After source |
|---|---|---|
--in-place CLI | Current file on disk | Same file (pre-edit is the “before”) |
--before flag | Explicit path | Input file |
| REST API | before_text field | File content |
| First run | None → full processing | Input file |
When no “before” is available, the orchestrator falls back to full processing automatically.
Fallback Behavior
The incremental path falls back to full processing when:
- No “before” text is provided (first run)
- All utterances changed (
summary.unchanged == 0 && summary.speaker_changed == 0) - The diff engine cannot establish any correspondence
This ensures incremental processing is always safe, worst case, it does the same work as batch processing, while the best common rerun case avoids both cache lookup misses and worker FA for stable regions.
Command Applicability
| Command | Incremental? | Granularity | Behavior |
|---|---|---|---|
morphotag | Yes | Per-utterance | Skip unchanged; reprocess changed words |
align | Yes | Per-group | Any changed utterance in a group → re-align group |
utseg | Not yet | Per-utterance | Would skip unchanged utterances |
translate | Not yet | Per-utterance | Would skip unchanged utterances |
transcribe | N/A | Whole-file | Creates from scratch (audio → text) |
coref | N/A | Whole-document | Context-dependent, must reprocess entirely |
Cache Interaction
The per-utterance BLAKE3 cache complements the diff engine:
- Cache hit on unchanged utterance: Diff engine preserves tiers directly from “before”, cache isn’t even consulted for these.
- Cache hit on changed utterance: The new content might match a previous cache entry (e.g., fixing a typo back to the original). Cache is checked for all utterances that need reprocessing.
- Cache miss on changed utterance: Normal path, infer and cache the result.
The diff engine adds value beyond caching by preserving dependent tier alignment (the cache stores NLP results, but tier injection requires the full AST context) and by enabling FA group-level optimization (cache is per-utterance, but FA operates per-group).
Performance Impact
For a file with 50 utterances where 3 were edited:
| Path | Worker calls | Cache checks |
|---|---|---|
| Batch | 50 utterance payloads | 50 |
| Incremental | 3 utterance payloads | 3 |
For FA with 8 groups where 1 contains a changed utterance:
| Path | FA groups aligned |
|---|---|
| Batch | 8 |
| Incremental | 1 |
Files
| File | Crate | Purpose |
|---|---|---|
diff/types.rs | talkbank-transform | UtteranceDelta, DiffSummary |
diff/classify.rs | talkbank-transform | diff_chat() algorithm |
diff/preserve.rs | talkbank-transform | copy_dependent_tiers() |
morphosyntax/ | batchalign | process_morphosyntax_incremental() |
fa/ | batchalign | process_fa_incremental() |
runner/dispatch/infer_batched.rs, fa_pipeline.rs | batchalign | Dispatch routing for incremental paths |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Server Model Loading and Caching
Status: Current Last updated: 2026-09-16 08:18 EDT
This document describes every ML model loaded by batchalign3 workers, when each model is loaded into memory, and how results are cached.
How Models Are Loaded
The Rust server spawns Python worker processes keyed by (target, lang).
Targets are either:
- a released infer task such as
infer:morphosyntaxorinfer:asr - or a test-echo worker bootstrapped for one infer task without loading models
Each worker loads its own models on first use. Workers are managed
by a WorkerPool (crates/batchalign/src/worker/pool/) with a
configurable idle timeout (default 10 minutes) and automatic crash
restart.
For a multi-file job the runner pre-scales its workers before file
dispatch begins, so the batch pays one cold start rather than one per file. A
startup warmup of a configured command list existed until 2026-07-30; it had
been inert on real servers since 2026-03-26 and was removed.
Per-Command Model Inventory
morphotag (task string: morphosyntax)
| Module | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
inference/morphosyntax.py | Stanza pipeline (tokenize, pos, lemma, depparse, mwt) | stanza / HF Hub | 300-500 MB per language | First file for each language (lazy per-language dict) | Yes |
Internal caching: Per-language stanza.Pipeline dict in the worker state.
A single worker handles all languages without reloading.
Result caching: SQLite utterance cache. Key = BLAKE3(words + lang + "|mwt"),
gated by Stanza version. Stores final %mor/%gra strings.
align (task string: fa)
The server auto-chains forced alignment + UTR + disfluency + retrace.
| Module | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
inference/fa.py (Whisper FA) | openai/whisper-large-v2 | HF Hub | ~3 GB | Worker startup (immediate) | Yes |
inference/asr.py (UTR) | openai/whisper-large-v3 for every language, pinned to the commit named in model_manifest.rs | HF Hub | ~3 GB | First audio file (lazy) | Yes |
| Rust (disfluency) | None (rule-based data files) | local | negligible | N/A | No |
| Rust (retrace) | None (Rust n-gram) | local | negligible | N/A | No |
Alternative: Wave2Vec FA (inference/fa.py) uses torchaudio.pipelines.MMS_FA
(~1.6 GB, loaded at startup, from PyTorch Hub, not HF Hub).
Result caching:
- Forced alignment: SQLite. Key =
BLAKE3(audio identity + time window + words + gap-healing policy + engine). - UTR: SQLite. Key =
BLAKE3(realpath + filesize), under the namespaceutr-asr-v1:<UTR engine>:<the models that plan pinned>. The engine name alone said only which engine wrote a row, never which weights it wrote it with, so a row from before a checkpoint moved was indistinguishable from one after; naming the pinned models makes rows written under an older composition unreadable rather than silently reusable. A plan in which any model floats is ineligible: such a run infers without reading or writing the cache at all. Protected from pruning.
transcribe (server-owned composition over asr)
Current CLI default engine is Rev.AI. Alternate ASR engines are selected with
--asr-engine whisper, --asr-engine whisper_hub, or
--asr-engine whisper_rs. (whisperx and whisper_oai are accepted names
with no implementation; submitting either is refused.) The server auto-chains
disfluency + retrace.
For languages with a dedicated utterance model (eng, cmn, zho, yue),
transcribe also runs pre-CHAT utterance segmentation before CHAT assembly.
| Module / Engine | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
Rust crates/batchalign/src/revai/asr.rs: Rev (default) | Rev.AI HTTP client only | local + remote API | negligible local memory | per-file server dispatch | No |
inference/asr.py: Whisper | openai/whisper-large-v3 + optional BertUtteranceModel | HF Hub | ~3 GB + ~400 MB | Worker startup (immediate) | Yes |
| Rust (disfluency) | None | local | negligible | N/A | No |
| Rust (retrace) | None | local | negligible | N/A | No |
BertUtteranceModel languages: Only loaded when the Rust manifest
(model_manifest::UTSEG_BOUNDARY_MODELS) pins a model for the language and
sends it with the worker spawn. Currently: eng
(talkbank/CHATUtterance-en), cmn / zho (talkbank/CHATUtterance-zh_CN),
yue (Cantonese-specific model). The worker loads the pinned snapshot by local
path, never by name, so the revision it reports is one it verified on disk.
Result caching: Raw provider-shaped Rev.AI evidence is cached and replayed after strict validation. Ordinary non-Rev ASR engines are not yet cached and run inference again.
transcribe_s (server-owned composition over asr)
transcribe_s now follows the same server-owned transcribe pipeline as
transcribe. The low-level task has no CLI command literally named speaker,
but it has two product surfaces: integrated diarized transcription and the
standalone diarize command. When the selected ASR
backend already returns usable speaker labels (for example Rev.AI or the
Cantonese provider adapters), Rust keeps those labels on the default path. When
--diarize is explicitly requested, Rust also composes the low-level speaker
infer task, receives raw diarization segments, and projects them onto timed ASR
words before utterance segmentation and CHAT assembly. That projection splits
prepared chunks at speaker changes even on top of Rev-labeled output.
The default dedicated diarization backend is pyannoteAI Precision-2. Its worker
adapter performs the typed PreparedWav to UploadedMedia to
SubmittedDiarizationJob to CompletedDiarizationJob lifecycle and requests
exclusive diarization for ASR reconciliation. Local Pyannote and NeMo remain
explicit alternatives. Local Pyannote loads lazily on the first request in a
worker process and is then reused within that process.
Result caching: Integrated diarized transcription and standalone
diarize share the same validated raw-evidence and derived-turn cache. A
normal warm run can therefore replay evidence without another paid or local
diarization call. Standalone defaults to local Pyannote but can explicitly
select pyannoteAI Precision-2 or NeMo; the backend and optional/known speaker
count are part of the evidence identity.
translate (task string: translate)
| Module / Backend | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
inference/translate.py: Google (default) | None (Google Translate API) | remote | N/A | N/A | No |
inference/translate.py: Seamless | facebook/hf-seamless-m4t-medium | HF Hub | ~1.2 GB | Worker startup (immediate) | Yes |
Result caching: SQLite utterance cache. Key = BLAKE3(text + src_lang + tgt_lang).
utseg (task string: utterance)
| Module | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
inference/utseg.py | BertUtteranceModel for eng / cmn / zho / yue; otherwise Stanza pipeline (tokenize, pos, lemma, constituency) | HF Hub / stanza | ~400 MB for BERT model or 300-500 MB per Stanza language | First batch (lazy factory) | Yes |
Result caching: SQLite utterance cache. Key = BLAKE3(text + lang).
coref (task string: coref)
| Module | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
inference/coref.py | Stanza tokenizer + ontonotes-singletons_roberta-large-lora | stanza / HF Hub | ~500 MB | First file (lazy) | Yes |
English only. Result caching: None.
benchmark (server-owned composition over asr)
Same engines as transcribe plus a Rust-side WER step:
| Module | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
crates/batchalign-transform/src/benchmark.rs | None (Rust Hirschberg DP alignment via the allowlisted dp_align::align call site) | local | negligible | N/A | No |
opensmile (task string: opensmile)
| Module | Model | Source | Size | Loaded When | HF Hub |
|---|---|---|---|---|---|
inference/opensmile.py | None (C++ feature extraction) | local | negligible | Worker startup | No |
Feature sets: eGeMAPSv02, GeMAPSv01b, ComParE_2016, eGeMAPSv01b.
Result caching: None (produces CSV output).
Device Placement
All torch-based inference modules auto-detect the compute device at load time:
| Priority | Device | Notes |
|---|---|---|
| 1 | CUDA | If torch.cuda.is_available() and not --force-cpu |
| 2 | MPS | macOS Metal (Apple Silicon). Used on Apple Silicon server/client machines when available |
| 3 | CPU | Fallback |
Stanza manages its own device internally (typically CPU).
Speaker engine credentials
The default cloud engine reads BATCHALIGN_PYANNOTE_API_KEY,
BATCHALIGN_PYANNOTE_KEY, or PYANNOTE_API_KEY, in that order. It also accepts
engine.pyannote.key in the [diarize] section of ~/.batchalign.ini for
compatibility. This is a worker-owned credential path and audio is uploaded to
pyannoteAI.
Hugging Face downloads for the local Pyannote engine
The released local speaker engine loads three PINNED artifacts:
talkbank/dia-fork, talkbank/seg-fork-3.0, and
hbredin/wespeaker-voxceleb-resnet34-LM. All three repositories are public
and ungated, and a worker downloads them anonymously; none needs hf auth login, HF_TOKEN, accepted model terms, or a pyannoteAI API key.
A fourth, UNPINNED artifact is fetched behind those three, and it is
currently GATED. pyannote.audio’s SpeakerDiarization pipeline class
loads a PLDA calibration artifact unconditionally during construction,
regardless of the pinned config’s clustering choice; the released config does
not override it, so the class’s own default applies, which is the gated
pyannote/speaker-diarization-community-1 repository. A worker with no
accepted terms and no Hugging Face token fails on first use naming that
repository, mapped by batchalign.inference._model_access_errors to a typed
ModelAccessDeniedError (Rust: ProtocolErrorCodeV2::ModelAccessDenied /
ServerError::ModelAccessDenied / FailureCategory::ModelAccessDenied,
never Validation). The remedy is a Hugging Face token in
~/.batchalign.ini [auth] hf_token (checked before Hugging Face’s own
HF_TOKEN/hf auth login resolution, via
batchalign.inference.pyannote_local.resolve_huggingface_hub_token), after
accepting the repository’s terms at
https://huggingface.co/pyannote/speaker-diarization-community-1. User-facing
detail: diarize.
The repository names are not the runtime identity. The packaged
batchalign/inference/local_pyannote_model.json manifest owns an exact
40-hex Hugging Face commit and required artifact for every node in the graph.
Python validates the manifest before downloading and rewrites the pipeline’s
transitive model references to those pinned artifacts. Rust hashes those same
manifest bytes into the raw speaker-evidence model revision. A moving Hub head
therefore cannot change inference while retaining an old cache identity, and
the Python and Rust sides cannot drift through duplicated version constants.
Release-candidate verification on 2026-08-31 exercised the packaged manifest through the real Pyannote loader, not only mocks: the loaded pipeline retained the manifest’s pinned segmentation revision, resolved the embedding from that revision’s snapshot path, and completed local inference over one second of prepared silent 16 kHz PCM with the expected empty segment result. This is a runtime wiring check, not a diarization-quality claim.
This differs from some upstream Pyannote pipelines whose publishers gate their repositories. If an operator deliberately replaces the TalkBank-pinned model with such a custom model, that operator must provide whatever Hugging Face authentication and terms acceptance the custom repository requires. That credential is not part of the default Batchalign3 deployment.
Once a model is downloaded, it is cached on disk at ~/.cache/huggingface/
and does not re-download on subsequent loads.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Dynamic Programming
Status: Current Last updated: 2026-09-06 23:14 EDT
Where dynamic programming is used at runtime across the workspace, which uses are intrinsically necessary, and which are correctness-critical even when avoidable. A policy test enforces that no new runtime DP appears outside the allowlisted call sites.
Inventory
| Area | Call site | DP algorithm | Notes |
|---|---|---|---|
| Whisper ASR timestamp extraction | batchalign/inference/audio.py | DTW (_dynamic_time_warping) | Maps decoder tokens to audio frames from cross-attention matrices |
| Whisper FA token timing | batchalign/inference/fa.py | DTW | Token jump times extracted in Python, mapped in Rust |
| Wave2Vec forced alignment | batchalign/inference/fa.py | CTC forced alignment (Viterbi-style DP) | torchaudio.functional.forced_align on emission matrix vs transcript |
| FA word-level remapping | crates/batchalign/src/chat_ops/fa/alignment.rs::apply_indexed_timings | None | Indexed callback protocol maps timings 1:1 by index |
| FA token-level remapping | crates/batchalign/src/chat_ops/fa/alignment.rs::align_token_timings | None | Deterministic token→word stitching only; unmatched words remain untimed |
| UTR timing recovery | crates/batchalign/src/chat_ops/fa/utr.rs::inject_utr_timing | Hirschberg edit-distance DP | Global alignment of all document words against all ASR tokens |
| Morphosyntax retokenize mapping | crates/talkbank-transform/src/retokenize.rs::build_word_token_mapping | None | Deterministic span-join + length-aware monotonic fallback |
| WER evaluation | crates/talkbank-transform/src/benchmark.rs (uses dp_align::align) | Hirschberg edit-distance DP | Canonical use of DP for transcript comparison |
| Compare command | crates/talkbank-transform/src/compare/engine.rs (uses dp_align::align) | Hirschberg edit-distance DP | Aligns main vs gold transcript words to compute WER and inject %xsrep / %xsmor tiers |
Classification
Intrinsic: DP is the algorithm
- WER evaluation / compare command. Comparing two independent word sequences is exactly edit-distance territory.
- CTC forced alignment (
forced_align). DP is intrinsic to the model family. - Whisper DTW path. If cross-attention DTW is the chosen alignment method, DP is part of the method.
Architecturally avoidable: removed from runtime
- FA word/token remapping. Removed; indexed or deterministic stitching only.
- Retokenize char-level DP. Removed; deterministic span-join with length-aware monotonic fallback.
Correctness-critical: DP retained on purpose
- UTR global ASR → transcript DP. UTR has to align two independent full-document word sequences (transcript words and ASR tokens). A local/windowed matcher can starve later utterances of tokens that earlier utterances consumed, exactly what happened in the 407-style hand-edited transcript regression. UTR uses a single global Hirschberg alignment for this reason. Same category as WER/compare, not the avoidable-runtime-remap category.
flowchart TD
input["Transcript words + ASR words"]
cheap{"Unique exact subsequence?"}
exact["Cheap monotonic mapping\n(no DP)"]
global["Single global Hirschberg DP\n(correctness baseline)"]
output["UTR timing injection"]
input --> cheap
cheap -->|yes| exact --> output
cheap -->|no| global --> output
UTR is a monotonic aligner. Dense overlap and text/audio reordering can still leave words unmatched in heavily reworked hand-edited transcripts; that limitation is inherent to monotonic DP.
Hirschberg Optimizations
The dp_align/ implementation in talkbank-transform includes
two optimizations beyond the textbook algorithm:
- Prefix/suffix stripping. Before entering the O(mn) DP core,
align()strips matching prefixes and suffixes in O(n). For the primary use case (WER / transcript comparison, 80-95% accuracy), reduces effective DP problem size 10-100×. Only the differing middle portion enters Hirschberg recursion. - Generic
Alignabletrait. BothString(word-level) andchar(character-level) entry points share one generic implementation. Monomorphization eliminates ~200 lines of duplicated code with zero runtime overhead.
Fuzzy Matching
The DP aligner supports three word-comparison modes via MatchMode:
flowchart LR
words["Word pair:\ntranscript 'gonna'\nASR 'gona'"]
subgraph Exact["MatchMode::Exact"]
exact_cmp["'gonna' == 'gona'?"]
exact_no["NO match\ncost = 2 (substitution)"]
end
subgraph CaseInsensitive["MatchMode::CaseInsensitive"]
ci_cmp["'gonna' =~ 'gona'?\n(case-insensitive)"]
ci_no["NO match\ncost = 2"]
end
subgraph Fuzzy["MatchMode::Fuzzy threshold=0.85"]
fast["Fast path:\nexact case-insensitive?"]
jw["Jaro-Winkler similarity:\nJW('gonna','gona') = 0.95"]
threshold{"0.95 >= 0.85?"}
fuzzy_yes["YES match!\ncost = 0"]
end
words --> exact_cmp --> exact_no
words --> ci_cmp --> ci_no
words --> fast -->|no| jw --> threshold -->|yes| fuzzy_yes
Jaro-Winkler compares two strings by counting matching characters within a distance window, penalizing transpositions, and boosting for common prefixes. Returns 0.0 (different) to 1.0 (identical). Better than Levenshtein for short words because it doesn’t penalize length differences as harshly.
Without fuzzy matching, a single ASR substitution (“gonna” vs “gona”) forces the DP to treat it as a gap (cost 2) rather than a match (cost 0). This can cascade, the mismatched word shifts subsequent alignments, potentially leaving entire utterances unmatched. With fuzzy matching, the substitution is recognized as a match, keeping the alignment anchored.
The default threshold (0.85) is tuned empirically across 6 corpora and 59 files. Examples at this threshold:
| Pair | JW | Match at 0.85? |
|---|---|---|
| “gonna” / “gona” | 0.95 | Yes |
| “went” / “wen” | 0.94 | Yes |
| “yesterday” / “yestarday” | 0.92 | Yes |
| “mhm” / “mmhm” | 0.83 | No |
| “the” / “da” | 0.00 | No |
| “he” / “she” | 0.61 | No |
| “cat” / “dog” | 0.00 | No |
UTR Two-Pass Overlap-Aware Alignment
When a file has overlap markers (+< linkers or CA ⌊ markers),
UTR uses a two-pass strategy to handle backchannel timing
recovery.
flowchart TD
file["CHAT file with overlaps"]
density{"Overlap density\n> 30%?"}
subgraph Pass1["Pass 1: Global DP"]
exclude["Exclude overlap utterances\nfrom word sequence"]
include["Include ALL utterances\n(density too high to exclude)"]
global["Hirschberg DP alignment\n(fuzzy matching 0.85)"]
end
subgraph Pass2["Pass 2: Backchannel Recovery"]
find_pred["Find predecessor utterance"]
ca_check{"Predecessor has\nCA marker ⌈?"}
full_window["Search full predecessor\ntime range"]
narrow_window["Narrow search to\nonset position ± buffer"]
windowed_dp["Small windowed DP\non ASR tokens in window"]
end
subgraph Fallback["Best-of-both"]
compare{"Two-pass creates\nfewer FA groups?"}
keep["Keep two-pass result"]
revert["Revert to global result"]
end
file --> density
density -->|"no (≤ 30%)"| exclude --> global
density -->|"yes (> 30%)"| include --> global
global --> find_pred --> ca_check
ca_check -->|yes| narrow_window --> windowed_dp
ca_check -->|no| full_window --> windowed_dp
windowed_dp --> compare
compare -->|"no (equal or more)"| keep
compare -->|"yes (fewer groups)"| revert
Configurable parameters
All tuning flags below apply only to explicit two-pass. Global and auto
use case-insensitive exact matching.
| Flag | Default | Controls |
|---|---|---|
--utr-strategy | auto | auto (currently global) / global / two-pass |
--utr-ca-markers | enabled | Use ⌈⌉⌊⌋ for onset windowing |
--utr-density-threshold | 0.30 | Max overlap fraction before skipping exclusion |
--utr-tight-buffer | 500 | Pass-2 tight window ±ms |
--utr-fuzzy | 0.85 | Jaro-Winkler similarity threshold |
CA marker onset estimation
When a predecessor utterance has ⌈ markers, the proportional position of ⌈ among the utterance’s words estimates when overlap begins:
flowchart LR
utt["*SPK: no pues de lo que sea ⌈tengo media hora⌉\n12660_15585"]
frac["⌈ at word 7 of 10\nfraction = 0.6"]
onset["onset = 12660 + 0.6 × (15585-12660)\n= 14415ms"]
window["Search window:\n14415 ± 500ms\n(vs full 12660-15585)"]
utt --> frac --> onset --> window
Narrows the pass-2 search window from the full predecessor range (~3 seconds in this example) to ~1 second around the estimated onset, roughly a 3× reduction in search space.
Known DP Failure Modes
- Crossing alignments / rapid overlaps. Global monotonic aligners cannot represent crossing matches; one side is dropped or mis-assigned.
- Repeated-token ambiguity. Repeated words create many equal-cost paths; deterministic tie-breaks may pick semantically wrong matches.
- ASR drift and hallucinations. Large payload/reference divergence causes sparse matches and low timing coverage.
- Tokenization or normalization mismatch. Char-level DP may align surprising spans when punctuation/case/tokenization differ.
- Temporal validity vs textual order. Correct per-utterance times can still violate CHAT monotonicity when transcript order diverges from audio order.
Mitigations in code
- Monotonicity enforcement (E362):
enforce_monotonicity()strips timing from regressions after FA. - Same-speaker overlap enforcement (E704),
strip_e704_same_speaker_overlaps()strips earlier conflicting timing. - Untimed fallback windows: proportional boundary estimates keep FA from skipping all untimed utterances. See Proportional FA Estimation.
- Retokenize diagnostics + safe fallback: invalid token mappings keep original words and mark parse taint.
Allowlist Policy
batchalign/tests/test_dp_allowlist.py::test_chat_ops_dp_calls_are_allowlisted
fails CI if new runtime dp_align::align / dp_align::align_chars
call sites appear outside the allowlist (the test scans both
crates/batchalign/src/**/*.rs and crates/talkbank-transform/src/**/*.rs):
| Call site | Purpose |
|---|---|
crates/talkbank-transform/src/benchmark.rs | WER evaluation |
crates/talkbank-transform/src/compare/engine.rs | Transcript comparison (window alignment + rotation, 2 calls) |
crates/batchalign/src/chat_ops/fa/utr.rs | UTR global timing recovery |
crates/batchalign/src/chat_ops/fa/utr/two_pass.rs | UTR two-pass overlap-aware pass |
The PyO3 boundary surface no longer hosts a dp_align call site
(the pre-slimdown pyfunctions.rs bridge was retired). Any new
runtime DP call site must be added to the allowlist in
test_dp_allowlist.py with a justification.
This page last changed: 2026-09-07 (commit 52b853df). The whole book last changed: 2026-09-16 (commit 34d249d8).
Test Server and Worker Lifecycle
Status: Current Last updated: 2026-05-19 22:58 EDT
The problem
ML integration tests (golden snapshots, audio transcription, parity checks, profile verification) need a running batchalign server with loaded Python ML workers. Each worker loads Whisper, Stanza, or pyannote models consuming 2-5 GB RAM. The lifecycle of these workers during testing has caused three kernel OOM panics (2026-03-19, 2026-03-20) on a 64 GB developer machine.
Root cause: per-binary server isolation (resolved)
Previously, each Rust integration test binary (golden.rs, golden_audio.rs,
golden_parity.rs, profile_verification.rs, etc.) was compiled as a separate
executable. Each binary included mod common; which compiled the shared fixture
module into its own address space. The fixture uses a static LazyLock for the
server backend, process-local, so 7 binaries = 7 independent worker pools.
Implemented solution: single binary consolidation
All ML integration tests are now consolidated into one binary (ml_golden.rs)
with submodules. One binary = one process = one LazyLock = one
PreparedWorkers = one set of loaded models. Peak memory is single-digit GB
instead of one-pool-per-binary multiples.
The submodule layout (under crates/batchalign/tests/ml_golden/) groups tests
by what they exercise:
ml_golden (one binary, one process)
→ LazyLock → PreparedWorkers → python3 (Stanza, Whisper, Wave2Vec, pyannote)
├── golden // baseline morphotag goldens
├── morphotag // morphotag-specific scenarios
├── align // forced-alignment goldens
├── compare // benchmark / WER goldens
├── compare_master_parity // BA2-vs-BA3 parity
├── coref // coref goldens
├── benchmark // benchmark task goldens
├── avqi // AVQI voice-quality goldens
├── opensmile // OpenSMILE feature-extraction goldens
├── options // option-receipt / config-roundtrip
├── live_server_fixture // bare-fixture sanity
├── profile_verification // server profile/dispatch verification
├── error_paths // error and recovery goldens
├── audio_helpers.rs // shared audio test helpers
└── parity_helpers.rs // shared parity test helpers
The LiveServerSession fixture within the binary is well-designed:
- One
PreparedWorkersbackend shared across all 70 tests - Fresh HTTP server per session (new port, new jobs dir, new SQLite)
- Semaphore-gated sessions so tests don’t collide on control-plane state
- Warm model cache across tests, only the first test pays cold-start cost
Architecture overview
The fixture system has three layers: the process-global backend (loaded models), the fixture thread (session lifecycle), and per-test sessions (isolated HTTP servers). This diagram shows the full structure:
graph TB
subgraph "ml_golden process"
LL["static LIVE_FIXTURE: LazyLock"]
LL -->|"initializes once"| FT["Fixture Thread<br/>(dedicated OS thread + Tokio runtime)"]
FT -->|"owns"| BS["BackendState"]
BS -->|"Ready"| LFB["LiveFixtureBackend"]
LFB --> PW["PreparedWorkers"]
LFB --> SC["ServerConfig"]
PW -->|"holds"| WP["WorkerPool"]
WP -->|"manages"| W1["python3 -m batchalign.worker<br/>--task morphosyntax --lang eng<br/>(Stanza ~1.5 GB)"]
WP -->|"manages"| W2["python3 -m batchalign.worker<br/>--task fa --lang eng<br/>(Whisper/Wave2Vec ~3 GB)"]
WP -->|"manages"| W3["python3 -m batchalign.worker<br/>--task asr --lang eng<br/>(Whisper ~3 GB)"]
FT -->|"creates/destroys"| AS["ActiveSession"]
AS --> AX["Axum HTTP server<br/>(ephemeral port)"]
AS --> DB["SQLite (jobs)"]
AS --> TD["TempDir (state)"]
subgraph "Test Threads"
T1["golden::morphotag_eng_simple"]
T2["golden_audio::align_eng_wav2vec"]
T3["golden_parity::parity_morphotag_eng"]
end
T1 -->|"Semaphore acquire"| SEM["Semaphore(1)"]
T2 -.->|"waits"| SEM
T3 -.->|"waits"| SEM
SEM -->|"Acquire command"| FT
end
style LL fill:#f9f,stroke:#333
style PW fill:#bbf,stroke:#333
style W1 fill:#fbb,stroke:#333
style W2 fill:#fbb,stroke:#333
style W3 fill:#fbb,stroke:#333
style AX fill:#bfb,stroke:#333
Fixture thread lifecycle
The fixture thread runs on a dedicated OS thread with its own Tokio runtime.
It processes Acquire and Release commands from test threads via an
mpsc channel. The backend (Python workers + models) is initialized lazily
on the first Acquire and cached for all subsequent sessions.
sequenceDiagram
participant T as Test Thread
participant S as Semaphore(1)
participant FT as Fixture Thread
participant BS as BackendState
participant PW as PreparedWorkers
Note over FT: Thread started by LazyLock
T->>S: acquire_owned()
S-->>T: OwnedSemaphorePermit
T->>FT: Acquire { reply }
alt BackendState::Uninitialized (first test)
FT->>BS: ensure_backend()
BS->>PW: prepare_workers()
Note over PW: resolve_python()<br/>spawn workers<br/>load models<br/>(Stanza, Whisper, etc.)
PW-->>BS: PreparedWorkers
BS-->>FT: BackendState::Ready
end
FT->>FT: start_session(backend)
Note over FT: TempDir::new()<br/>RuntimeLayout::from_state_dir()<br/>create_app_with_prepared_workers()<br/>TcpListener::bind("127.0.0.1:0")<br/>axum::serve()
FT-->>T: SessionSnapshot { base_url, state_dir, infer_tasks }
Note over T: Run test:<br/>POST /jobs → poll → GET /results
T->>FT: Release { reply }
FT->>FT: cleanup_session()
Note over FT: server_task.abort()<br/>state.shutdown_for_reuse(5s)<br/>drop(state)<br/>drop(runtime_root)
FT-->>T: ack
T->>S: drop(OwnedSemaphorePermit)
Note over S: Next test can acquire
Python worker startup and model loading
When the backend initializes (first Acquire), prepare_workers() spawns
Python worker subprocesses. Each worker loads multi-GB ML models into memory
and signals readiness over its stdio JSON-lines protocol.
sequenceDiagram
participant RS as Rust Server
participant WP as WorkerPool
participant PY as python3 -m batchalign.worker
RS->>WP: prepare_workers(config, pool_config)
WP->>PY: spawn(--task morphosyntax --lang eng)
Note over PY: import stanza<br/>stanza.Pipeline("en")<br/>~1.5 GB into RAM<br/>~15-30s cold start
PY-->>WP: stdout: {"ready": true, "pid": 12345, "transport": "stdio"}
WP->>PY: spawn(--task fa --lang eng)
Note over PY: import whisper<br/>whisper.load_model("base")<br/>~3 GB into RAM<br/>~30-60s cold start
PY-->>WP: stdout: {"ready": true, "pid": 12346, "transport": "stdio"}
Note over WP: Workers registered in pool<br/>PID files written to ~/.batchalign3/worker-pids/
WP-->>RS: PreparedWorkers { pool, infer_tasks }
Note over RS: Backend ready, all subsequent<br/>Acquire commands reuse these workers
Per-test session lifecycle
Each test acquires a session (serialized by the semaphore), gets a fresh HTTP server with its own jobs directory and SQLite database, runs its test logic, then releases the session. The Python workers persist across sessions.
sequenceDiagram
participant T as Test Function
participant LSS as LiveServerSession
participant AX as Axum Server (ephemeral)
participant WP as WorkerPool (shared)
participant PY as Python Worker (warm)
T->>LSS: require_live_server(InferTask::Morphosyntax)
LSS->>LSS: LiveServerSession::acquire()
Note over LSS: Semaphore → Acquire → start_session()
T->>AX: POST /jobs { command: "morphotag", files: [...] }
AX->>AX: Parse CHAT, extract words
AX->>WP: checkout worker for (morphosyntax, eng)
WP-->>AX: CheckedOutWorker (reuses warm process)
AX->>PY: stdin: {"op": "batch_infer", "items": [...]}
PY-->>AX: stdout: {"results": [{pos, lemma, deprel}...]}
AX->>AX: Inject %mor/%gra into CHAT AST
AX->>AX: Serialize CHAT, store result
AX-->>T: 200 OK { job_id }
T->>AX: GET /jobs/{id} (poll)
AX-->>T: { status: "completed" }
T->>AX: GET /jobs/{id}/results
AX-->>T: { files: [{ content: "..." }] }
T->>T: assert / insta::assert_snapshot!
T->>LSS: drop (or explicit close)
LSS->>LSS: Release → cleanup_session()
Note over LSS: HTTP server aborted<br/>SQLite + TempDir dropped<br/>Workers stay alive for next test
Cleanup and safety layers
Multiple overlapping mechanisms ensure workers are cleaned up even if tests crash or are killed.
graph TB
subgraph "Normal shutdown"
A["Test completes"] --> B["LiveServerSession::close()"]
B --> C["cleanup_session()"]
C --> D["server_task.abort()"]
C --> E["state.shutdown_for_reuse(5s)"]
E --> F["Workers returned to pool<br/>(stay warm for next test)"]
end
subgraph "Drop fallback"
G["Test panics"] --> H["LiveServerSession::Drop"]
H --> I["thread::spawn release"]
I --> J["cleanup_session() on background thread"]
end
subgraph "Process exit"
K["All tests done /<br/>binary exits"] --> L["WorkerPool::Drop"]
L --> M["Kill all idle workers<br/>(SIGTERM)"]
L --> N["Remove PID files"]
end
subgraph "Orphan recovery (next startup)"
O["New test run starts"] --> P["PID file reaper"]
P --> Q["Scan ~/.batchalign3/worker-pids/"]
Q --> R{"Worker alive?<br/>Parent dead?"}
R -->|"Yes (orphan)"| S["SIGTERM → 2s → SIGKILL"]
R -->|"No (stale file)"| T["Remove PID file"]
R -->|"Both alive"| U["Skip (belongs to<br/>running server)"]
end
subgraph "External guard"
V["Claude Code session"] --> W["Guard hook checks<br/>pgrep batchalign.worker"]
W -->|"Workers found"| X["Block test command<br/>(prevent double-spawn)"]
end
style F fill:#bfb,stroke:#333
style M fill:#fbb,stroke:#333
style S fill:#fbb,stroke:#333
Defense-in-depth layers
These remain as additional safety nets beyond the single-binary consolidation:
| Layer | Where | What |
|---|---|---|
required-features gate | crates/batchalign/Cargo.toml | ML binary excluded from a plain cargo test; opt in with --features ml-golden --test ml_golden |
--test-threads=1 | the ML entry point (make batchalign-test-ml-golden) | ML binary serialized when opted in |
| Claude Code guard hook | operator’s ~/.claude/settings.json deny list (workspace-level, not tracked in this repo) | Blocks test commands when worker processes detected |
| Global worker cap | WorkerPool (max_total_workers) | Hard ceiling on total workers across all keys |
WorkerPool::Drop | pool/mod.rs | Kills idle workers when pool dropped without shutdown() |
| PID file reaper | pool/reaper.rs | Scans ~/.batchalign3/worker-pids/ on startup, kills orphans |
Future: shared test daemon (historical analysis)
If the test suite outgrows the single-binary approach (e.g., the binary becomes too large to link, or test isolation requires separate processes), the next step is a shared test daemon. This is preserved here as a future option, not a current plan.
The idea: one long-lived server for the entire ML-suite invocation, with test binaries connecting as HTTP clients. The server’s autotuner and memory gate handle scheduling. Models load once and stay warm.
Implementation options (in order of simplicity):
- A runner setup hook: some harness starts a daemon
- Test-managed daemon: file-lock coordination in
common/mod.rs - Always-on dev daemon: assume a running server, skip if absent
Relationship to the broader worker architecture
The test lifecycle problem is a microcosm of the deployment lifecycle:
- Development: one developer machine, multiple concurrent test/dev sessions
- Production: one server, multiple concurrent jobs from the fleet
The single-daemon test architecture exercises the same code paths as production: autotuner, memory gate, worker pool, idle timeout, health checking. The per-binary in-process approach exercises none of these, which is why it was blindsided by the OOM crashes that production handles gracefully.
Making tests use the production dispatch path also means test failures surface real bugs (scheduling, memory, lifecycle) rather than hiding them behind per-test isolation.
This page last changed: 2026-07-30 (commit 5157a549). The whole book last changed: 2026-09-16 (commit 34d249d8).
How align Throughput Works
Status: Current
Last verified: 2026-03-05
This page describes the current Rust server / Python worker runtime for
batchalign3 align. Older BA2 Python-CLI executor details are relevant only in
the migration book, not as the active release path.
Current execution model
align is no longer a Python CLI ThreadPoolExecutor pipeline. In the current
system:
- The Rust CLI submits the job to a single server or local daemon.
- The Rust server groups utterances into FA windows per file.
- The server checks the utterance cache per group.
- Cache misses are sent to Python workers through the worker pool.
- Rust applies returned timings, generates
%wor, and enforces monotonicity.
Key current components:
- server runner:
crates/batchalign/src/runner/mod.rs - FA orchestrator:
crates/batchalign/src/fa/ - FA grouping/alignment:
crates/batchalign/src/fa/ - worker pool:
crates/batchalign/src/worker/pool/
Throughput levers that matter now
Worker reuse
Python workers are persistent subprocesses keyed by (command, lang). The
server reuses idle workers instead of paying cold-start cost on every file.
Job concurrency cap
The server limits concurrent jobs with max_concurrent_jobs. This is the main
top-level throughput guard, not the old Python CLI executor split.
Pre-scaling
For multi-file jobs, the server pre-scales workers before dispatch to reduce sequential spawn latency.
Cache reuse
Forced-alignment results are cached per audio window + transcript text + gap-healing policy + engine. Re-runs mainly pay for changed groups.
Largest-first discovery
Current CLI discovery sorts matching files by size descending before submission, which reduces straggler-heavy long runs.
What is still parallel and what is not
- parallel:
- multiple files/jobs can be active concurrently
- multiple workers can handle different infer/execute requests
- not magically parallel:
- a single FA window still depends on the underlying model/runtime cost
- cache misses still require real model work
This page last changed: 2026-08-14 (commit dd96e330). The whole book last changed: 2026-09-16 (commit 34d249d8).
Proportional FA Estimation
Status: Current Last updated: 2026-05-19 14:18 EDT
When forced alignment runs against a CHAT file with utterances that have no timing bullets, the FA grouping algorithm falls back to proportional estimation: untimed utterances get an estimated audio window based on their word-count fraction of the file. This gives the FA model a reasonable search window so it can produce real timing, instead of skipping untimed utterances entirely.
Estimation rule
For each untimed utterance:
estimated_start = (words_before / total_words) * total_audio_ms
estimated_end = (words_before + this_utt_words) / total_words * total_audio_ms
A 2-second buffer is added on each side, clamped to
[0, total_audio_ms]:
let buffer_ms = 2000;
let start = estimated_start.saturating_sub(buffer_ms);
let end = (estimated_end + buffer_ms).min(total_audio_ms);
The FA model (Whisper or Wave2Vec) conditions on transcript text and finds where it occurs in the audio, so the window only needs to be approximately correct. If the estimate is off, FA produces slightly less accurate timing but won’t crash or skip the utterance.
Mixed files
Files with some timed and some untimed utterances are handled naturally. Timed utterances use their real bullets. Untimed utterances use proportional estimates. Both are grouped normally.
When proportional estimation runs
UTR (Utterance Timing Recovery) is the default first pass,
inject_utr_timing() sets utterance bullets from ASR tokens before
FA grouping, so ~100% of utterances get timing from ASR.
Proportional estimation is the fallback:
- UTR enabled (default). UTR populates utterance bullets from the ASR pre-pass. Proportional estimation usually doesn’t fire.
- UTR disabled (
--no-utr). Proportional estimation kicks in duringgroup_utterances()whentotal_audio_msis available. ~96% coverage on test corpora. - UTR partial success. Individual utterances that UTR could
not match (the
unmatchedcount inUtrResult) fall through to proportional estimation. - Neither, no
total_audio_ms. Untimed utterances are skipped from FA grouping (legacy behavior).
The Rust server always passes total_audio_ms when available
(crates/batchalign/src/runner/dispatch/fa_pipeline.rs). It’s a
no-op for pre-timed files (Rust never hits the estimation path).
Implementation
group_utterances() in
crates/batchalign/src/chat_ops/fa/grouping.rs takes a
total_audio_ms: Option<u64> parameter and uses a two-pass
approach:
- First pass: count total alignable words across all
utterances (timed and untimed). If
total_audio_msisNone, skip untimed utterances as before. - Second pass: for untimed utterances when
total_audio_msisSome, compute the proportional estimate and use it as the bullet.
The post-processing loop handles utterances that were untimed on
input but received timing from FA, they need
postprocess_utterance_timings and add_wor_tier too.
The Python worker (batchalign/inference/fa.py) computes audio
duration from the loaded ASRAudioFile:
duration_ms = int(round(f.tensor.shape[0] / f.rate * 1000))
For audio files passed by path (without preloading), torchaudio.info()
gives duration without loading the full file.
Why proportional, not learned
- No new dependencies: pure arithmetic, runs in microseconds.
- Deterministic: same input always produces same windows.
- Good enough for FA: FA only needs an approximate window; it does precise alignment within the window using the actual audio signal.
- Graceful degradation: if the estimate is off, FA may produce slightly less accurate timing but won’t crash or skip utterances.
A learned window predictor would add a model dependency and training pipeline for marginal gain, proportional estimation is already accurate enough that the FA window finds the utterance.
Tests
| Layer | Coverage |
|---|---|
Rust unit (fa/grouping.rs) | Untimed grouped with proportional estimates when total_audio_ms is provided |
| Rust unit | Untimed still skipped when total_audio_ms is None (backward compat) |
| Rust unit | Mixed timed/untimed grouped correctly |
| Rust unit | Buffer clamped to [0, total_audio_ms] |
| Python integration | add_forced_alignment with untimed CHAT + total_audio_ms produces timing |
| Python integration | add_forced_alignment with untimed CHAT + no total_audio_ms produces no timing (backward compat) |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Overlap Encoding: &* and +< Internals
Status: Current Last updated: 2026-09-06 23:14 EDT
AST Representation
&*: OtherSpokenEvent
Model (talkbank-tools): ../chatter/crates/talkbank-model/src/model/content/other_spoken.rs
pub struct OtherSpokenEvent {
pub speaker: SpeakerCode, // e.g., "INV"
pub text: smol_str::SmolStr, // e.g., "oh_okay_yeah"
pub span: Span, // source location (skipped in serde)
}
Appears in two enum locations:
UtteranceContent::OtherSpokenEvent(OtherSpokenEvent): top-level contentBracketedItem::OtherSpokenEvent(OtherSpokenEvent): inside groups
Parser (talkbank-tools):
../chatter/crates/talkbank-parser/src/parser/tree_parsing/main_tier/content/
The tree-sitter grammar accepts &* + speaker chars + : + non-whitespace chars.
Serialization: &*SPK:text: roundtrips cleanly via WriteChat.
+<: Linker::LazyOverlapPrecedes
Model (talkbank-tools): ../chatter/crates/talkbank-model/src/model/content/linker.rs
#![allow(unused)]
fn main() {
pub enum Linker {
LazyOverlapPrecedes, // +<
OtherCompletion, // ++
QuickUptakeOverlap, // +^
// ...
}
}
Stored on TierContent.linkers: TierLinkers (a Vec<Linker> newtype).
Linkers appear at the start of an utterance’s content, before words.
Content Walker Behavior
The content walker (for_each_leaf / for_each_leaf_mut) skips
OtherSpokenEvent entirely. It is listed in the no-op match arm alongside
events, pauses, overlap points, and other non-alignable content:
UtteranceContent::OtherSpokenEvent(_) => {} // skipped
This means &* content:
- Is not counted in word alignment (Wor, Mor, Pho, Sin domains)
- Does not appear in
%wortier generation - Is not extracted by
collect_fa_words()for forced alignment - Is not included in the UTR reference word sequence
Two-Pass UTR Strategy
When +< or CA overlap markers (⌊) are present, the alignment pipeline uses a
two-pass UTR strategy. See Forced Alignment, UTR
for the algorithm details, and the CHAT Data Model content-walker API
(walk_overlap_points) in the chatter project.
Key points:
- Pass 1 excludes overlap utterances from the global DP alignment
- Pass 2 recovers their timing from the predecessor’s audio window
- CA overlap markers (position) narrow the pass-2 search window via proportional onset estimation
- Best-of-both fallback compares FA group counts to avoid regression on non-English
Code: crates/batchalign/src/fa/utr.rs and
crates/batchalign/src/fa/utr/two_pass.rs
&* → +< Conversion
An experimental convert subcommand transforms &* to separate +<
utterances using the typed AST:
- Walk each utterance’s content (including inside groups).
- Extract
OtherSpokenEventnodes, recording speaker + text. - Remove them from the host utterance.
- For each extracted event, create a new
Utterancewith+<linker and words split from the underscore-joined text. - Insert after the host utterance.
Edge cases handled
- Multiple
&*in one utterance (each becomes its own+<utterance) - Multi-word
&*with underscores (oh_okay_yeah→oh okay yeah) &*inside groups (<... &*INV:mhm ...> [//])- Reverse direction (
&*PAR:yeahon INV’s line) - Host utterances with and without timing bullets
- Host dependent tiers preserved (they were already
&*-invisible)
Corpus Statistics
&* (OtherSpokenEvent)
| Corpus | Files | Total markers | Single-word % |
|---|---|---|---|
| ca-data | 256 | 12,016 | 96% |
| aphasia-data | 644 | 10,161 | 88% |
| rhd-data | 190 | 5,160 | 83% |
| psychosis-data | 236 | 2,799 | 98% |
| tbi-data | 135 | 2,105 | 90% |
| dementia-data | 390 | 1,680 | 89% |
| slabank-data | 191 | 774 | , |
| childes-data | 146 | 411 | , |
| Total | ~35,000 | 91% |
Top words: mhm (~12,500), yeah (~5,500), okay (~3,300), mm (~1,400).
+< (LazyOverlapPrecedes)
| Corpus | Files | +< utterances |
|---|---|---|
| childes-data | 10,596 | 194,720 |
| phon-data | 614 | 50,892 |
| biling-data | 248 | 37,727 |
| aphasia-data | 1,241 | 15,720 |
| tbi-data | 251 | 7,469 |
| ca-data | 242 | 6,606 |
| dementia-data | 1,536 | 4,745 |
| Total | ~327,000 |
Of these, ~131,000 (40%) already have timing bullets.
File Locations
| File | Purpose |
|---|---|
crates/batchalign/src/chat_ops/fa/utr.rs | UtrStrategy trait, GlobalUtr, select_strategy, run_global_utr |
crates/batchalign/src/chat_ops/fa/utr/two_pass.rs | TwoPassOverlapUtr, recover_overlap_timing |
crates/batchalign/src/chat_ops/fa/tests/ | Integration tests (snapshots + per-feature modules) |
crates/batchalign/src/runner/dispatch/utr.rs | resolve_strategy, UtrPassContext.strategy |
crates/batchalign/src/types/options.rs | UtrOverlapStrategy enum |
crates/batchalign/src/cli/args/commands.rs | --utr-strategy CLI flag |
This page last changed: 2026-09-07 (commit 52b853df). The whole book last changed: 2026-09-16 (commit 34d249d8).
Algorithm Visualizations
Status: Current Last updated: 2026-05-19 19:23 EDT
The dashboard ships interactive visualizations for retokenization mapping and DP alignment (both static and live-from-job modes). Visualizations for the ASR pipeline waterfall and FA timeline are not yet implemented.
The batchalign3 dashboard includes interactive algorithm visualizations that show the internal workings of key algorithms, DP alignment, ASR post-processing, forced alignment timing, and retokenization mapping. Each visualization supports two modes:
- Static mode: educational, with editable sample data and no server required. TypeScript ports of the Rust algorithms run locally in the browser.
- Live mode: shows actual intermediate states from a completed job,
fetched via the
GET /jobs/{id}/tracesREST endpoint.
Architecture
┌─────────────────────────────────────┐
│ React Dashboard (frontend/) │
│ │
│ /dashboard/visualizations/ │
│ ├── dp-alignment ──┐ │
│ ├── asr-pipeline ──┤ Static │
│ ├── fa-timeline ──┤ sample │
│ └── retokenize ──┘ mode │
│ │
│ /dashboard/jobs/:id/traces/ │
│ ├── dp-alignment ──┐ │
│ ├── asr-pipeline ──┤ Live │
│ ├── fa-timeline ──┤ job │
│ └── retokenize ──┘ mode │
│ │
│ engines/ ← TS ports for static │
│ mode + rendering logic │
└──────────────┬──────────────────────┘
│ REST (live mode)
▼
┌─────────────────────────────────────┐
│ Rust Server │
│ │
│ GET /jobs/{id}/traces │
│ → JobTraces per file │
│ │
│ Structured results: │
│ FaResult, MorphosyntaxResult │
│ always carry intermediate data │
│ │
│ Storage: ephemeral in-memory │
│ (moka LRU, 50 jobs, 1hr TTL) │
└─────────────────────────────────────┘
Structured Result Types
Orchestrators return rich result types that always carry intermediate data,
regardless of whether traces are stored. The dispatch layer decides what to
persist based on the job’s debug_traces flag.
FaResult
Returned by process_fa() in crates/batchalign/src/fa/:
pub struct FaResult {
pub chat_text: String,
pub groups: Vec<FaGroupTrace>,
pub pre_injection_timings: Vec<Vec<Option<TimingTrace>>>,
pub gap_healing: WordGapHealing,
pub violations: Vec<ViolationTrace>,
}
The dispatch layer extracts chat_text for file output. When debug_traces
is enabled, it calls into_timeline_trace() to build a FaTimelineTrace and
stores it via TraceStore::upsert_file().
MorphosyntaxResult
Returned by process_morphosyntax() (single-file path):
pub struct MorphosyntaxResult {
pub chat_text: String,
pub retokenizations: Vec<RetokenizationInfo>,
}
RetokenizationInfo is emitted by inject_results() in
batchalign whenever Stanza retokenization occurs, it captures the
original words, Stanza tokens, and the word-to-token mapping for each affected
utterance.
Design principle
Previous iterations passed a debug_traces: bool parameter through the
orchestrator call chain and conditionally collected trace data alongside the
main output. This added complexity without benefit, the intermediate data
(groups, timings, retokenization mappings) was already computed as part of
normal processing.
The current design makes the orchestrator API surface richer by default:
structured results always carry the intermediate state. The debug_traces
flag only controls whether the dispatch layer stores that data in the
ephemeral trace cache. This is simpler, avoids parameter threading, and opens
the door to other consumers of the structured data (e.g. detailed error
reports, regression analysis).
Trace Storage
TraceStore wraps a moka::future::Cache<String, Arc<JobTraces>> with:
- Capacity: 50 jobs (LRU eviction)
- TTL: 1 hour per entry
- Location: field on
JobStore(accessible everywhere the store is) - Concurrency: uses moka’s
and_upsert_withfor per-key atomic read-modify-write, concurrent FA file completions for the same job are serialized without blocking unrelated jobs
Traces are diagnostic-only and not persisted to SQLite.
The primary write API is upsert_file(job_id, file_index, file_traces) which
atomically gets-or-creates the JobTraces entry, inserts the file, and puts
it back. This is safe to call from multiple concurrent process_one_fa_file
tasks within the same job.
Activation
Per-job: set "debug_traces": true in the job submission JSON.
POST /jobs { "command": "align", "debug_traces": true, ... }
REST Endpoint
GET /jobs/{job_id}/traces
→ 200: JobTraces JSON
→ 404: job not found
→ 204: job exists but no traces collected
GET /jobs/{job_id}/traces/{file_index}
→ 200: FileTraces JSON (single file)
→ 404: file index not found
Trace Data Model
All trace types live in crates/batchalign/src/types/traces.rs.
JobTraces
└── files: BTreeMap<usize, FileTraces>
├── filename: String
├── dp_alignments: Vec<DpAlignmentTrace>
├── asr_pipeline: Option<AsrPipelineTrace>
├── fa_timeline: Option<FaTimelineTrace>
└── retokenizations: Vec<RetokenizationTrace>
| Trace type | Source orchestrator | What it captures |
|---|---|---|
DpAlignmentTrace | dp_align.rs | Full cost matrix, traceback path, alignment result |
AsrPipelineTrace | transcribe.rs | 7-stage ASR post-processing intermediates |
FaTimelineTrace | fa.rs | Group boundaries, pre/post timings, violations |
RetokenizationTrace | morphosyntax.rs | Word↔token mapping per utterance |
Frontend
Visualizations
| Visualization | Route (static) | Route (live) | Status |
|---|---|---|---|
| DP Alignment Explorer | /dashboard/visualizations/dp-alignment | /dashboard/jobs/:id/traces/dp-alignment | Complete |
| Retokenization Mapper | /dashboard/visualizations/retokenize | /dashboard/jobs/:id/traces/retokenize | Static complete |
| ASR Pipeline Waterfall | /dashboard/visualizations/asr-pipeline | , | Planned |
| FA Timeline | /dashboard/visualizations/fa-timeline | , | Planned |
TypeScript Engine Ports
Static mode uses TypeScript ports of the Rust algorithms located in
frontend/src/engines/:
| Engine file | Rust source | What it ports |
|---|---|---|
dpAlignment.ts | crates/batchalign-transform/src/dp_align/ | align_small with step-by-step emission |
retokenize.ts | crates/batchalign-transform/src/retokenize.rs | Word↔token mapping |
These are faithful ports, same algorithm, same cost model, same edge cases, not approximations.
Dual-Mode Pattern
Each visualization page accepts a route parameter /:id for live mode. When
present, it fetches traces from the server via useTraceQuery(id). When
absent, it uses local state and the TS engine for static mode.
function DPAlignmentPage() {
const { id } = useParams();
const { data: traces } = useTraceQuery(id); // live mode
const dpResult = useMemo(() => {
if (id) {
// Convert server trace to visualization format
return traceToResult(traces.dp_alignments[selectedIdx]);
}
// Static mode: run TS engine locally
return alignWithSteps(payload, reference, matchMode);
}, [id, traces, payload, reference, matchMode]);
// Same visualization components for both modes
return <CostGrid ... />;
}
Shared Components
Reusable visualization components in frontend/src/components/visualizations/:
| Component | Purpose |
|---|---|
CostGrid | SVG grid for DP cost matrix with fill/traceback animation |
StepControls | Play/pause/step/skip controls for stepping through algorithm |
ModeToggle | Static ↔ Live mode indicator |
SpanRuler | Horizontal span bar for retokenization mapping |
Trace Collection Points
| Orchestrator | What to capture | Where in code |
|---|---|---|
fa.rs | Group boundaries, pre/post timings, violations | After parse_fa_response() and apply_fa_results() |
morphosyntax.rs | Retokenization mappings per utterance | Return value of inject_results() |
transcribe.rs | ASR pipeline intermediate states | Wrap process_raw_asr() stages, not yet wired |
dp_align.rs | Cost matrix + traceback | Optional trace output parameter |
What ships today
- Visualization routes, landing page, shared components.
- Retokenization engine: TypeScript port + static-mode page.
- DP alignment engine: TypeScript port with step emission;
CostGridvisualization with fill / traceback animation; live mode viauseTraceQueryhook against the server’s/jobs/{id}/tracesendpoint. - Structured results (
FaResult,MorphosyntaxResult) carry intermediate data through dispatch. - FA trace collection and storage in the dispatch layer.
Not yet implemented
- ASR pipeline waterfall (port 7 ASR post-processing stages to
TypeScript;
DiffViewcomponent for stage-by-stage transforms; trace collection intranscribe.rs). - FA timeline (DAW-style SVG timeline with pan / zoom; FA grouping and timing-injection animation; post-processing before / after comparison).
This page last changed: 2026-08-14 (commit dd96e330). The whole book last changed: 2026-09-16 (commit 34d249d8).
Processing Provenance System
Status: Current Last updated: 2026-09-16 10:19 EDT
Overview
The provenance system injects structured @Comment headers into CHAT
files recording what batchalign3 did, when, and with what engines. This
enables reproducibility, auditing, and UI display of processing history.
Architecture
flowchart LR
subgraph "Pipeline (Rust)"
A["Parse CHAT\n(parse_lenient)"] --> B["Process\n(infer/inject)"]
B --> C["inject_provenance()\n(provenance.rs)"]
C --> D["Serialize\n(to_chat_string)"]
end
subgraph "Worker"
W["capability report\n(engine per task)"]
R["per-item results\n(model or engine on each item)"]
end
W -->|"admitted once in the pool;\nonly FaCacheNamespace is read"| C
R -->|"identities on the applied results"| C
Source: crates/batchalign/src/provenance.rs
The module provides:
ProvenanceComment: a typed stamp: aReleasedCommandand fields keyed by the closedStampFieldenum, each holding aStampFieldValueinject_provenance(&mut ChatFile, &ProvenanceComment): AST-level injection that adds/replaces@Commentheadersinject_provenance_into_text(&str, &ProvenanceComment) -> Result<String, ParseErrors>: strictly parses serialized output before adding a comment; parser recovery errors return diagnostics instead of a rewritten document- Per-command builders:
morphotag_provenance(),align_provenance(),transcribe_provenance()andresult_named_provenance()(one builder for translate and coref, chosen byResultNamedCommand) cannot fail, because every value they write is already stamp-safe text. Two builders can fail, each for its own reason:utseg_provenance()admits a boundary model’s id and revision, which come from worker evidence, when it builds the stamp (InvalidStampSafeText), andincremental_morphotag_provenance()reads the--beforedocument’s stamp back (IncrementalMorphotagStampError, see Engine identity sources) extract_provenance(&str) -> Result<Vec<ProvenanceEntry>, UnparseableStamp>: reads stamps back for the job results API, which returns each file’s outcome as aFileProvenancestateis_provenance_only_difference(old, new, command): decides whether a re-run of acommandjob would change nothing meaningful, so the writer (write_chat_output_artifact_with_provenance_gateinrecipe_runner/runtime.rs) can skip the write
Comment Format
[fc-ba3 <command> | key=val ; key=val | ISO-8601-timestamp]
The [fc-ba3 opening is the machine-parseable discriminator. The
bracketed format is visually distinct from user-authored comments and
greppable with grep -E '\[(fc-)?ba3 '. A stamp with no fields is
[fc-ba3 <command> | <timestamp>].
Grammar owner: StampCodec
One codec, StampCodec, writes the grammar for ProvenanceComment::format and
reads it back for extract_provenance and the no-op write gate, from shared
separator constants: | between sections, ; between fields, = between
key and value, and ] to close.
- The command is a
ReleasedCommand, so no caller can stamp a command that does not exist. - Keys are the closed
StampFieldenum (asr,asr_model,diarize,engine,fa,incremental,lang,retokenize,utr,wor). ItsOrdcompares the key as written, so a stamp’s fields come out in alphabetical key order whatever order the variants are declared in. - A value is a
StampFieldValue, a wrapper overStampSafeText(crates/batchalign-types/src/domain.rs), the one type for text that cannot change a stamp’s structure. It refuses blank text, surrounding whitespace (exactly the UnicodeWhite_Spacecharacters,StampSafeText::WHITESPACE), and|,;,], newline and carriage return (StampSafeText::STAMP_STRUCTURE); the error isInvalidStampSafeText.ReportedEngineNamewraps the same type, so a reported engine name becomes a field value with no second check. StampSafeTexthas three routes in, and each keeps the invariant:TryFrom(also used by deserialization) checks runtime text;const fn from_staticchecks a literal at compile time (callers write it insideconst { }); andjoin(first, rest, StampJoiner)composes values that are already safe with a safe joiner (Concat,Plusfor+,Colonfor:,Atfor@). Every conversion intoStampFieldValueis therefore total: each source is a reported engine name, a language code, a literal or a join.- Text that could be unsafe is admitted where it enters, not when the stamp is
built. A FunAudio checkpoint selected through
funaudio_modelis admitted byAsrBackend::admit_checkpoint(transcribe/types.rs) at submission (JobSubmission::validate, so the job is refused before it is queued) and again when the transcribe plan is admitted (TranscribeAsrPlanError::InvalidCheckpoint { key, reason }). Such a checkpoint used to fail the job only when the stamp was built, after ASR had run. That admission still stands, but it is no longer whatasr_model=renders: the models a run actually loaded are, and they are stamp-safe by construction because every id and revision was admitted asStampSafeTextwhen the plan pinned it. - The JSON Schemas of
StampSafeTextandReportedEngineNamecarry apatterngenerated from the same two character lists (StampSafeText::json_schema_pattern). The Python producer,reported_engine_nameinbatchalign/worker/_types.py, uses an explicitWhite_Spaceset rather than Python’s own idea of whitespace. Rust’sstamp_safe_text_testsandbatchalign/tests/test_stamp_safe_text_conformance.pyboth readtests/fixtures/stamp_safe_text_cases.json, and the Python test also checks the generated schema pattern. - A comment that opens as one of our stamps but does not parse is an
UnparseableStampnaming the comment and itsStampDefect(no closing], missing command or timestamp, a field that is notkey=value, a repeated key).extract_provenancereturns it instead of silently leaving the stamp out. The job results endpoints (GET /jobs/{id}/resultsandGET /jobs/{id}/results/{filename}) report each file’s provenance as a typedFileProvenance:parsedwith its entries,unparseablewith the reason, ornot_readfor non-CHAT output and failed files. An unparseable stamp is that one file’s state; the rest of the job’s results are still served.
Recognizing our own comments: two steps, four answers
One recognizer answers “did this build write this comment?”, in the private
recognize module beside the codec, and it answers in two steps because its
readers need different amounts of it.
| Step | Function | Answers |
|---|---|---|
| One | RecognizedComment::classify(body) | Whether the comment is ours and, for a stamp, WHICH command wrote it, from the opening and the command alone |
| Two | NamedStamp::parse() | The fields and the timestamp of a stamp whose command step one has already read |
Step one has four answers, not two:
| Answer | Meaning |
|---|---|
Stamp(NamedStamp) | Ours, recording the command the stamp names |
Unnamed(UnnamedStamp) | Ours, but too damaged to say which command wrote it: nothing precedes the first | that ends a command |
UncheckedAsrWarning { engine } | Our unchecked-ASR warning, naming that engine |
Foreign | Written by something else |
The split is what keeps the no-op write gate safe. The gate must know WHOSE
stamp it is looking at before a damaged one means anything, because a damaged
stamp belonging to a command the job does not run is not the gate’s business. A
recognizer that could only answer “parsed” or “did not parse” would collapse
“damaged” together with “not ours”, and the gate would rewrite files it should
leave alone. So extract_provenance and the offline replays take both steps,
because they report a whole entry, while the gate, the replacement predicate
and the incremental morphotag reader take step one and make their own
comparison.
A command must be followed by the exact separator. Stray text after it, as in
[fc-ba3 align |x junk | <timestamp>], is reported as a defect; the codec’s own
section split used to swallow that text into the command and report a stamp
whose command was align |x junk.
A stamp’s command therefore lives in exactly one place: NamedStamp owns it and
the parsed body carries only fields and a timestamp. The types are minted only
inside that module, so no caller can pair one stamp’s command with another
stamp’s body.
Stamp names: written and recognized
Builds before 2026-09-15 wrote the same grammar under the name ba3
([ba3 <command> | ...]), and a file keeps whichever name wrote it. The
private closed set StampName owns both names:
| Operation | Names |
|---|---|
Writing (ProvenanceComment::format) | fc-ba3 only (StampName::WRITTEN) |
Replacement on re-run (inject_provenance) | fc-ba3 and ba3 |
No-op write detection (is_provenance_only_difference) | fc-ba3 and ba3 |
Extraction (extract_provenance, job-detail API) | fc-ba3 and ba3 |
A legacy writer has no constructor, so no code path can produce a new ba3
stamp. ProvenanceEntry does not record which name wrote a stamp, so the API
shape is unchanged. The literal names are written once, as macros shared by
the stamp openings and the unchecked-ASR warning.
Recognition matches our openings and nothing else. Another Batchalign
distribution writes lines such as batchalign3 <sha> | <stage>: <engine> | <timestamp> and a bare Unchecked output of ASR model; neither is treated as
ours, so a re-run never deletes them.
What counts as a meaningful difference
is_provenance_only_difference(old, new, command) lets the writer skip a
re-run that changed nothing that matters. A command job can write more
stamps than its own, because its recipe composes stages and each stamping
stage writes its own command’s stamp. commands_stamped_by(command)
(command_model/catalog.rs) derives that set from the catalog, with no
hand-written list: the command itself, plus RecipeStageId::provenance()
(recipe_runner/recipe.rs) for every stage of its recipe, following a stage
that runs another command’s recipe into that recipe.
| Stage | Stamps it contributes |
|---|---|
BuildChat | transcribe |
UtteranceSegmentation | utseg |
Morphosyntax | morphotag |
ForcedAlignment | align |
RunTranscribeRecipe | every stamp of the transcribe recipe |
RunCompareRecipe | every stamp of the compare recipe |
| every other stage | none |
So transcribe sets aside transcribe, utseg and morphotag stamps,
benchmark sets aside benchmark, transcribe, utseg, morphotag and
compare, and align sets aside only align.
The gate walks both texts line by line in lockstep, sets aside those stamps and our unchecked-ASR warnings on each side (so a stamp that only moved position is not a difference by itself), and compares what it set aside once both walks finish:
- Set-aside stamps are compared per command, by their fields. Two stamps
count as the same only when they differ in the stamp name (
fc-ba3orba3) and the timestamp. Any other field difference, an engine, a language or a flag, is meaningful, and the file is written. - A set-aside stamp present on one side only is meaningful.
- Our warnings are compared by the ASR engine they name. Their shape (current or legacy) and the build identity they name are ignored.
- A set-aside stamp that does not parse is meaningful, so the write goes through and replaces it with one that does.
- A damaged stamp the job does NOT write is not meaningful by itself. A stamp for another command, and one too damaged to name any command, are compared as ordinary content: identical on both sides they provoke no rewrite, and a rewrite would not repair them anyway, because replacement matches a stamp by its command.
- Anything else that differs (the stamp of a command the job does not compose,
%mor,%gra,%wor, main tiers) is meaningful, as before.
Setting aside only the job’s own stamp made every transcribe re-run on a new
build a write, because the utseg and morphotag stamps it also writes carry
new timestamps. A transcribe re-run over identical content now leaves the
file untouched.
Corpus consequence, stated plainly: re-running a command over files whose
stamps spell a field differently from what this build writes rewrites those
files, even when their tiers are unchanged. Morphotag is the common case:
files stamped with an older engine= spelling (engine=stanza-1.11.1, or
engine=stanza-1.11.1:eng) are rewritten with
engine=stanza-<version>:<lang>:<pipeline> on the next morphotag run. A file
whose stamp differs only in its name and timestamp is still not rewritten, so
the ba3 to fc-ba3 rename alone never churns a corpus.
AST Manipulation (not string hacking)
Provenance is injected into the CHAT AST, not the serialized text:
- Any existing
@Commentholding a stamp for the same command, under either name, is removed fromChatFile.lines - A new
Line::Header { Header::Comment { BulletContent } }is inserted after the last constant participant header (@ID,@Birth of,@Birthplace of, or@L1 of) - The file is then serialized normally via
to_chat_string()
This ensures provenance comments participate in proper CHAT serialization (bullet handling, encoding).
A stamp is never wrapped, however long it is. inject_provenance builds the
comment’s content with BulletContent::from_text as one text segment, the
Chatter serializer writes a @Comment header’s bullet content on one line and
breaks a line only at an explicit continuation segment, and stamp-safe text
cannot contain a line break. So a stamp of any length stays one line and reads
back unchanged. a_long_stamp_is_written_on_one_line_and_reads_back proves it
through injection, re-parse, a second injection and extract_provenance.
Re-export chain
The provenance module needs Header, BulletContent, and Span from
talkbank-model. Since batchalign doesn’t depend on
talkbank-model directly, these types are re-exported through
batchalign:
talkbank-model::header::Header → batchalign::Header
talkbank-model::model::BulletContent → batchalign::BulletContent
talkbank-model::Span → batchalign::Span
Injection Points
Each pipeline injects provenance right before serialization:
| Command | File | Injection site |
|---|---|---|
| morphotag | pipeline/morphosyntax/states.rs | Analysis::<Applied>::postcheck(): before the post-validation gate |
morphotag (incremental, --before) | morphosyntax/mod.rs | process_morphosyntax_incremental(): before its gate, only on the path where utterances were reanalyzed |
| utseg | pipeline/text_infer.rs | run_text_batch_pipeline() per file, and run_text_pipeline() for the per-file pipeline transcribe uses: after apply(), before the gate |
| translate | pipeline/text_infer.rs | Same as utseg (shared generic pipeline) |
| coref | coref.rs | run_coref_batch_impl(): per file, after the annotations are applied and before that file’s gate |
| align | runner/dispatch/fa_pipeline.rs | AlignAudioTask::finalize_success(): PostValidated::with_provenance_injected() on the gated proof |
| transcribe | pipeline/transcribe.rs | serialization stage: injects structured provenance and the unchecked-ASR warning through the production AST helpers |
The shared text pipeline takes its stamp from a TextProvenance<Response>
function of the applied responses, so a text command can only stamp identities
its responses named. Both halves of that pipeline take the same hook, so a
file processed in a cross-file batch records what produced it exactly as a
per-file run does.
A stamp source returns a TextStamp: either the comment to write, or
NotStamped carrying a NoStampReason. There is no bare “no stamp” any more,
so a run that applied nothing states why, and the pipelines match it
exhaustively. Two reasons exist: NothingApplied (every item was blank,
unresolved, or produced nothing that was applied) and SourceNotNamed (utseg
applied boundaries whose source the worker did not name). A file whose items
all FAILED is a different outcome again: it is reported as a per-item failure
and never written, so there is nothing to stamp.
The decision is recorded, not only logged. It travels with the file’s result to
the writer, which stores it on the file’s own status as a FileStampOutcome
(stamped, not_stamped with the reason, or unrecorded), and the job detail
serves it beside that file’s status. unrecorded is the honest answer for a
command that writes no per-file stamp and for a status rebuilt from the job
database after a restart, because the decision is not persisted.
Engine identity sources
There is no pipeline-wide engine version. PipelineServices carries only the
worker pool and the cache; it used to carry one engine version for the whole
pipeline, which inside transcribe belonged to the ASR engine and was stamped
onto the morphotag and utseg stages too (engine=stanza-rev). Files written
then keep that text. Each stage now names its own engine:
| Command | Field | Source |
|---|---|---|
| morphotag | engine=, ud_repairs= | The model identity every analyzed morphosyntax item carries (MorphosyntaxModelIdentityV2: Stanza version, the language whose pipeline ran, and the pipeline variant), collected over the responses the file applied, secondary-language re-analysis included (AppliedAnalyses). Rendered stanza-<version>:<lang>:<pipeline>, where the pipeline is its wire name (MorphosyntaxPipelineV2::wire_name): standard, mandarin_retokenize or cantonese_pycantonese_pos. Distinct identities are joined with + in text (byte) order by EngineNames, for example stanza-1.11.1:cmn:mandarin_retokenize+stanza-1.11.1:cmn:standard. Absent when no model analyzed anything: a wordless item (no_words) and an unsupported-language placeholder carry no identity. ud_repairs= counts every Universal Dependencies relation the workers rewrote inside those responses (UdRelationRepairV2, carried per analyzed item and collected by AppliedAnalyses beside the models). Stanza does not guarantee UD-conformant labels, so a relation can arrive as padding (<PAD>), in the wrong case, under a known non-UD spelling (iob for iobj), or as no relation at all; each rewrite is reported by the worker that made it, and the count is what the file records. The field is ABSENT when nothing was repaired: absence is how the grammar states “none”, so ud_repairs=0 has no representation. A file stamped by a build older than the field states nothing about repairs and is indistinguishable from one that repaired nothing. |
| morphotag (incremental) | engine=, incremental=true | Built by incremental_morphotag_provenance(&before_file, lang, &engines, retokenize) in morphosyntax/mod.rs. When no model ran (nothing needed reanalysis, or every reanalyzed utterance was wordless or in an unsupported language), no stamp is written and the document keeps the stamp it already carries. Otherwise the output holds tiers from both runs, so engine= names the models the --before document’s morphotag stamps name (read back through StampCodec and split on +) together with the models that ran now, distinct and in text order. The job fails with IncrementalMorphotagStampError when the prior stamp does not parse (UnparseablePriorStamp), a prior engine name is not stamp-safe text (UnwritablePriorEngine), or a model that ran now has a name containing + (SeparatorInModelName), because such a list could not be read back. ud_repairs= is likewise the sum of the --before document’s count and this run’s, because the output holds tiers from both runs; a prior stamp whose ud_repairs= is not a count fails the job with UnreadablePriorRepairCount rather than being read as none, which would undercount the written file. |
| utseg | engine= | The inference sources behind the predictions it applied (UtsegEngineIdentities): a boundary model as model_id@revision, or stanza-constituency; distinct names joined with + in text order (EngineNames), for example stanza-constituency+talkbank/CHATUtterance-en@764ec3f762c2e24df2def8df98b5fe34940085c6. A boundary model is ALWAYS written with its revision: it is loaded from a pinned snapshot whose commit is read off the directory on disk, so the revision is a required part of its identity and the id-only form has no representation to render. A source that names nothing contributes no name, and a file with no named source gets no stamp and a recorded SourceNotNamed: there is no unobserved-worker or @unrecorded-revision placeholder. |
| translate | engine= | The engine each translated item named (AdmittedTranslation), distinct names joined with + in text order. No stamp when nothing was translated. |
| coref | engine= | The engine the resolved result named (ResolvedCoref). No stamp when nothing was resolved. |
| align | fa=, utr= | fa= is FaCacheNamespace, the FA engine the worker reported (also the FA cache namespace). utr= names the timing-recovery engine when a recovery pass ran (UtrContribution). |
| transcribe | asr=, asr_model= | Direct backend enum matching, plus asr_model= naming every model the run LOADED, each at the revision it was observed at (AsrModelIdentityV2::stamp_value, carried on the ASR response). The primary model is written <id>@<revision>, and each auxiliary is appended as +<role>:<id>@<revision>, so a Qwen run records its forced aligner beside its recognizer and a Paraformer run records its voice-activity and punctuation models. Where a hub exposed no revision the id is written alone. Every engine records one now, including a default SenseVoice run, which previously recorded nothing because it selected no checkpoint through an override key. A replayed legacy projection reports no models and so still records no asr_model. |
Capability reports: task support plus the FA identity
A worker’s capability response carries infer_tasks and engine_versions,
keyed by task: one entry per advertised task. Forced alignment’s entry is a
ReportedEngineName, or null until an FA model has loaded; every other
task’s entry is null, because those stages name their engines on the results
they return. The worker never sends a guessed name or "unknown" (a test-echo
worker reports "test-echo" for FA and null for the rest). A blank or
separator-bearing name, or a key that is not a task, fails deserialization.
The pool admits a report exactly once, in WorkerPool::record_capabilities
(WorkerEngineReports::admit in engine_reports.rs), refusing an advertised
task with no entry (MissingEngineReport), an entry for a task nobody
advertised (UnadvertisedEngineReport), or a name for any task other than
forced alignment (EngineNamedForNonFaTask), and stores the outcome per
worker key.
Only forced alignment reads its identity from a report, because FA cache rows
are namespaced by it before any worker runs. Supporting FA and naming the FA
engine are separate facts: a lazily loading worker supports FA before it has
loaded a model, and names the engine only after. So align is advertised and
accepted whenever FA is supported (capability::command_supported, which
reads only the command’s primary infer task). At dispatch
(runner/routing.rs), WorkerPool::ensure_command_capabilities loads the
command’s task on the selected worker and returns LoadedCapabilities: the
task it loaded and the report taken after that load. Routing checks
command_supported again against that report, and the forced-alignment
dispatch arm then calls FaCacheNamespace::from_loaded, the only route to the
name. It refuses a report taken after a different task loaded
(LoadedAnotherTask), an engine still unnamed after the load
(UnreportedAfterLoad) and a worker that does not support FA
(NotSupported), so an align job fails in those cases instead of writing a
placeholder. FaCacheNamespace is a plain newtype over ReportedEngineName,
and the dispatch arm that reads it is the only place that says a command needs
an identity before dispatch; no catalog field declares it. Translate, coref
and morphotag do not read the report at all, so none of them is refused for an
unnamed engine.
Example report from a worker that has loaded an FA model:
{
"infer_tasks": ["morphosyntax", "fa", "asr", "translate"],
"engine_versions": {
"morphosyntax": null,
"fa": "whisper-fa-large-v2",
"asr": null,
"translate": null
}
}
The batch text path (run_text_batch_pipeline, which standalone utseg,
translate and coref jobs use) stamps each file from the results that file
applied. It wrote no stamp at all before 2026-09-15, so a corpus processed
through a standalone utseg, translate or coref job recorded nothing about
what produced it; those files are stamped on the next run over them.
Replacement Semantics
When the same command is run again on the same file:
inject_provenance()scans allLine::Headerentries- Any
Header::Commentholding a stamp for that command, under either stamp name, is removed - The new comment is inserted after the last constant participant header
This means re-running morphotag replaces the morphotag comment but preserves any align or transcribe comments. The processing history accumulates across different commands but doesn’t duplicate within one command.
Human-readable unchecked-ASR warning
Transcribe writes two comments for different audiences:
[fc-ba3 transcribe | ...]is machine-readable processing provenance; and- the warning below is a human-visible safety statement.
@Comment: fc-ba3 <build identity>, ASR engine rev. Unchecked output of ASR model, DO NOT USE.
inject_unchecked_warning() owns this production path. The typed
UncheckedAsrWarning renders the build identity (crate::build_hash(), the
one reader of BUILD_HASH from build.rs, never the semver, because freshness
is judged by build identity) and the AsrIdentity.
The parenthetical names the models that RAN, in the same text asr_model=
carries, for example
ASR engine qwen (Qwen/Qwen3-ASR-1.7B-hf@<commit>+aligner:Qwen/Qwen3-ForcedAligner-0.6B-hf@<commit>).
The shape is unchanged, <engine> (<models>), so anything reading these fields
as opaque text is unaffected.
Both lines come from ONE accessor, AsrIdentity::asr_model, which
Display delegates to rather than formatting a second time. That is the point:
a warning and a stamp describing the same run must not be able to disagree, and
two independent formatters is exactly how they would.
Where no run reported its models, which today means replaying legacy evidence,
the parenthetical is OMITTED and the warning reads ASR engine <engine>. It is
never filled in from the request. A warning that printed a requested checkpoint
as though it had been observed would be the same substitution the ASR bridge
refuses when a worker reports an identity that disagrees with its plan.
Re-transcribing replaces a previous warning of ours in either shape, as listed
in OUR_WARNING_SHAPES (built from the same literal macros the writer uses):
| Shape | Text |
|---|---|
| Current | fc-ba3 <build>, ASR engine <engine>. Unchecked output of ASR model, DO NOT USE. |
| Legacy | Batchalign <version>, ASR Engine <engine>. Unchecked output of ASR model., optionally ending , DO NOT USE. |
Each shape must open with its product token and name an engine, so a comment
that is exactly Unchecked output of ASR model is left in place. The
recognizer returns the engine text, which is what the no-op write gate
compares. The warning uses the same constant-header-aware AST insertion point
as structured provenance.
Regression coverage
Tests beside provenance.rs cover deterministic formatting, the codec reading
back what it writes, stamp recognition under both names, replacement of a
legacy stamp and of a current one, cross-command preservation, constant-header
ordering, extraction under both names, typed errors for each unparseable stamp
defect, and the no-op write gate: timestamp-only and name-only differences
suppressed; a changed engine, another command’s stamp, a tier change and an
unparseable stamp all written; our warning compared by engine; and every stamp
a transcribe job writes set aside. Two tests pin the recognizer itself:
classification_names_a_command_before_the_body_is_parsed pins step one’s four
answers, including a command named from a stamp whose body does not parse, and
provenance_only_diff_leaves_a_damaged_stamp_of_another_command_alone pins that
the gate rewrites a file over a damaged stamp of its own command but leaves a
damaged stamp of another command, and one naming no command, where they are.
Builder tests pin that morphotag names the models and pipeline variants it
applied, that translate and coref name the
engines on their results (and stamp nothing without one), that incremental
morphotag leaves the existing stamp when no model ran, names the prior and the
new models together, and refuses what it cannot read back, that a checkpoint
containing a stamp character is refused at plan admission, that align records
utr= only when recovery ran, and that a long stamp is written on one line and
reads back. stamp_safe_text_tests in batchalign-types pin the
StampSafeText refusals against the fixture shared with Python.
engine_reports.rs pins admission (reported, unreported, missing,
unadvertised, duplicated, and a name for any task but FA), the tagged JSON of a
refusal, that the FA namespace is the reported string byte for byte, and that
a report taken after another task loaded is refused; capability.rs pins the
lazy-daemon case: a report that supports FA without naming its engine still
advertises align, a post-load report that names it resolves the namespace,
and one still unnamed after the load refuses. Malformed main tiers and generated
morphosyntax tiers are refused at the serialized-output boundary. The
dispatcher preserves these diagnostics as ServerError::OutputParse, a system
failure rather than a successful file or a bad-input response. The ASR backend
matrix in transcribe/mod.rs proves that Rev, Whisper variants, Tencent,
Aliyun, Funaudio, and Qwen retain distinct provenance names. There is no
test-only comment implementation: tests exercise the production builder and
AST injection path. The batch text pipeline’s own tests pin that a file
processed in a cross-file batch carries the stamp of what it applied
(pipeline/text_infer.rs), which no batch file carried before 2026-09-15.
In the ML golden suite, snapshots keep each stamp with its
timestamp pinned, and BA2 parity comparisons drop stamp lines; both read stamps
through extract_provenance (see Testing).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Time Transparency: A Cross-Cutting UX Principle
Status: Current Last updated: 2026-05-19 21:03 EDT
Principle
Transparency about where batchalign3 spends its time is vital for UX: all model downloading or loading from disk must be logged and also prominently displayed in UI whether console, TUI, app, or web dashboard.
Any operation that takes more than a perceptible moment must be both logged (to the daemon and worker logs with structured metadata) and prominently displayed in every UI surface batchalign3 exposes (CLI, TUI, Tauri desktop app, web dashboard). Silent waits are UX bugs, not acceptable defaults.
The reader of this page should leave with one rule in mind: if a worker spends more than ~1 second on something the user could mistake for “BA3 is stuck”, that something must surface to the UI. Examples:
- Downloading any model resource (catalog, language pack, HuggingFace weights, torchaudio bundle, NeMo checkpoint).
- Loading a model from disk into RAM or GPU.
- GPU JIT compile.
- Audio decoding for long files.
- External API calls (Rev.AI ASR, Anthropic, Aliyun, etc.), at minimum a
"Calling Rev.AI…"event before the call returns. - Sleep, backoff, retry, make the wait visible, not silent.
Why this matters
The principle exists because of a specific incident shape that has recurred more than once:
- The worker enters a deterministic failure that masquerades as a wait (catalog missing, model not loaded, network unreachable).
- The orchestrator treats the resulting worker exit as transient and retries.
- Each retry dumps a multi-line stack trace to a log file with no user-visible signal.
- The user sees an opaque error after several retries (or, worse, a silent multi-hour hang) and the on-call engineer finds gigabytes of log spam diagnosing it.
The canonical anti-pattern instance: an operator host produced hundreds
of GB of server.log over 24 hours because Stanza’s resource catalog
was missing, the worker raised UnsupportedLanguageError, and the
orchestrator retried indefinitely. The user-visible message was the
unhelpful "capability table is unavailable". Fix: catalog
auto-bootstrap (now emits a download event) plus this principle to
prevent the same shape from recurring with a different model family.
Mechanism
Wire protocol: progress_v2 events
The worker emits user-facing events on stdout as JSON lines, distinct from its final-result IPC payload:
{"op": "progress_v2", "event": {"request_id": "...", "completed": 0, "total": 0, "stage": "downloading_stanza_catalog"}}
Source: batchalign/worker/_protocol.py:write_progress_event.
stage is a short machine-readable identifier (e.g.,
downloading_stanza_catalog, loading_whisper_large,
calling_rev_ai_asr). The Rust runner uses it both as a structured log
key and as a fallback display label. The user-facing wording lives in the
emitting site (see “Wording” below).
Python side: helpers in _progress.py
emit_download_event(stage, user_message, request_id=None, size_bytes_estimate=None)
emit_hf_download_if_missing(model_id, kind, request_id=None)
emit_download_event is the generic helper for non-HF downloads (Stanza
catalog, Stanza language packs, torchaudio bundles, NeMo).
emit_hf_download_if_missing probes the HuggingFace cache via
huggingface_hub.try_to_load_from_cache and emits only when the model
will actually download. Wrap every from_pretrained() call.
Rust side: progress forwarding to the file-status sink
The runner spawns a progress_forwarder per request (see
crates/batchalign/src/runner/dispatch/audio_task.rs:spawn_progress_forwarder).
It reads progress_v2 lines from worker stdout and dispatches them to a
FileStatusEventSink (see
crates/batchalign/src/runner/util/file_status/event_sink.rs). The sink
fans out to:
- CLI / console: rendered as a per-file status line by the runner’s console reporter.
- TUI: same line, displayed in the dashboard component.
- Web dashboard at
:8001/dashboard/jobs/<id>: the file status field is included in the JSON the dashboard polls. - Tauri desktop app: consumes the same job/file status events through the dashboard JSON API.
Adding a UI surface? Subscribe to FileStatusEventSink events and render
the stage (and any user-facing wording the worker emits). Do not
duplicate event-shape logic in each UI; the sink is the single source.
flowchart LR
worker["Worker (Python)<br/>_progress.emit_*"] -->|"progress_v2 JSON line"| runner["Runner (Rust)<br/>spawn_progress_forwarder"]
runner -->|"FileStatusEvent"| sink["FileStatusEventSink<br/>(util/file_status/)"]
sink --> cli["CLI / console reporter"]
sink --> tui["TUI dashboard component"]
sink --> web["Web dashboard JSON API<br/>:8001/dashboard/jobs"]
sink --> tauri["Tauri desktop app"]
Operations that must be surfaced
This list is non-exhaustive but covers every category the codebase currently has. Any new long operation must be added to it.
Model downloads
Every family. See the developer-facing model downloads chapter for the inventory and the helper-function shape.
Model loads from disk to RAM or GPU
A multi-GB model load can take 30+ seconds even from a warm cache (deserialization + GPU upload). Emit before the load, especially for Whisper-large-class models.
Audio decoding for long files
A multi-hour audio file’s first decode can take a minute or more. The
worker should emit "Decoding audio: <filename>…" before the decode call.
External API calls
Rev.AI ASR, Anthropic, Aliyun, OpenAI, anywhere the worker makes a
synchronous network call that could legitimately take more than a couple
seconds. At minimum: emit "Calling <provider> for <task>…" before the
call returns. For long-running providers like Rev.AI streaming, emit
periodic heartbeats so the user sees the call is still alive.
Sleeps, backoffs, retries
Backoff loops that wait several seconds between attempts must surface
each wait. Without an event, a time.sleep(30) looks identical to “BA3
is stuck” from the outside. The orchestrator’s retry layer
(crates/batchalign/src/runner/util/error_classification.rs and
crates/batchalign/src/infer_retry.rs) is the right place to hook this
in for transport-level retries.
Wording: what the user reads
The user_message field, the text rendered to the user, must convey
four things for any download or load:
- What is happening (“Downloading openai/whisper-large-v3 for ASR”).
- How big (approximate;
~3 GBis fine). - That it’s a one-time cost (“future runs will use the local cache” or “future runs will be instant”).
- That BA3 is not stuck.
Standardized templates live in _progress.py. When adding a family, copy
an existing template; do not invent new wording from scratch, UI users
get used to the shape.
For loads (no download), the shape is "Loading <model> for <task>…" with
an implicit “this should take a few seconds” because if it took longer it
would also need a progress signal mid-load.
What NOT to do
- Do not swallow exceptions from long-blocking operations and return
a default value. The Stanza catalog incident was exactly this: the
bootstrap path swallowed
ResourcesFileNotFoundErrorand returnedNone, leaving the gate above to surface a misleading “language not supported” error. If something is recoverable (download it), do that and emit. If it isn’t, raise a typed error. - Do not rely on the upstream library’s stderr progress as the user-
visible signal. HuggingFace’s
tqdmprints to terminal stderr, which reaches the CLI but not the TUI, web dashboard, or desktop app. Theprogress_v2channel is the only signal that reaches every UI. - Do not emit progress events from the Rust runner’s own slow operations without also surfacing them. If the runner is doing something slow (warming caches, validating fixtures), emit on the same channel that worker progress uses, into the same sink.
- Do not suppress events on cached / fast-path runs. Emitting only
when a download will happen is the right call (BA3’s
emit_hf_download_if_missingdoes this); but a load that genuinely takes a few seconds, even from cache, still warrants a “Loading X…” event. Users prefer one always-shown line to a guessing game about whether a wait is “real”.
Adding a new long operation: contributor checklist
- Identify the slow site. Anywhere the worker (or the runner) blocks for > 1 s.
- Choose a stage identifier. Short, snake-case, unambiguous. Examples:
downloading_stanza_catalog,loading_whisper_large,calling_rev_ai_asr,decoding_audio. - Choose user wording. Use the
_progress.pytemplates. Convey the four things above. Be specific. - Pair start with completion when the operation is recoverable, e.g., a download has both a “downloading…” event and a “ready” event.
- Verify each UI surface renders the event. CLI: run the command and
see the line. TUI: run with
--tuiand see the dashboard label. Web: poll the dashboard JSON. Tauri: check the desktop app’s status panel. - Add a regression test. A unit test that mocks the slow path and
asserts at least one
progress_v2event was emitted with the right stage.
Related references
- User-facing model-downloads chapter.
- Developer-facing model-downloads chapter.
- Source:
batchalign/worker/_progress.py,batchalign/worker/_protocol.py. - Rust forwarder:
crates/batchalign/src/runner/dispatch/audio_task.rs:spawn_progress_forwarder. - Sink:
crates/batchalign/src/runner/util/file_status/event_sink.rs.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Worker Failure Classification and Retry Architecture
Status: Current Last updated: 2026-09-06 08:27 EDT
This chapter is the canonical contributor reference for how a Python worker exception becomes, or does not become, an end-user error. It covers the wire protocol, the typed error taxonomy on both sides of the Rust/Python seam, the classifier that decides retry behavior, and the user-facing message router. Add a new bootstrap-class error? Adding a worker exception type? Adjusting retry policy? Start here.
The architecture documented in this chapter was rewritten on 2026-05-06 in response to two coupled defects:
- The on-demand model-download contract. A fresh-install worker must
automatically download missing models without any manual seeding. The
user-visible failure for a missing-model condition must be an
actionable network/disk/auth error, never an opaque “capability table
is unavailable”. See
book/src/batchalign/user-guide/model-downloads.md. - Bootstrap-class retry classification. A worker that fails
deterministically during bootstrap (missing model, catalog download
failure, package import error) must NOT be retried by the
orchestrator. Pre-fix, retries amplified one Stanza-catalog miss
into hundreds of GB of
server.logspam over a single day on a fleet host.
Both fixes are different facets of the same architectural shape, what the worker can fail at, how it conveys that failure across the IPC seam, and what the orchestrator does with it.
The full pipeline at a glance
flowchart TD
py["Python worker dispatch<br/>(_protocol.py:_serve_stdio)"]
classifyExc{"_classify_dispatch_<br/>exception(exc)"}
emit_b["_write_error(msg, kind='bootstrap')"]
emit_r["_write_error(msg, kind='runtime')"]
exit_b["Worker exits cleanly<br/>(pool spawns replacement)"]
keepalive["Worker stays alive<br/>(serves next request)"]
wire["Wire JSON line:<br/>{op:'error', error, kind}"]
rust["Rust IPC reader<br/>(handle/protocol.rs)"]
kind{"WorkerErrorKind"}
we_b["WorkerError::Bootstrap(msg)"]
we_r["WorkerError::WorkerResponse(msg)"]
classifyRust["classify_worker_error()<br/>(error_classification.rs)"]
cat_b["FailureCategory::WorkerBootstrap<br/>(non-retryable)"]
cat_r["FailureCategory::ProviderTransient<br/>(retryable, 3 attempts)"]
retry{"is_retryable_worker_<br/>failure(category)?"}
user_b["User sees verbatim worker error<br/>(network failure, disk full, …)"]
user_r_ok["Retry succeeds → user sees result"]
user_r_fail["3 retries fail → 'engine returned a temporary error'"]
py --> classifyExc
classifyExc -->|"typed bootstrap exception"| emit_b
classifyExc -->|"any other exception"| emit_r
emit_b --> wire
emit_r --> wire
emit_b --> exit_b
emit_r --> keepalive
wire --> rust
rust --> kind
kind -->|"bootstrap"| we_b
kind -->|"runtime (default)"| we_r
we_b --> classifyRust
we_r --> classifyRust
classifyRust -->|"Bootstrap"| cat_b
classifyRust -->|"WorkerResponse"| cat_r
cat_b --> retry
cat_r --> retry
retry -->|"no"| user_b
retry -->|"yes"| user_r_ok
retry -.->|"retries exhausted"| user_r_fail
The key invariants the diagram encodes:
- Bootstrap-class exceptions take a separate path on both sides of the
seam. Python emits with
kind=bootstrap; Rust decodes intoWorkerError::Bootstrap; classifier returnsFailureCategory::WorkerBootstrap;is_retryable_worker_failurereturnsfalse. - The runtime path preserves all pre-2026-05-06 retry semantics. Any
exception that isn’t a typed bootstrap error stays in the
WorkerResponse → ProviderTransient → retry up to 3×loop. - The user-facing wording is verbatim for bootstrap errors. The worker’s typed error message (e.g., “Failed to download Stanza catalog: connection refused”) reaches the user with light framing, not a generic “internal error” wrapper. Bootstrap errors are user-actionable.
Shared stdio worker generations
A successful spawn does not prove permanent liveness. The shared GPU pool
previously stored each key’s first worker in a OnceCell; after that process
died, every later request received the same dead handle. In the September 6
MICASE run, one FA worker failure was followed by failures for the rest of that
file and all 743 groups of another file.
GpuWorkerSlot now retains one stable slot across replaceable worker
generations. Its occupancy is Empty, Transitioning or Worker; status reporting
distinguishes a retained but unavailable worker from one accepting requests.
SharedGpuWorker::check_available supplies the same live observation to the
slot and the dispatch boundary. External process death remains a fallible
runtime event, not a permanent guarantee attached to a successful constructor.
Warm handoffs only inspect occupancy. A separate per-key transition permit
serializes retirement and replacement, without holding the map lock or blocking
unrelated warm keys. SlotTransition owns that permit: publishing the new worker
consumes the transition, while failure or cancellation restores Empty before
unlocking. The old process is retired through its lifecycle owner before the
replacement is initialized. The slot is never evicted while another caller
may still hold it. Pool shutdown retains responsibility for occupied slots;
an in-flight initializer observes pool cancellation and retires its result.
The integration regression kills an actual owned echo worker, then requires four concurrent follow-up dispatches to share one new PID and checks both processes are reaped. Run the existing worker boundary target:
cargo test -p batchalign --test gpu_concurrent_dispatch
This changes which process serves later requests; it does not retry the crashing input automatically, classify every process exit as OOM, or establish the validity of an overlong FA audio window. Those are separate decisions.
The wire protocol
Request envelope (Rust → Python)
Defined by WorkerRequest in
crates/batchalign/src/worker/handle/protocol.rs. Tagged-union JSON over
stdio (or TCP for shared-GPU daemons):
{"op": "infer", "request": {...}}
{"op": "batch_infer", "request": {...}}
{"op": "execute_v2", "request": {...}}
{"op": "ensure_task", "request": {"task": "morphosyntax", "engine_overrides": null}}
{"op": "health"}
{"op": "capabilities"}
{"op": "shutdown"}
Response envelope (Python → Rust)
Defined by WorkerResponse in the same file. Same tagged-union shape:
{"op": "infer", "response": {...}}
{"op": "batch_infer", "response": {...}}
{"op": "execute_v2", "response": {...}}
{"op": "progress_v2", "event": {...}} // intermediate, before final response
{"op": "ensure_task", "response": {...}}
{"op": "health", "response": {...}}
{"op": "capabilities", "response": {...}}
{"op": "shutdown"}
{"op": "error", "error": "<message>", "kind": "runtime" | "bootstrap"}
The error op is the focus of this chapter. The kind field was added
2026-05-06; legacy workers that don’t emit it default to runtime on
the Rust side, which preserves the pre-fix retry behavior exactly.
Why kind and not a separate bootstrap_error op?
Two reasons:
- One decode path, not two. Adding a sibling op (
{"op": "bootstrap_error", ...}) would double the number of match arms at every consumer ofWorkerResponse, plus add a serializer surface. A discriminator field is cheaper. - Backward compatibility. A new op breaks legacy workers; an optional field with a default does not. Rolling out a wire change to the fleet must not require lockstep daemon redeploys.
The Python side: _serve_stdio and exception handling
Source: batchalign/worker/_protocol.py.
Pre-2026-05-06 behavior (the bug)
def _serve_stdio() -> None:
for raw_line in sys.stdin:
# ... json decode ...
dispatch = dispatch_protocol_message(message) # raises propagate up
_write_json(dispatch.payload)
An uncaught exception from dispatch_protocol_message propagated through
the loop, the worker’s main module, and Python’s interpreter shutdown
machinery, ultimately killing the process with exit code 1 and dumping
the traceback to stderr.
The Rust orchestrator saw WorkerError::ProcessExited →
FailureCategory::WorkerCrash → retryable, and retried up to 3× with
the same configuration. For deterministic bootstrap failures, every
retry crashed identically, generating ~3 KB of traceback per attempt.
On a fleet host this loop ran for ~24 hours and produced hundreds of
GB of log spam before the daemon was restarted.
Post-2026-05-06 behavior
def _serve_stdio() -> None:
for raw_line in sys.stdin:
# ... json decode ...
try:
dispatch = dispatch_protocol_message(message)
except BaseException as exc:
kind = _classify_dispatch_exception(exc) # 'bootstrap' or 'runtime'
# Log the full traceback once for diagnostics …
_write_error(str(exc) or exc.__class__.__name__, kind=kind)
if kind == "bootstrap":
break # Worker exits cleanly; pool spawns replacement.
continue # Worker stays alive for the next request.
_write_json(dispatch.payload)
The catch is intentionally broad (BaseException, not Exception), we
want every exception to produce a structured error rather than a process
exit, including ones like KeyboardInterrupt that would otherwise leak
through. The price of BaseException is one rule for contributors:
never raise SystemExit from inside a dispatch handler (use the
shutdown op instead). The catch will swallow it.
_classify_dispatch_exception is the bootstrap-vs-runtime discriminator:
def _classify_dispatch_exception(exc: BaseException) -> str:
bootstrap_types = []
try:
from batchalign.worker._stanza_capabilities import StanzaCatalogDownloadError
bootstrap_types.append(StanzaCatalogDownloadError)
except ImportError:
pass
try:
from batchalign.worker._stanza_loading import UnsupportedLanguageError
bootstrap_types.append(UnsupportedLanguageError)
except ImportError:
pass
return "bootstrap" if isinstance(exc, tuple(bootstrap_types)) else "runtime"
The lazy imports are deliberate: a missing optional dependency must not
crash the classifier itself. If a future ML library introduces typed
bootstrap errors, append them to the list, the tuple(...) dispatch
handles the type fan-in cleanly.
Why does the worker exit on bootstrap errors?
A worker that hit a bootstrap failure is in a partially-initialized
state, model loaders may have allocated GPU memory, opened file
handles, or established network connections that the surviving
_state does not track. Continuing to serve requests after a bootstrap
failure invites silent data corruption (e.g., a request that needed
language eng running against a worker whose eng Pipeline never
finished loading).
The exit is safe because the orchestrator classifies bootstrap errors as non-retryable: the pool spawns a fresh replacement worker on the next request, but it does NOT re-execute the failing request that just errored. The user gets the typed bootstrap error verbatim, no retry storm, no log explosion.
For runtime errors, by contrast, the worker stays alive. A transient inference failure on one input is no reason to throw away a fully-loaded worker (and the gigabytes of GPU memory it holds).
Transport coverage: stdio AND TCP
The same exception-shielding contract applies to all four worker request
loops in _protocol.py:
| Function | Transport | Mode |
|---|---|---|
_serve_stdio | stdio | sequential |
_serve_stdio_concurrent | stdio | concurrent (thread pool) |
_handle_tcp_connection_sequential | TCP | sequential |
_handle_tcp_connection_concurrent | TCP | concurrent (thread pool) |
Stanza/IO-profile workers use the TCP variants (one daemon, multiple
servers connecting). They are exactly the profiles that load Stanza
catalogs, the incident shape this whole architecture is designed to
prevent originally manifested on these workers. Sequential loops wrap
dispatch_protocol_message; concurrent handlers wrap
dispatch_prepared_protocol_message. Both catch BaseException, classify via
_classify_dispatch_exception, and emit the structured error envelope.
TCP handlers also tear the connection down on bootstrap-kind errors after
emitting that typed error.
The Rust side: WorkerError, FailureCategory, classifier
Source files:
| Concern | File |
|---|---|
WorkerError enum | crates/batchalign/src/worker/error.rs |
Wire WorkerResponse decoder | crates/batchalign/src/worker/handle/protocol.rs |
| TCP variant decoder | crates/batchalign/src/worker/tcp_handle.rs |
FailureCategory enum | crates/batchalign/src/types/scheduling.rs |
Classifier (classify_worker_error, is_retryable_worker_failure) | crates/batchalign/src/runner/util/error_classification.rs |
| Retry loop | crates/batchalign/src/infer_retry.rs |
| User-facing message router | crates/batchalign/src/runner/util/error_classification.rs::user_facing_error |
WorkerError taxonomy
Each variant has a documented retryability class.
| Variant | Fires when | Retryable? |
|---|---|---|
SpawnFailed(String) | The Python child process can’t start (missing python, OS resource limits) | Terminal: same config will fail again |
ReadyTimeout { timeout_s } | Worker started but didn’t emit ready signal in time | Retryable, transient stall |
ReadyParseFailed(String) | Worker emitted invalid ready signal | Terminal: version mismatch |
HealthCheckFailed(String) | Periodic health probe failed | Retryable, pool replaces worker |
ProcessExited { code, stderr } | Worker died unexpectedly mid-job | Retryable, but if deterministic, replacement will die too |
Protocol(String) | IPC framing or response shape was wrong | Terminal-for-this-request, protocol desync |
WorkerResponse(String) | Worker returned {"op":"error", "kind":"runtime"} | Retryable, per-request failure |
Bootstrap(String) | Worker returned {"op":"error", "kind":"bootstrap"} | Terminal: deterministic |
Io(io::Error) | Pipe-level I/O failure (broken pipe, etc.) | Retryable, pool replaces worker |
MemoryGuard(MemoryGuardError) | Memory-guard refused to admit the worker (insufficient headroom under the configured budget) | Not retried by is_retryable_worker_failure: classified as FailureCategory::MemoryPressure, which is outside the retry set; the scheduler re-admits later once memory frees |
NoWorker { command, lang } | Reserved variant; unused today | Terminal |
The Bootstrap variant added 2026-05-06 is the one this chapter is
about. Every existing variant kept its prior retryability class to
preserve behavior on already-debugged paths.
FailureCategory and the retry decision
FailureCategory is the broader classification that the retry loop, the
user-facing message router, and the persistence layer all consume. Thirteen
variants:
Validation, ParseError, InputMissing, EvidenceUnavailable, WorkerCrash, WorkerTimeout,
WorkerProtocol, WorkerBootstrap, ProviderTransient, ProviderTerminal,
MemoryPressure, Cancelled, System
Retry decision in is_retryable_worker_failure:
matches!(
category,
FailureCategory::WorkerCrash
| FailureCategory::WorkerTimeout
| FailureCategory::ProviderTransient
)
WorkerBootstrap is intentionally absent. That’s the load-bearing
property: a bootstrap-class failure cannot reach the retry loop’s
continue branch, so a deterministic failure stops at the first
attempt instead of echoing through three.
A retained provider response whose language was not admitted
A Rev.AI response that was retained but whose language could not be resolved is
carried as its own typed failure,
ServerError::UnresolvedAsrLanguage(RetainedRevLanguageRejection), rather than
folded into a validation message. It answers 502, because the provider’s
evidence was unusable rather than the client’s input malformed, and its body
carries diagnostic_key and admission: "rejected_unresolved_language"
alongside the message, so a caller can act on the rejection instead of parsing
prose. classify_server_error puts it in ProviderTerminal, which the
matcher above excludes: the retry would retain the same response and need the
same explicit language decision, so it is not attempted automatically.
How kind flows from wire to category
sequenceDiagram
participant Py as Python worker
participant Wire as JSON wire
participant Decoder as WorkerResponse<br/>decoder
participant Mapper as WorkerErrorKind<br/>::into_worker_error
participant Classifier as classify_<br/>worker_error
participant Retry as is_retryable_<br/>worker_failure
Py->>Wire: {"op":"error", "error":"X", "kind":"bootstrap"}
Wire->>Decoder: deserialize
Decoder->>Mapper: WorkerErrorKind::Bootstrap, "X"
Mapper->>Classifier: WorkerError::Bootstrap("X")
Classifier->>Retry: FailureCategory::WorkerBootstrap
Retry-->>Mapper: false (do not retry)
Note over Py,Wire: Legacy workers omit "kind"
Py->>Wire: {"op":"error", "error":"Y"}
Wire->>Decoder: deserialize (kind defaults to Runtime)
Decoder->>Mapper: WorkerErrorKind::Runtime, "Y"
Mapper->>Classifier: WorkerError::WorkerResponse("Y")
Classifier->>Retry: FailureCategory::ProviderTransient
Retry-->>Mapper: true (retry up to 3×)
The WorkerErrorKind::into_worker_error(message) helper in
handle/protocol.rs is the single dispatch point, every wire decoder
goes through it. Eleven call sites in handle/ipc.rs and
tcp_handle.rs were updated as part of the the bootstrap-retry defect fix; they all share
this helper.
One specialization: ensure_task errors are forced bootstrap
WorkerResponse::Error { error, kind } => {
// ``ensure_task`` is the on-demand model-loading IPC; any error
// here is by definition a bootstrap-class failure regardless
// of the wire ``kind`` field. Default to ``Bootstrap`` …
match kind {
WorkerErrorKind::Bootstrap | WorkerErrorKind::Runtime => {
Err(WorkerError::Bootstrap(format!("ensure_task failed: {error}")))
}
}
}
Why force-bootstrap regardless of the wire kind? Because ensure_task
is the on-demand model-loading IPC, its sole purpose is to bootstrap a
task into a worker’s runtime state. A failure during that operation is
always deterministic across retries, even if the worker’s
_classify_dispatch_exception doesn’t yet know about the specific error
type. The orchestrator must not retry; if the cause was actually
transient, the user can re-submit the job.
This is a defense-in-depth measure: even if a future contributor adds a
new bootstrap-class error type to Python and forgets to add it to
_classify_dispatch_exception, an ensure_task failure still classifies
correctly.
The user-facing message
Source: crates/batchalign/src/runner/util/error_classification.rs::user_facing_error.
The router maps FailureCategory to a final string the user sees in the
dashboard, CLI, TUI, and desktop app. Two design constraints shape it:
- No system internals. Strings like “Broken pipe (os error 32)”,
“exit code: Some(1)”, and Python tracebacks must never reach the
user. They get logged via
tracingfor developer debugging. - Bootstrap errors are exempt from the “internal error” framing. The worker has already produced an actionable, user-facing message (network failure, disk full, missing auth). The router surfaces it verbatim with light wrapping.
The WorkerBootstrap arm:
FailureCategory::WorkerBootstrap => {
let detail = truncate_tail(raw_error, 1000);
format!("{command_label} failed for {filename}: {detail}")
}
Compare with the WorkerCrash arm:
FailureCategory::WorkerCrash => {
let detail = truncate_tail(raw_error, 500);
format!(
"{command_label} failed for {filename}: the processing engine crashed.\n{detail}"
)
}
Bootstrap errors are not “the processing engine crashed”, they are “X is missing or unreachable, here’s what to do”. The verbatim inclusion is what makes the difference.
EvidenceUnavailable is likewise terminal and actionable, but it does not
mean a worker failed. It means a cache-required request reached a typed
evidence boundary without a reusable entry. MissingRequiredEvidence is a
closed sum type: forced alignment retains a nonempty group-index set, while
Rev.AI and speaker diarization retain the exact content-derived CacheKey.
All map to HTTP 412, and the user-facing message states that
--require-media-cache prevented inference. A missing kind cannot be confused
with another, and an empty forced-alignment miss is unrepresentable, so the
public category cannot be emitted for the successful “nothing to infer”
state.
The retry loop
Source: crates/batchalign/src/infer_retry.rs.
for attempt_number in 1..=retry_policy.max_attempts {
match pool.dispatch_execute_v2_with_progress(lang, request, progress_tx).await {
Ok(response) => return Ok(response),
Err(error) => {
let category = classify_worker_error(&error);
let has_retry_budget = attempt_number < retry_policy.max_attempts;
if is_retryable_worker_failure(category) && has_retry_budget {
let backoff_ms = retry_policy.backoff_for_retry(attempt_number);
warn!(
task = ?request.task,
lang = %lang,
attempt_number,
max_attempts = retry_policy.max_attempts,
error = %error,
category = %category,
%backoff_ms,
"Retrying execute_v2 after transient worker failure"
);
tokio::time::sleep(Duration::from_millis(backoff_ms.0)).await;
continue;
}
return Err(ServerError::Worker(error));
}
}
}
The fix lands transparently here: is_retryable_worker_failure(category)
returns false for WorkerBootstrap, the if falls through, the
function returns the error immediately. Pre-fix, the category was
WorkerCrash, the if was true, and the loop spun three times.
Future enhancement (not yet landed): emit a progress_v2 event before
each retry sleep so the UI shows “Retrying after worker error
(attempt 2/3)…”. This is a clean fit with the
time-transparency principle but is out of scope
for the the bootstrap-retry defect fix.
Adding a new bootstrap-class error type
Checklist for contributors:
-
Define the typed error in Python. Inherit from a sensible base (
RuntimeErrorfor general bootstrap failures,ValueError/UnsupportedLanguageErrorfor input-driven ones). Document that it is bootstrap-class in the docstring, the type itself is the contract. -
Register it in the classifier. Add a lazy import + append to the
bootstrap_typeslist inbatchalign/worker/_protocol.py:_classify_dispatch_exception. -
Make sure the error message is actionable. The user will see it verbatim. Include: what failed, where (URL, file path), why (network / disk / auth), and what they should do.
-
Add a test. Two patterns, both already in the codebase:
batchalign/tests/test_serve_stdio_bootstrap_error.py: assert the new exception type classifies asbootstrap.- A handler-level test that mocks the underlying failure and asserts the worker emits the right wire envelope.
-
No Rust changes required for additional bootstrap-class types on the Python side. The wire protocol is type-erased, Python just emits
kind: "bootstrap", and Rust’s existingWorkerError::Bootstrapvariant absorbs every such error uniformly.
If the new error class needs different orchestrator behavior (e.g., “retry after a delay even though it’s deterministic”), that’s a deeper change, discuss before implementing.
Adding a new wire-level worker error variant
Less common but documented for completeness. If a new orthogonal error shape needs its own Rust variant (e.g., “external provider returned permission-denied” → distinct user-facing remediation):
- Add the variant to
WorkerErrorinworker/error.rswith a doc comment explaining when it fires and its retryability class. - Add a matching
FailureCategoryvariant intypes/scheduling.rs(and update theDisplay/FromStrimpls). - Update
classify_worker_errorto map the newWorkerErrorvariant to the new category. - Update
user_facing_errorto render an actionable message for the new category. - Update
is_retryable_worker_failureif the new category is retryable. - Update the test in
runner/util/mod.rs::worker_error_classification_is_stableto lock in the classification. - Update this chapter’s tables.
Concurrent reader control and shutdown
Rust admission separates executable requests from immediate reader replies.
PendingProtocolRequest has no Python constructor, owns its operation, and
cannot name shutdown. Native execution accepts only that type. Changing the
original message or the copied envelope exposed for error correlation cannot
turn an admitted health request into shutdown. Immediate reply payloads and
actions come from one closed native outcome, so a shutdown action cannot be
paired with an error payload.
Both concurrent transports process immediate replies before submitting work to the executor. After replying to shutdown, the reader exits its loop without waiting for another input line or client EOF. Queued requests observe shutdown; already running calls are still joined. This fixes the idle open-pipe hang, not cancellation of an arbitrary in-flight model call.
The concurrent TCP handler also owns publishing shutdown and waking its socket
reader as one operation. Bootstrap errors and write failures can therefore
close the connection after their response without waiting for client EOF. The
former bootstrap test masked a blocked reader with a two-second read timeout;
it now requires the handler to terminate while the client write side is open.
Concurrent stdio uses native ProtocolStdin, which owns delivery and stopping
through one bounded mailbox. Bootstrap errors and response-write failures stop
that mailbox and wake Python immediately. EOF is a different state: it drains
already admitted requests rather than cancelling them. The former independent
Python event and blocked sys.stdin iterator are gone.
One process-owned Rust thread reads UTF-8 protocol lines from OS stdin. It holds no Python objects or GIL and retains at most one queued line plus the line being read. A private process-wide lease prevents competing native readers. Stopping wakes mailbox waits but cannot portably cancel the OS read; the worker therefore does not join this native thread before process exit. The thread retains its lease until its read ends or the process terminates. This is a worker-process transport, not a reusable reader for arbitrary embedded Python streams. No polling interval, Python buffered-reader thread, or new dependency is involved. Already running model calls remain joined by the executor.
Reproduce with the actual child process, loopback socket and native admission:
uv run --no-sync pytest -q batchalign/tests/test_serve_stdio_bootstrap_error.py \
batchalign/tests/test_serve_tcp_bootstrap_error.py \
batchalign/tests/test_worker_protocol_dispatch.py
The stdio regression received its shutdown acknowledgement but exceeded five seconds while stdin remained open before the fix. The asynchronous bootstrap case independently reproduced the same hang. Both now exit with the parent pipe still open, and an EOF control requires all 32 queued health replies. The TCP bootstrap regression also failed before its reader wakeup was added. A malformed Unicode operation now produces an error reply instead of escaping from admission as a reader exception. Wire responses for supported operations retain their existing shape.
Alignment window admission
Grouping owns the audio window sent to an aligner. Every production FaGroup
comes from that stage with a private, admitted FaWindow; live dispatch and raw
cache replay use that same value. They no longer receive a separately supplied
recording and reconstruct the window from timestamps. Unit tests of downstream
injection can declare fixtures through a test-only constructor, which is absent
from production builds.
The engine’s time budget includes trailing gap padding. A pending group owns its words, utterance indices and bounded window together; appending overlapping utterances extends its coverage without shrinking the earlier extent. When a new utterance cannot join, the pending group is finished before the new one starts. Utterances with no alignable words cannot alter a pending audio window.
A single utterance with an oversized, empty, inverted or out-of-recording window
receives a durable FA window_refused decision. No request is dispatched for
it. Grouping preserves the supplied words and timing rather than clipping an
uncertain long window or guessing finer word positions. Later pipeline timing
repair remains a separate, recorded operation. Refusal means narrower timing
evidence is required; it does not mean the source speech is invalid or that the
corpus is complete.
The existing character-count grouping policy remains separate. A single utterance exceeding the Whisper label budget can still reach its model error path; this audio-window change does not claim tokenizer-budget admission.
Reproduce the window-policy checks with the repository fixture:
cargo test -p batchalign --lib group_window_budget
Before the fix, the oversized-utterance case produced no refusal, and padding expanded a requested 10,000 ms window to 11,500 ms. The private group field also made 19 old unit-test struct literals fail to compile, removing that raw construction route from production callers.
FA evidence after a group-local failure
An attempted worker call is not itself timing evidence. Both full-file and
incremental FA consume FaWorkerGroupResult::into_projection(), which issues
the timing projection and its source together. A successful admitted response
records inference; a deliberately skipped group records unaligned and
materializes one missing timing per word. A successful response may also align
zero words, so missing timings alone cannot determine provenance.
This adds the unaligned evidence-source label to serialized FA artifacts.
Existing source labels remain readable. Older artifacts may label failed groups
as inference; the September 6 MICASE failed run preserves that historical
output alongside its worker logs rather than rewriting the evidence. Consumers
of older runs must consult those logs when distinguishing an attempted call
from an admitted response.
ProcessExited proves that the request lost its worker. Without a reported
exit cause it does not establish OOM, a signal, or deterministic failure of that
input. The warning now states process exit without inventing a diagnosis.
Worker replacement and safe FA window sizing are separate concerns; correct
provenance does not make an oversized alignment request safe.
The serialization regression runs inside the existing library test target:
cargo test -p batchalign --lib worker_outcomes_serialize_distinct_evidence_provenance
Cross-references
- Time transparency UX principle, why every
long worker operation must surface to the UI; downstream consumer of
the same
progress_v2channel. - User-facing model-downloads chapter , the user-facing contract that motivates the on-demand-download path.
- Developer model-downloads chapter , full inventory of every model-load site.
- Server architecture overview, the broader context this chapter slots into.
- Server model loading, per-command model inventory and lazy-load policy.
- Stanza capability registry, the pre-flight gate whose silent-fail path was the proximate cause of the retry-classification bug this chapter documents.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
CHAT Dependent Tier Handling by Command
Status: Current Last updated: 2026-05-20 01:14 EDT
Each command reads and writes different dependent tiers (%mor, %gra, %wor, %xtra).
This determines which parse mode is used at pipeline entry.
Parse modes
- Strict (tree-sitter parse via
talkbank_parser): Rejects the file on ANY parse error. Used when the input is expected to be valid , i.e., output from a previous pipeline stage. - Lenient (
talkbank_transform::parse::parse_lenientat../chatter/crates/talkbank-transform/src/parse.rs:17): Error recovery: keeps parseable content and drops broken tiers. Used at pipeline entry because input may have malformed dependent tiers from legacy CLAN runs or previous batchalign versions.
Where each parse mode is used
| Location | Mode | Why |
|---|---|---|
| Rust server (per-file dispatch) | Lenient | Input CHAT may have broken dep tiers |
| Rust server (post-injection re-parse) | Strict | Engine output should be valid; catch bugs early |
| Rust server (comment insertion) | Strict | Pipeline output should be valid |
Per-command tier handling
| Command | Reads Dep Tiers | Writes Dep Tiers | Notes |
|---|---|---|---|
| morphotag | None (clears %mor/%gra first) | %mor, %gra | clear_morphosyntax() strips existing tiers before processing |
| align | %wor (for UTR) | %wor | Regenerates timing from scratch |
| translate | None | %xtra | Adds translation tier |
| utseg | None | (restructures utterances) | Splits/merges utterance boundaries |
| transcribe | N/A (generates from audio) | All | Creates fresh CHAT |
| opensmile | N/A (media only) | N/A (CSV output) | Analysis only |
Why morphotag clears before processing
The Rust collect_payloads() function
(crates/batchalign-transform/src/morphosyntax/payload.rs:135) skips
utterances that already have a %mor tier (optimization for cache
injection). Without clearing first, files with existing %mor would
be silently round-tripped unchanged. Calling the Rust function
clear_morphosyntax
(crates/batchalign-transform/src/morphosyntax/payload.rs:333) at the
top of process_morphosyntax()
(crates/batchalign/src/morphosyntax/mod.rs:64) ensures all
utterances are reprocessed. Cache hits still work, the cache lookup
happens after clearing but before Stanza runs.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
@Options and Per-File Command Scoping
Status: Current Last updated: 2026-09-05 04:22 EDT
CHAT files can carry an @Options: header that scopes which
batchalign3 commands are allowed to run on them. The two values
batchalign3 reads are CA and NoAlign. They are independent
directives, each scoped to a specific command, they are not a
generic “skip everything” flag.
@Options: CA: skip morphotag
A Conversation Analysis transcript. By default, @Options: CA means
“the morphotag command is not to be run on this file.” CA
transcripts use a separate convention for prosodic and discourse
annotation that does not benefit from automatic POS / dependency
analysis, and any %mor / %gra written by morphotag would be at best
noise.
batchalign3’s behavior on a @Options: CA file:
morphotag: pass-through. The file’s existing %mor / %gra (if any) is preserved unchanged. No Stanza inference. No provenance comment is added.- All other commands, run normally.
Corpus reconstruction can make a different, explicit choice with
--ca-policy analyze. That policy runs morphotag while preserving the
@Options: CA header. It exists for a documented source-to-publication
rebuild where automatic morphology is part of the intended edition, such as
reconstructing MICASE material whose published derivative already carries
%mor. The default remains honor; a routine invocation never overrides the
transcript declaration silently.
@Options: NoAlign: skip the align command
@Options: NoAlign literally means “the align command in
batchalign3 is not to run on this file.” NoAlign is scoped to
forced alignment specifically. The directive came from the era when
audio bullets in CHAT could be word-level or utterance-level; a
NoAlign file declares that its audio bullets cover whole utterances
and should not be re-aligned to individual word boundaries by FA.
batchalign3’s behavior on a @Options: NoAlign file:
align: pass-through. No Whisper, no DP alignment, no rewriting of timing markers.morphotag: runs normally. NoAlign has nothing to do with the text-level morphological analysis that morphotag performs. (Prior to 2026-05-07, the morphotag pipeline incorrectly conflated NoAlign with a global skip; this caused 297 corpus files to accumulate stale%mor/%grafrom old buggy runs with no rerun path. The fix restores the orthogonal scoping the directive was always meant to have.)- All other commands, run normally.
Combining CA and NoAlign
A file may carry both: @Options: CA, NoAlign. Under the default CA policy,
the directives remain orthogonal: CA skips morphotag, NoAlign skips
align, and any other command runs. --ca-policy analyze changes only the
morphotag decision; NoAlign continues to govern alignment independently.
What the directives are NOT
They are not a generic “this file is special, leave it alone” flag.
They are not a substitute for setting --lang or --skip-* flags.
They scope batchalign3 commands by name, deliberately, per the
CHAT manual’s per-file convention. New batchalign3 commands should
not extend or repurpose these directives without an explicit
specification update.
Related
- CHAT manual: https://talkbank.org/0info/manuals/CHAT.html (search for “Options” in the headers reference).
crates/batchalign/src/pipeline/morphosyntax.rs, where the submittedCaMorphotagPolicyand parsed header produce one typed per-file disposition consumed by all downstream stages.crates/batchalign/src/fa/mod.rs: thealign-side gate (is_no_align).
This page last changed: 2026-09-05 (commit 73f146d4). The whole book last changed: 2026-09-16 (commit 34d249d8).
Morphosyntax Pipeline
Status: Current Last updated: 2026-05-23 23:52 EDT
1. Overview
The batchalign morphosyntax pipeline (morphotag command) adds %mor and %gra tiers to
CHAT transcripts. Rust owns CHAT parsing, word extraction, UD-to-CHAT mapping, AST
injection, and serialization. Python’s only role is ML inference, calling Stanza for
POS/lemma/dependency analysis.
Per-file scoping via @Options
morphotag reads two CHAT-level directives, both with narrow,
command-specific semantics:
@Options: CA: “the morphotag command is not to be run on this file.” CA transcripts are pass-through: existing %mor / %gra is preserved unchanged, no Stanza inference, no provenance comment.@Options: NoAlign: scoped to thealigncommand, NOT morphotag. NoAlign files run morphotag normally. (Pre-2026-05-07 the pipeline incorrectly conflated NoAlign with a global skip, leaving NoAlign files with stale morphotag and no rerun path; fixed.)
Full semantics + worked examples: @Options and Per-File Command
Scoping.
2. Architecture
flowchart TD
chat["CHAT files"]
parse["parse_lenient()\n(talkbank-transform::parse)"]
clear["clear_morphosyntax()\nstrip existing %mor/%gra"]
collect["collect_payloads()\nper-utterance word lists"]
batch["execute_v2 morphosyntax\n→ Python Stanza worker<br/>(no-op cache: always infer)"]
mode{"TokenizationMode?"}
preserve["map_ud_sentence()\nmerge MWT → clitics\n1 MOR per CHAT word"]
retok["map_ud_sentence_expanded()\n1 MOR per component word\nfilter Range parents"]
inject_p["inject_morphosyntax()\nadd %mor/%gra tiers"]
inject_r["retokenize_utterance()\nrewrite main tier + inject"]
l2{"L2 @s words\ndeferred?"}
l2disp["dispatch_secondary_l2()\nplan + dispatch secondary Stanza"]
splice["splice_l2_into_chat()\nreplace L2|xxx"]
out["Serialize → CHAT"]
chat --> parse --> clear --> collect --> batch
batch --> mode
mode -->|Preserve| preserve --> inject_p
mode -->|StanzaRetokenize| retok --> inject_r
inject_p --> l2
inject_r --> l2
l2 -->|yes| l2disp --> splice --> out
l2 -->|no| out
The diagram shows the two injection paths that diverge based on
TokenizationMode. The L2 secondary dispatch runs after primary
injection by default; pass --no-l2-morphotag to skip it.
Cache note: Morphosyntax (a text NLP task) uses a no-op cache, all utterances skip cache lookup and are always sent to Stanza inference. This is faster than SQLite lookups, as Stanza workers stay warm between utterances in the cross-file batch. Audio tasks (transcribe, align) use real caching; text tasks (morphosyntax, utseg, translate) do not.
Data Flow
Rust entry point: `crates/batchalign/src/morphosyntax/mod.rs::run_morphosyntax_impl`
│
├── Parse CHAT (Rust AST via tree-sitter, parsed once per file)
│
├── clear_morphosyntax(): strip existing %mor/%gra tiers
│ (talkbank-transform::morphosyntax::payload)
│
├── collect_payloads(): extract utterance word lists globally
│ (talkbank-transform::morphosyntax::payload)
│
├── Batch infer (all utterances pool → one Stanza call per language)
│ ├── Group by language, dispatch concurrently
│ ├── Python worker (batchalign/inference/morphosyntax.py)
│ │ • Replace special forms with "xbxxx"
│ │ • nlp(combined_text) → Stanza UD analysis
│ │ • Return raw UD results as JSON
│ └── Repartition responses back by file
│
├── map_ud_sentence() or map_ud_sentence_expanded()
│ → %mor/%gra (UD→CHAT mapping, Rust)
│ (talkbank-transform::morphosyntax::sentence_mapping)
│
├── inject_results(): AST injection + validation
│ (talkbank-transform::morphosyntax::injection)
│
├── dispatch_secondary_l2() (if `@s` words and not `--no-l2-morphotag`)
│ → transform-layer plan, secondary dispatch, merge, splice
│ (crates/batchalign/src/morphosyntax/batch.rs)
│
├── apply_pos_hints() (if --respect-pos-hints, default on)
│ → transcriber `$POS` annotations override POS categories
│ (talkbank-transform::morphosyntax::pos_hints)
│
├── remove_empty_morphosyntax_placeholders()
│ → sweep serialize-time empty %mor/%gra slots
│ (talkbank-transform::morphosyntax::pos_hints)
│
└── Serialize → CHAT (now with %mor/%gra, L2 morphology, POS hints)
Module Inventory
Rust: batchalign-transform crate (crates/batchalign-transform/src/)
The core morphosyntax pipeline logic lives in talkbank-transform. Most files
handle CHAT-side extraction, UD→CHAT mapping, and injection. The
batchalign crate orchestrates; talkbank-transform implements.
| File | Purpose |
|---|---|
parse.rs | parse_lenient(): top-level CHAT parsing entry point |
extract.rs | ExtractedWord struct + word extraction from AST for morphosyntax input |
inject.rs | inject_morphosyntax(): primary AST injection of %mor / %gra tiers |
morphosyntax/injection.rs | inject_results(): orchestration helper called by the batch pipeline |
morphosyntax/payload.rs | clear_morphosyntax(), collect_payloads(), dispatch_secondary_l2() host adapter |
morphosyntax/sentence_mapping.rs | map_ud_sentence(), map_ud_sentence_expanded(), shared build_gra_and_validate() |
morphosyntax/gra_validate.rs | validate_generated_gra(): single-root, cycle-free, valid-heads checks |
morphosyntax/mapping_helpers.rs | assemble_mors() (clitic merge), is_clitic(), map_relation() |
morphosyntax/stanza_raw.rs | Parse raw Stanza JSON output, supply defaults for Range token annotation fields |
morphosyntax/pos_hints.rs | apply_pos_hints() and the empty-placeholder sweep |
morphosyntax/l2/ | L2 code-switching: planning, extract, merge, splice @s words via secondary Stanza models |
morphosyntax/lang_en.rs | English-specific rules (irregular verbs, irrealis annotations) |
morphosyntax/lang_fr.rs | French-specific rules (pronoun case, APM) |
morphosyntax/lang_ja.rs | Japanese-specific rules (verb form overrides) |
morphosyntax/lang_it.rs | Italian-specific rules |
retokenize/, retokenize.rs | AST retokenization (Stanza-tokens rewrite); see Section 7 |
dp_align/ | Hirschberg DP alignment used by retokenize |
Rust: batchalign crate (crates/batchalign/src/)
The batchalign crate owns command orchestration; the morphosyntax-specific glue is:
| File | Purpose |
|---|---|
morphosyntax/mod.rs | run_morphosyntax_impl(): top-level orchestrator called from the morphotag command |
morphosyntax/batch.rs | dispatch_secondary_l2(): async wrapper that calls into the transform-layer L2 seam for secondary @s dispatch |
morphosyntax/worker.rs | Stanza-pool dispatch, partition_groups_by_stanza_support() |
chat_ops/nlp/mapping/mod.rs | Re-export shim: pub use talkbank_transform::morphosyntax::*: historical alias kept so existing imports keep resolving. New code should import from talkbank_transform directly. |
chat_ops/nlp/types.rs | FA-only raw-response types (FaRawToken, FaIndexedTiming, FaRawResponse); the UD/NLP type set (UdSentence, UdWord, UdId, etc.) lives in talkbank_transform::morphosyntax. |
Python (stateless ML inference only)
| File | Purpose |
|---|---|
inference/morphosyntax.py | Calls Stanza nlp(), returns raw to_dict() output |
worker/_infer_hosts.py | Worker-side host wrapper invoked by execute_v2 |
Python does no orchestration, caching, or UD→CHAT mapping, all handled by Rust.
3. What Batchalign Needs from %mor
Batchalign treats %mor tiers as mostly opaque. No pipeline decomposes POS, lemma, or features into structured data for analysis. The consumers and what they actually access:
| Consumer | What it accesses | Decomposes POS/lemma/features? |
|---|---|---|
Cache (engine.py) | Final %mor/%gra strings (BLAKE3 key) | No, stores/retrieves whole strings |
Coreference (coref) | Token boundaries in %mor tier | No, counts tokens only |
WER evaluation (benchmark) | Token count from %mor for word-level accuracy | No, counts only |
Pre-serialization validation (validation.py) | Chunk count alignment (%mor chunks vs %gra relations) | No, calls count_chunks() in Rust |
| CLAN commands (talkbank-clan crate in talkbank-tools) | Full %mor structure (POS, lemma, suffixes for FREQ/MLU/MLT) | Yes, but via talkbank-model’s Mor type |
| Forced alignment | No %mor access | N/A |
| ASR / diarization | No %mor access | N/A |
Key finding: Within batchalign itself, %mor is a cached final string. The pipeline
generates it (via Stanza + Rust mapping), stores it, and injects it into the AST, but
never reads it back to extract linguistic information. Downstream consumers that do
decompose %mor (CLAN commands) do so through talkbank-model’s typed Mor structure, not
through batchalign code.
Implication for the format: The flat POS|lemma[-Feature]* structure that Stanza
produces is sufficient for batchalign’s needs. Richer UD key=value features flow through
the pipeline without code changes, they’d be encoded as CHAT suffixes by mapping.rs and
round-tripped by the parser, but no batchalign consumer currently needs them.
4. Two MOR Traditions
%mor tiers in CHAT come from two fundamentally different sources, and understanding which one batchalign produces is key to assessing “information loss.”
CLAN MOR Grammars (Legacy)
Hand-coded per-language grammars, maintained since the 1990s. They produce rich morphological structure:
- Subcategorized POS:
pro:sub|I,n:prop|John,v:cop|be - Compounds:
adj|+adj|big+n|bird(structured multi-stem words) - Prefixes:
trans#n|port - Morpheme segmentation:
go&PAST(fusional) vscat-PL(agglutinative) - Language-specific affix inventories hand-coded per grammar
These grammars are incomplete (not all languages covered), inconsistent across languages, and require manual maintenance. They encode a specific morphological theory baked into each grammar file.
Stanza UD (What Batchalign Produces)
Automatically trained models producing Universal Dependencies analysis for 70+ languages:
- Flat UPOS:
pron|I,propn|John,aux|be - Lemma + feature list:
verb|go-Past,noun|cat-Plur - MWT clitics:
pron|I~aux|will - No compounds, no prefixes, no morpheme segmentation
- Consistent cross-linguistic feature inventory (UD standard)
- Richer dependency structures (%gra from UD is genuinely better than what CLAN produced)
What the Model Looks Like
The shared talkbank-model Mor type:
struct Mor {
main: MorWord,
post_clitics: SmallVec<[MorWord; 2]>,
}
struct MorWord {
pos: PosCategory, // "noun", "verb", "pron", ...
lemma: MorStem, // cleaned stem text
features: SmallVec<[MorFeature; 4]>, // flat ordered list
}
Three fields per word: POS (string), lemma (string), features (ordered vector of
strings). This maps cleanly to what Stanza produces, UPOS to pos, lemma to
lemma, UD feature values to features, MWT components to post_clitics.
What’s “Lost”
| Structure | Legacy MOR grammar | Stanza UD | Model representation |
|---|---|---|---|
| POS subcategories | pro:sub|I | pron|I | POS string, subcategories preserved if present (parser accepts pro:sub) |
| Compounds | adj|+adj|big+n|bird | Not produced | Parsed by grammar if encountered; no typed compound field |
| Prefixes | trans#n|port | Not produced | Parsed by grammar if encountered; stored in stem |
| Morpheme segmentation | go&PAST vs go-PAST | Not produced | Both parsed; suffix carries separator character |
| UD feature keys | N/A | Number=Plur | MorFeature has optional key field, preserved if present |
| xpos (language-specific POS) | N/A | Available in Stanza | Discarded, only UPOS used |
The structures that the model doesn’t have typed fields for, compounds, prefixes, morpheme boundaries, are structures that Stanza never produces. The model was shaped to match the producer.
Freedom from CLAN MOR Constraints
This is mostly a good thing:
- Cross-linguistic consistency. CLAN MOR grammars varied wildly per language. UD gives the same feature inventory everywhere.
- No manual grammar maintenance. Stanza models are trained automatically. Adding a language means training a model, not writing a grammar by hand.
- Better dependency analysis. UD %gra is more accurate than what CLAN produced, the Rust mapper’s O(N) cycle detection catches malformed-head structures that earlier CLAN-era pipelines silently accepted.
- Feature transparency. UD features like
Number=Plurare semantically meaningful and machine-readable. CLAN suffixes like-PLrequired per-grammar documentation.
The CLAN Caveat
CLAN commands (FREQ, MLU, MLT) access %mor through talkbank-model’s Mor type.
For counting (MLU) and frequency (FREQ), the flat pos + lemma + features structure is
sufficient. For fine-grained morphological queries on legacy corpus data, “find all
compound nouns”, “count prefixed verbs”, you’d currently have to pattern-match on the POS
string (e.g., n:prop contains :) or lemma, which works but isn’t ideal.
Should the model grow structured compound/prefix/subcategory fields? Not urgently.
The flat model serves all current use cases, and enriching it would be additive (no
breakage). Now that batchalign shares talkbank-model via path dependencies (no more
vendored copy), any such enrichment is a shared decision visible in talkbank-tools’s review
process, the right place for it to happen.
5. UD-to-CHAT Mapping
Absorbed from the former mor-gra-generation.md. The mapping lives in
crates/batchalign-transform/src/morphosyntax/, with the main entry point being
sentence_mapping.rs::map_ud_sentence.
Pipeline
Main tier words
↓
Stanza NLP (Python worker, `worker/_infer_hosts.py` → `inference/morphosyntax.py`)
↓ produces UdSentence { words: Vec<UdWord> }
↓ each UdWord has: id, text, lemma, upos, feats, head, deprel
↓
map_ud_sentence() (Rust, talkbank-transform::morphosyntax::sentence_mapping)
↓ produces (Vec<Mor>, Vec<GrammaticalRelation>)
↓
Post-construction validation (gra_validate.rs::validate_generated_gra)
↓ rejects if chunk count != gra count, single-root violated, or cycle detected
↓
inject_morphosyntax() (talkbank-transform::inject) /
inject_results() (talkbank-transform::morphosyntax::injection)
↓ writes %mor and %gra tiers into the AST
↓
CHAT serialization
MOR Generation: Two Mapping Variants
The mapping layer provides two functions that differ only in how MWT Range
tokens are handled. Both share identical GRA/validation logic via the
internal build_gra_and_validate() helper.
flowchart LR
ud["UdSentence\n(from Stanza)"]
mode{"Mapping\nvariant?"}
merged["map_ud_sentence()\nassemble_mors() merges\nRange → 1 clitic MOR"]
expanded["map_ud_sentence_expanded()\nmap_ud_word_to_mor() per component\nRange → N individual MORs"]
gra["build_gra_and_validate()\nchunk indexing, GRA relations,\nroot check, terminator, validation"]
out["(Vec<Mor>, Vec<GrammaticalRelation>)"]
ud --> mode
mode -->|"Preserve\n(L2 splice)"| merged --> gra
mode -->|"StanzaRetokenize\n(main tier rewrite)"| expanded --> gra
gra --> out
map_ud_sentence(): Preserve mode and L2 splice. Produces one Mor
item per CHAT word. MWT Range tokens are merged into a single clitic MOR
via assemble_mors():
"I'll" → Range(1,2): ["I", "'ll"]
is_clitic("I", en) → false → main_idx = 0
Post-clitics: ["'ll"]
Result: pron|I~aux|will (1 MOR, 2 chunks)
map_ud_sentence_expanded(): Retokenize mode. Produces one Mor
per component word. Range parent tokens are skipped; each component gets
its own MOR via map_ud_word_to_mor():
"gonna" → Range(1,2): ["gon", "na"]
gon → verb|go-Part-Pres-S (1 MOR)
na → part|to (1 MOR)
Result: 2 separate MORs (matched to 2 tokens on rewritten main tier)
The expanded variant exists because the retokenize path rewrites the main tier with Stanza’s tokens, each token needs its own MOR item. The Preserve path keeps the original main tier, so Range components must be merged into one clitic MOR to match the single CHAT word.
GRA Generation
Critical: %gra indices are per-chunk, not per-word.
Each %mor chunk (including clitics) needs its own %gra relation. The GRA builder:
-
Builds a chunk-based index mapping (
ud_to_chunk_idx). Each UD word ID maps to a sequential chunk index. For MWT ranges, each component gets its own index:Range(1,2) "I'll": ID 1 → chunk 1, ID 2 → chunk 2 Single(3) "give": ID 3 → chunk 3 Single(4) "you": ID 4 → chunk 4 -
Emits one GRA relation per component (not one per MWT). Each component’s UD head and deprel are used directly:
I: head=3 (give), deprel=nsubj → 1|3|NSUBJ 'll: head=3 (give), deprel=aux → 2|3|AUX give: head=0 (root) → 3|0|ROOT you: head=3 (give), deprel=iobj → 4|3|IOBJ -
Adds terminator PUNCT relation pointing to ROOT.
TalkBank Conventions
The mapper applies four CHAT-specific transformations, all lossless:
| UD Convention | TalkBank Convention | Example |
|---|---|---|
head=0 for root | head=0 (same, UD standard) | 2|0|ROOT |
| Subtypes with colon | Subtypes with dash | acl:relcl → ACL-RELCL |
| Lowercase relations | Uppercase relations | nsubj → NSUBJ |
| Multi-value features with comma | Commas preserved | PronType=Int,Rel → -Int,Rel |
The first three are trivially reversible surface-syntax changes. The comma convention
preserves the UD multi-value separator as-is. This differs from older CLAN-produced
corpus data which used concatenation (IntRel, AccNom). The tree-sitter grammar and
%mor parser both accept commas in suffix values.
POS Mapping
POS categories use lowercased UPOS tags:
| UPOS | CHAT POS | Suffix features |
|---|---|---|
| NOUN | noun| | Gender, Number, Case, Ger |
| VERB/AUX | verb|/aux| | VerbForm, Tense, Person, -irr |
| PRON | pron| | PronType, Case, Reflex, Number, Person |
| DET | det| | Gender, Definite, PronType |
| ADJ | adj| | Degree, Case |
| ADP | adp| | (none) |
| PROPN | propn| | (none) |
| INTJ | intj| | (none) |
| CCONJ | cconj| | (none) |
| SCONJ | sconj| | (none) |
Language-specific rules live in dedicated modules under
crates/batchalign-transform/src/morphosyntax/: lang_en.rs (English
irregular-verb table and irrealis annotations), lang_fr.rs (French
pronoun case and APM handling), lang_ja.rs (Japanese verb-form
overrides), lang_it.rs (Italian). Add a language by mirroring the
shape of one of these modules.
6. Post-Construction Validation
map_ud_sentence validates generated output before returning:
Structural GRA Validation
validate_generated_gra enforces four rules:
- Single root: exactly one self-referential or head=0 relation (excluding terminator)
- No circular dependencies: no word is its own ancestor in the head chain
- Valid heads: all head references point to existing word indices or 0
- Sequential indices: guaranteed by construction
Cycle detection uses an O(N) White-Gray-Black DFS with memoization: each word follows its head chain to the root, marking nodes IN_PROGRESS (gray) on the way down and NO_CYCLE (black) on the way back. Encountering a gray node means a cycle.
On failure, validate_generated_gra (in
crates/batchalign-transform/src/morphosyntax/gra_validate.rs) returns
Err(MappingError) with a detailed error message including the full
invalid structure. The caller (morphosyntax orchestrator) logs the
error and skips the utterance, no corrupted %gra is written to disk.
The mapper uses HashMap<usize, usize> to translate UD word IDs to
CHAT chunk indices. Missing keys fall through to unwrap_or(&0) and
are caught by the valid-heads check, so a wild UD response cannot
silently produce a malformed %gra line.
Chunk Count Alignment
The critical guard added after the MWT/GRA bug:
let mor_chunk_count = mors.iter().map(|m| m.count_chunks()).sum::<usize>() + 1;
if gras.len() != mor_chunk_count {
return Err(MappingError::ChunkCountMismatch { ... });
}
This catches any mismatch at generation time, preventing corrupted data from ever being written to CHAT files.
7. Module Details
PyO3 boundary
The PyO3 surface (crates/batchalign-pyo3/src/lib.rs) is intentionally
narrow: it exposes only the worker-side IPC and ML-inference adapters
(worker_protocol, worker_asr_exec, worker_fa_exec,
worker_media_exec, worker_text_results, worker_artifacts,
cantonese_asr_bridge). All morphosyntax orchestration, extract,
map, inject, cache key derivation, secondary L2 dispatch, happens
in Rust, called directly by the run_morphosyntax_impl orchestrator
in the batchalign crate. Python participates only as a Stanza
inference endpoint behind the worker IPC.
extract.rs: Word Extraction
Walks the CHAT AST using walk_words() (from talkbank-model) and collects words appropriate for a given tier domain. The walker centralizes traversal of all 24 UtteranceContent variants and 22 BracketedItem variants; extract.rs provides only the word-handling closures for counts_for_tier() filtering and ReplacedWord branch logic.
#![allow(unused)]
fn main() {
pub struct ExtractedWord {
pub text: String, // cleaned text (for NLP)
pub raw_text: String, // original text (with markers)
pub special_form: Option<String>, // @c → "c", @s → "s", etc.
}
}
Domain-aware traversal via TierDomain:
| Domain | Retraces | Replacements | Untranscribed (xxx/yyy/www) |
|---|---|---|---|
| Mor | Skipped | Use replacement words | Skipped (case-insensitive) |
| Wor | Included | Use original words | Included |
Case-insensitive untranscribed detection: The counts_for_tier() gate
recognizes xxx, yyy, and www case-insensitively: uppercase variants
like XXX (illegal per E241 but common in legacy corpora) are also excluded
from extraction. Without this, uppercase untranscribed markers would be sent to
Stanza, which assigns them UPOS=X, producing a spurious x|XXX entry on
%mor that breaks alignment (E706). See Word::compute_untranscribed() in
talkbank-model.
dp_align/: Hirschberg Alignment
crates/batchalign-transform/src/dp_align/ provides the linear-space
sequence aligner used by retokenization. Properties:
- Cost model: match=0, substitution=2, gap=1
- Space: O(min(n,m)) via Hirschberg’s linear-space trick
- Small cutoff: Falls back to full DP table for small n × m
%mor / %gra parsing
%mor and %gra lines are parsed through the canonical fragment
parsers in ../chatter/crates/talkbank-parser/ into typed Mor and
GrammaticalRelation values (../chatter/crates/talkbank-model/src/model/ dependent_tier/mor/). Batchalign never re-parses these tiers from
serialized strings during pipeline execution; it operates on the
typed AST.
inject.rs: Morphosyntax Injection
The injection path lives in two places:
crates/batchalign-transform/src/inject.rs::inject_morphosyntax: the top-level entry point that walks the AST using the same traversal order asextract.rsand assignsMoritems to alignableWordnodes.crates/batchalign-transform/src/morphosyntax/injection.rs::inject_results: the helper called by the batched orchestrator aftermap_ud_sentencereturns.
Key invariant: the traversal order used by inject_morphosyntax
must exactly match the one used by extract.rs. The shared
walk_words() walker in talkbank-model enforces this, both
modules call into the same primitive and supply only their
leaf-handling closures.
retokenize/: AST Retokenization
crates/batchalign-transform/src/retokenize.rs declares the module;
its implementation files live alongside in
crates/batchalign-transform/src/retokenize/:
rebuild.rs: AST rebuilding when Stanza’s tokens differ from the original main tierparse_helpers.rs:resolve_token_text()and word-parsing helpers used during rebuild
When retokenize=true, Stanza uses its own UD tokenizer, which can
change word boundaries (splits, merges, different text). The algorithm:
- Filter Range parent tokens from
ud_sentence.words: only component words appear in the token vector. Range parents are the container entry (e.g.,id=[1,2] text="gonna") whose components follow immediately. Including both would overcount tokens and break MOR alignment. - Character-level DP alignment between original and Stanza token texts
- Build mapping: original_word_idx → stanza_token_indices
- Walk AST, rebuilding content vectors (1:1, 1:N splits, preserving non-word content)
- New
Wordvalues created by calling the fragment parser intalkbank-parser(notWord::new, which would bypass parser validation for Stanza-supplied text that may carry CHAT-significant characters) - Inject MOR/GRA tiers (MOR items are per-component via
map_ud_sentence_expanded())
UD-to-CHAT mapping module path
The implementation of map_ud_sentence() and
map_ud_sentence_expanded() lives in
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs. The
older crates/batchalign/src/chat_ops/nlp/mapping/mod.rs is a
re-export shim (pub use talkbank_transform::morphosyntax::*) kept so
existing imports continue to resolve; new consumers should import
from talkbank_transform directly. See Section 5
for the algorithm details.
8. The Callback Pattern
Batched Payload (Rust → Python)
The primary path is batched: Rust collects all utterance payloads in one pass and sends them as a JSON array. Each element:
{
"words": ["I", "eat", "cookies"],
"terminator": ".",
"special_forms": [null, null, null]
}
With special forms (e.g., gumma@c):
{
"words": ["gumma", "is", "yummy"],
"terminator": ".",
"special_forms": [["gumma", "c"], null, null]
}
Response (Python → Rust)
One item per payload item, in payload order, each a tagged union
(MorphosyntaxItemResultV2) with exactly three kinds. Python does NOT build
%mor or %gra: it returns Stanza’s own doc.to_dict() sentences, and the
UD-to-CHAT mapping happens in Rust (section 5 above).
{
"kind": "analyzed",
"raw_sentences": [[
{"id": 1, "text": "attenzi", "lemma": "attenzare", "upos": "VERB",
"head": 0, "deprel": "root"},
{"id": 2, "text": "ne", "lemma": "ne", "upos": "PRON",
"head": 1, "deprel": "iobj"}
]],
"model": {"stanza_version": "1.13.0", "lang": "ita", "pipeline": "standard"},
"repairs": [
{"kind": "relation_alias", "word": "ne",
"from_relation": "iob", "to_relation": "iobj"}
]
}
The other two kinds carry no analysis: {"kind": "no_words"} for an utterance
with no words (no model ran, so it names none), and {"kind": "failed", "error": "..."} for an item whose file fails with that message. All three
fields of analyzed are required, with no defaults: an analysis that could not
name its model, or that left “repaired nothing” indistinguishable from “does
not report repairs”, is exactly the shape that hid these facts before.
Relation repair
Stanza does not guarantee that deprel is a Universal Dependencies relation.
RepairedSentence (in inference/morphosyntax.py) is the boundary that fixes
that, and it reports what it fixed instead of only logging it. Four repairs
exist, named identically on both sides of the wire
(RelationRepairKind / UdRelationRepairKindV2):
| Kind | Trigger | Applied |
|---|---|---|
pad_relation | a padding label, <PAD> or <UNK> | dep |
relation_case | a UD relation in the wrong case, NSUBJ | lowercased, subtype kept |
relation_alias | a known non-UD spelling, iob | the UD relation it means, iobj |
unknown_relation | no UD relation and no known alias | dep |
Only the relation HEAD is a closed set. UD defines subtypes as open and
language-specific, and the corpora legitimately use many (nmod:poss,
acl:relcl, flat:foreign), so a subtype is preserved verbatim and never
validated.
Two properties are worth knowing before changing any of this:
- The repair is the constructor.
RepairedSentencecan only be built from raw Stanza words, and_analysistakes nothing else, so the step cannot be skipped and no caller can produce an analysis claiming repairs it did not make. It replaced a validator that returnedNoneand mutated its argument, which left no proof in any signature that it had run; the production path did not call it for months whilePADandIOBflowed into published corpora. - A repair cannot be a no-op or invalid.
RelationRepairrefuses a rewrite whose relation did not change, and one whose result is not a UD relation, so a count of repairs cannot be inflated by either.
Rust collects the repairs per file (AppliedAnalyses, beside the models) and
writes the total into the morphotag provenance comment as ud_repairs=; see
Provenance.
Worker-side batch inference (worker/_infer_hosts.py + inference/morphosyntax.py)
The worker-side morphosyntax host wraps Stanza to conform to this interface:
- Validate each payload item (
MorphosyntaxBatchItem); one that does not validate becomes that item’s error and no other item is affected - An utterance with no words becomes
no_wordswithout reaching Stanza - Group the rest by each item’s own language, so a code-switched utterance reaches the model for its language
- Per group, resolve the pipeline variant and the realignment mode, install
the CHAT word boundaries for Stanza’s tokenizer, and call
nlp(text)under the lock on GIL-enabled Python. The terminator is appended to the text as a parsing cue, never as data - A raise, a missing pipeline, or a sentence-count mismatch fails every item of that group with a typed reason; none of them gets an empty analysis
- Per item: remove the appended terminator, apply the PyCantonese POS
override where the variant says so, then build
RepairedSentence, which validates every word and repairs its relation - Return one tagged item per payload item, each analysis naming its model and carrying its repairs
Cache orchestration
There is no morphosyntax cache. Morphosyntax is a text-only NLP task, and the engine deliberately does not cache its outputs, see the “Cache note” at the end of Section 2. Every utterance runs through Stanza inference on every invocation; warm Stanza workers make this faster than the SQLite lookup the audio caches require. Caching applies only to FA and UTR.
9. L2 Morphotag (Default)
By default, @s (code-switched) words are routed to secondary language
Stanza models. Pass --no-l2-morphotag to opt out and emit L2|xxx
stubs on the %mor tier instead.
Dispatch Flow
sequenceDiagram
participant R as Rust Server<br/>(batch.rs)
participant P1 as Primary Stanza<br/>(e.g., German)
participant P2 as Secondary Stanza<br/>(e.g., English)
participant L2 as L2 Module<br/>(morphosyntax/l2/)
R->>P1: morphotag all utterances<br/>(primary language)
P1-->>R: UdResponse with L2|xxx<br/>for @s positions
R->>L2: extract_l2_deferred_positions()
L2-->>R: deferred positions + target lang
R->>L2: plan_secondary_dispatch()
L2-->>R: contiguous spans + host attachments
R->>P2: infer_batch(retokenize=true)<br/>contiguous @s spans
P2-->>R: UdResponse with<br/>Range tokens for contractions
R->>L2: merge_planned_secondary_span()<br/>planned structural + lexical merge
L2-->>R: merged Mor items
R->>R: splice_l2_into_chat()<br/>replace L2|xxx with real MOR
How It Works
- Primary pass produces %mor/%gra for the entire utterance. @s words
get
L2|xxxplaceholders via the special form handler ininject.rs. - Extract deferred positions identifies which words have
L2|xxxand their target languages (from@s:spa,@s:eng, or bare@sresolved via@Languages). - Plan dispatch spans creates contiguous per-utterance spans of same-language
@s words and computes the host attachment for each span root
(e.g.,
los@s:spa niños@s:spa→ one span of 2 words with an explicit external-anchor plan). - Secondary dispatch sends each planned span to a Stanza worker for the target
language with
retokenize=true. MWT contractions (it's,don't) are expanded via Range tokens,map_ud_sentence()merges them into clitics. - Merge combines secondary lexical output (lemma, features) with primary structural info (deprel, head) plus the planned host attachment using a 6-level POS resolution priority.
- Splice replaces
L2|xxxwith the merged MOR items and corrects GRA relations where the resolved POS contradicts the primary deprel.
Validation and repair policy
- Whole-utterance same-language all-
@spatterns are rejected during pre-validation (E255). The accepted CHAT form is utterance-level[- lang]. - Explicit
@s:LANGstill routes toLANGeven ifLANGis absent from@Languages, but validation emits warn-only E254 to surface the header drift. chatter debug fix-sis the intended normalization tool for both cases: it rewrites the qualifying whole-utterance@spattern, appends missing explicit languages to@Languages, and skips files that already need no change.
The fix-s rewrite predicate verifies that every word-bearing item
on the main tier (words, fillers &~/&-/&+, nonwords, retraced
material) resolves to the same target language. Fillers and nonwords
participate in the predicate AND have their @s shortcuts cleared
when the rewrite fires, otherwise a bare @s would flip its resolved
language under the new [- LANG] precode. See
the chatter CLI fix-s debug command
for the full safety contract.
Unsupported non-primary languages
morphotag skips files whose primary @Languages code is not
Stanza-supported with a typed diagnostic (no pipeline entry). When the
primary IS supported, non-primary content targeting an unsupported
language degrades gracefully:
[- UNSUPPORTEDLANG]precodes,infer_batchpartitions language groups viapartition_groups_by_stanza_support; unsupported groups bypass Stanza dispatch and the words receiveL2|xxxin%mor.@s:UNSUPPORTEDLANGper-word markers, the secondary dispatch path for that span is short-circuited the same way; the host primary analysis is preserved and the@stoken’s slot stays asL2|xxx.
The worker never crashes on an unsupported secondary, and other utterances or spans in the same file targeting supported languages continue to receive real morphology.
Key Files
| File | Purpose |
|---|---|
morphosyntax/l2/plan.rs | Contiguous span planning and host-attachment planning |
morphosyntax/l2/extract.rs | Extract primary structural info from UD responses |
morphosyntax/l2/spans.rs | Group @s positions into contiguous dispatch spans |
morphosyntax/l2/merge.rs | POS resolution priority, planned structural merge |
morphosyntax/l2/splice.rs | Replace L2|xxx in ChatFile with merged MOR |
morphosyntax/l2/deprel.rs | UdDeprel newtype, deprel→POS constraint mapping |
morphosyntax/batch.rs | dispatch_secondary_l2(): thin worker adapter over the transform-layer L2 seam |
MWT Contraction Handling
L2 dispatch sends retokenize=true to the secondary worker, enabling
Stanza’s MWT expander for the target language. For English @s words:
it's@s:eng→pron|it~aux|be(clitic MOR, notL2|xxx)don't@s:eng→aux|do~part|not(clitic MOR)working@s:eng→noun|work-Part-Pres-S(no contraction, regular MOR)
The L2 path uses map_ud_sentence() (merged clitics), which is correct
because L2 does NOT rewrite the main tier, the @s word stays as-is,
and its %mor slot gets the clitic form.
10. Gotchas
cleaned_text is Derived, Not Settable
The CHAT serializer uses Word.content (WordContents), not
raw_text or cleaned_text. Simply changing
word.cleaned_text = "new" does not change serialized output. To
create a word with different text, parse it via the talkbank-parser
fragment API (SingleItemParser::parse_word or the
parse_word_fragment entry on parser_api.rs), which runs the full
tree-sitter parse and produces a structurally-valid Word.
Word::new() Bypasses Validation
Word::new(raw_text, cleaned_text) creates a minimal Word with a
single WordContent::Text element. For retokenization where text
comes from Stanza (which may contain CHAT-significant characters),
prefer one of the fragment parser entries above instead, so that
markers, brackets, and other CHAT structure are recognized rather
than embedded raw.
Traversal Order Must Match Between extract/inject/retokenize
All three modules walk the AST using walk_words() / walk_words_mut() from
talkbank-model, ensuring identical traversal order. The walker handles group recursion
and domain-aware gating centrally. If leaf-handling closures apply different filtering
between extraction and injection, morphology is assigned to wrong words.
Separator Word Counter Sync
extract.rs includes tag-marker separators (comma ,, tag „, vocative ‡) as NLP
words in the Mor domain. Any code walking the AST with a word_counter must also
increment for separators. retokenize.rs handles this explicitly. Forgetting causes
counter desync.
Manual JSON Parsing
batchalign-core uses manual JSON field extraction instead of serde_json at runtime to
avoid the dependency in the release binary. The parsers handle escapes but are not
general-purpose.
Special Forms and xbxxx
Words with @c, @s, @b markers are replaced with "xbxxx" before Stanza analysis.
When retokenize=true, retokenize.rs restores original text via resolve_token_text().
skipmultilang and Language Handling
When skipmultilang=true, utterances with [- lang] override where the language differs
from the file’s primary language are skipped. Language codes: file language is ISO 639-3
("eng", "fra"); callback adapter converts to ISO 639-1 ("en", "fr") for Stanza.
This flag is only about utterance-level [- lang] routing. Per-word @s
secondary dispatch is controlled separately by --no-l2-morphotag.
BracketedItems is a Newtype
BracketedContent.content is BracketedItems(Vec<BracketedItem>), a newtype that does
not implement Default. Use std::mem::replace(&mut field, BracketedItems(Vec::new()))
instead of std::mem::take().
Uppercase Untranscribed Markers (XXX, YYY, WWW)
Legacy corpora frequently contain uppercase XXX instead of the required
lowercase xxx. These are flagged as E241 by the validator, but the morphotag
pipeline must still handle them correctly. The extraction layer’s
counts_for_tier() gate uses Word::compute_untranscribed(), which matches
case-insensitively. This prevents uppercase variants from being sent to Stanza,
which would produce spurious x|XXX entries on the %mor tier and cause E706
alignment mismatches.
Stanza token.id is Always a Tuple
(word_id,) for regular words, (start, end) for MWT. Never assume it’s an int.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
%gra Format Conventions
Status: Current Last updated: 2026-05-20 07:55 EDT
This page describes the %gra forms that batchalign3 currently accepts when
reading corpora and the stricter form it generates when writing new %gra
tiers.
Accepted Root Conventions
When parsing existing CHAT data, batchalign3 accepts both root styles that
occur in TalkBank corpora:
head=0for theROOTrelationhead=selffor theROOTrelation
Examples:
head=0
%gra: 1|2|SUBJ 2|0|ROOT 3|2|OBJ 4|2|PUNCT
head=self
%gra: 1|3|DET 2|3|AMOD 3|3|ROOT 4|6|NSUBJ 5|6|ADVMOD 6|3|ACL-RELCL 7|3|PUNCT
Current %gra generation in batchalign3 morphotag emits head=0.
Other TalkBank %gra Conventions
- Relation labels are uppercase, such as
NSUBJ,ADVMOD, andACL-RELCL. - Relation subtypes use dashes rather than UD colons, such as
ACL-RELCLandNMOD-POSS. %graand%morremain item-aligned: each%moritem has a corresponding%graitem.- The utterance terminator gets its own
PUNCTrelation whose head points to the root word.
For comparison, a UD-style rendering would use lowercase labels and colon subtypes:
%gra: 1|3|det 2|3|amod 3|0|root 4|6|nsubj 5|6|advmod 6|3|acl:relcl 7|3|punct
Parser Validation for Existing Data
When reading existing CHAT files, batchalign3 keeps %gra validation lenient
enough to ingest historical corpora that contain invalid dependency trees.
Current parser-side checks (all four codes are defined as
Severity::Error in
../chatter/crates/talkbank-model/src/errors/codes/error_code.rs:566-578 and
emitted from ../chatter/crates/talkbank-model/src/model/dependent_tier/gra/tier.rs):
E721(GraNonSequentialIndex): indices must be sequential (1..N)E722(GraNoRoot): noROOTrelationE723(GraMultipleRoots): multipleROOTrelationsE724(GraCircularDependency): circular dependency
The lenient parser still ingests files that trip these checks, the
errors are logged and the affected tier is left as parsed, so older
corpora stay processable even when %gra is malformed. The codes
themselves are not Severity::Warning; the leniency is a
caller-side policy at the pipeline entry, not a downgrading of the
error code.
Generator Validation for New %gra
When batchalign3 morphotag generates new %gra, validation is stricter. The
current implementation in
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs::build_gra_and_validate validates:
- sequential indices
- exactly one non-terminator root (
head=0orhead=self) - no dependency cycles
- no head references outside the utterance
If validation fails, generation returns Err(MappingError) and the caller logs
and skips the utterance rather than writing invalid %gra.
Current Write Contract
Current Rust %gra generation avoids the older positional-repair failure mode
by:
- mapping IDs explicitly rather than relying on brittle array-position repair
- rejecting invalid or unmappable structures before writeback
- validating root/head invariants before returning the tier
Current migration rationale for the head=0 write contract lives in the
migration docs; this page keeps only the current parser/generator behavior.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Forced Alignment Design
Status: Current Last updated: 2026-09-07 19:52 EDT
Overview
The align command adds word-level timestamps to CHAT files. Given a transcript
and an audio file, it determines exactly where each word appears in the recording.
flowchart TD
chat["CHAT file + audio"]
parse["Parse CHAT → AST"]
reuse{"Complete reusable\n%wor timing?"}
refresh["Refresh main-tier bullets\nfrom %wor + optionally\nregenerate %wor"]
partial{"Per-utterance\npartial %wor?"}
partial_refresh["Refresh clean utterances\nfrom %wor, track reusable set"]
count{"Untimed\nutterances?"}
utr["UTR pre-pass:\nrun_utr_pass() with ASR caching\n+ partial-window optimization"]
group["Group utterances\nby time window + token limit"]
extract["Extract words per group"]
cache{"Cache\nlookup"}
fa["execute_v2('fa', prepared_audio + prepared_text)\n→ Whisper/Wave2Vec"]
dp["DP alignment\n(Hirschberg O(n+m) space)\ntokens → transcript words"]
inject["Inject timings\n+ generate %wor tier"]
mono["Enforce document order\nstrip backward starts; resolve same-speaker\noverlap by speaker stream, not file adjacency"]
out["Serialize → CHAT"]
retry{"FA failed +\nuntimed?"}
fallback["Fallback UTR:\nrun_utr_pass() (once)"]
chat --> parse --> reuse
reuse -->|"yes"| refresh --> out
reuse -->|"no"| partial
partial -->|"some clean"| partial_refresh --> count
partial -->|"none"| count
count -->|"yes + utr_engine"| utr --> group
count -->|"no (all timed)"| group
count -->|"yes + no utr_engine"| group
group --> extract --> cache
cache -->|miss| fa --> dp --> inject
cache -->|hit| inject
inject --> mono --> out
fa -.->|"error"| retry
retry -->|"yes (first time)"| fallback --> group
retry -->|"no"| backoff["Retry with backoff"]
backoff --> extract
Prerequisites
Before alignment can begin, two steps must succeed:
- Media resolution: The server locates the audio file for the CHAT
file from server-visible local paths, either alongside it (paths mode),
through a shared
source_dir, via localmedia_mappings, or via an explicit--media-dir. See Media Conversion. - Media conversion: If the audio is in a container format that
soundfilecannot read (MP4, M4A, WebM, WMA), it is automatically converted to 16 kHz mono WAV via ffmpeg and cached at~/.batchalign3/media_cache/. See ensure_wav.
Both steps happen in the Rust server before any Python worker is invoked.
Execution flow and ownership
The most important architectural fact is that direct mode and explicit server mode share the same FA pipeline.
- Without
--server, the CLI runsalignthroughDirectHost. No HTTP server or daemon is spawned for that path. - With
--server,alignsubmits a shared-filesystempaths_modejob. The execution host must be able to read the submitted source path, resolve media from that host’s local filesystem view, and write the requested output path.
Both routes end up in process_one_fa_file() in
crates/batchalign/src/runner/dispatch/fa_pipeline.rs.
Single-parse architecture
The CHAT file is parsed once into a ChatFile AST. UTR mutates the AST in
place (no serialization). FA receives the same AST directly via
run_fa_from_ast() (no re-parse). The file is serialized once at the end.
flowchart TD
start["align request"]
host{"direct or\n--server?"}
direct["DirectHost\ninline execution"]
server["ServerBackend\nqueued paths_mode job"]
media["Resolve media on the\nexecution host"]
wav["ensure_wav()\ncontainer → cached WAV if needed"]
parse["Parse CHAT → ChatFile AST\n(single parse)"]
utr["UTR: mutate ChatFile in-place\n(no serialize)"]
fa["run_fa_from_ast(ChatFile)\nFA grouping + worker dispatch\n(no re-parse)"]
inject["Inject timings + %wor"]
finalize["FaFinalized\noptional repair, then typed monotonicity"]
serial["Serialize ChatFile → CHAT text\n(single serialize)"]
start --> host
host --> direct --> media
host --> server --> media
media --> wav --> parse --> utr --> fa --> inject --> finalize --> serial
Key functions:
run_utr_pass(&mut ChatFile, ...): mutates AST, returnsUtrResultrun_fa_from_ast(ChatFile, ...): accepts AST directly, returnsFaResultprocess_fa(&str, ...): parse-then-delegate wrapper for callers that only have text (transcribe, incremental)
Pipeline: UTR then FA
Alignment runs as a two-step pipeline:
- UTR (Utterance Timing Recovery): Assigns utterance-level timing boundaries.
- FA (Forced Alignment): Assigns word-level timing within those boundaries.
Step 0: Cheap reuse for already word-timed files
Before UTR or FA grouping, align now checks whether the parsed file already
contains a complete reusable %wor tier. This is the cheapest safe rerun path
for files that have already been aligned once and are being passed through
align again.
The important detail is that this check is not based only on
main-tier Word.inline_bullet. After a CHAT parse roundtrip, main-tier word
timing may be represented as InternalBullet tokens while %wor carries the
durable first-class timing bullets. The server therefore:
- verifies that every alignable main-tier word has a clean main↔
%worpositional mapping, - verifies that every mapped
%worword has a timing bullet, - copies those timings back onto main-tier words,
- removes parsed
InternalBullettokens, and - refreshes utterance bullets and optionally regenerates
%wor.
If that verifier succeeds, align skips FA entirely for the file.
When the whole-file check fails, a per-utterance check
(find_reusable_utterance_indices) identifies which utterances still have
clean %wor. Those are refreshed in place; the rest proceed through normal
FA grouping. During the group partition step, groups where all utterances
are in the reusable set have their timings collected directly from the
refreshed main tier (no cache lookup or worker call needed).
flowchart TD
start["Parse CHAT"]
wor{"Complete reusable\n%wor timing?"}
map["Verify main↔%wor mapping\nand collect word timings"]
rehydrate["Rehydrate main-tier word bullets\nfrom %wor"]
refresh["Refresh utterance bullets\n+ optionally regenerate %wor"]
partial{"Any utterances with\nclean %wor?"}
partial_refresh["Refresh clean utterances\ntrack reusable set"]
normal["Continue to UTR/FA pipeline\n(stale groups only need workers)"]
start --> wor
wor -->|"yes"| map --> rehydrate --> refresh
wor -->|"no"| partial
partial -->|"some"| partial_refresh --> normal
partial -->|"none"| normal
Step 1: UTR (detect-and-skip)
UTR mutates the ChatFile AST in place via run_utr_pass(&mut ChatFile, ...).
No serialization occurs, the same AST flows directly to FA. The orchestration
lives in crates/batchalign/src/runner/dispatch/utr.rs; the core injection
algorithm lives in crates/batchalign/src/chat_ops/fa/utr.rs.
Detection: count_utterance_timing() counts timed vs untimed utterances.
If all utterances are timed, UTR is skipped entirely (the common case for
production CHAT files from CLAN).
Recovery: When untimed utterances exist and a UTR engine is configured
(--utr, the default), the run_utr_pass() helper:
-
Checks the ASR cache for a prior result (key includes audio identity + lang). On hit, skips inference entirely, repeat runs are instant.
-
Chooses partial-window or full-file mode:
- Partial-window (when >50% timed and audio >60s):
find_untimed_windows()identifies time regions covering only the untimed utterances (with 500ms padding). Each window is extracted viaextract_audio_segment()(ffmpeg-ss/-to→ cached WAV) and ASR runs only on those segments. Token timestamps are offset by the window start time. Each segment’s result is cached independently. - Full-file (mostly-untimed or short audio): ASR runs on the full audio and the result is cached as a single entry.
- Partial-window (when >50% timed and audio >60s):
-
Converts ASR response tokens to
AsrTimingToken(text + start_ms + end_ms). -
Calls
inject_utr_timing(). That function first tries a cheap exact monotonic subsequence match for the whole document word list against the ASR word list. If that match is unique, timing assignment is linear-time and no DP is needed. If the match is missing or ambiguous, UTR falls back to a single global Hirschberg DP alignment of all document words (timed + untimed) against the projected ASR words, using case-insensitive exact matching for the default global strategy. Timed utterances participate in the alignment to anchor their neighbors but their bullets are left unchanged. For each untimed utterance, the min/max matched ASR token indices determine the utterance bullet’s time span. The global alignment avoids the token-exhaustion problem that per-utterance windowed approaches suffer from. It is still a monotonic aligner, so dense overlap / text-audio reordering remains a known limitation.Two-pass only:
--utr-fuzzy <threshold>configures the experimental--utr-strategy two-passstrategy (default 0.85; 1.0 for exact-only matching). It does not change the default global strategy. -
Re-serializes the CHAT with recovered bullets.
flowchart TD
words["Transcript words + ASR words"]
unique{"Unique exact\nmonotonic subsequence?"}
exact["Assign utterance token ranges\nwithout DP"]
dp["Run one global Hirschberg DP\nacross the whole file"]
bullets["Inject/recover\nutterance bullets"]
words --> unique
unique -->|"yes"| exact --> bullets
unique -->|"no"| dp --> bullets
UTR alignment evidence and offline replay
Provider tokens may contain whole phrases. UtrLexicalStream projects each
token into nonempty words separated by Unicode whitespace before global or
local overlap matching. It owns the relationship between those words and the
original token stream. Matches record the original token_index and the
word_index within its text; skipped blank tokens do not renumber later
provider tokens. This removes the mismatch in which transcript words were
compared against whole Whisper segments.
Projection preserves the provider’s interval for every word in a segment. It does not interpolate word timestamps, remove punctuation, or infer language segmentation where whitespace is absent. Two utterances matched within one segment can therefore receive the same coarse interval. Forced alignment must still establish word boundaries. Existing cached ASR responses remain reusable: the projection happens after reading the retained provider response, and no new inference is required to replay it.
The global alignment plan is a first-class typed value. It records the chosen strategy, participation policy, every utterance state, every monotone word-to-token match, its lexical relation, and the timing proposal derived from the first and last matched token. Deliberately excluded overlap lines are distinct from attempted but unmatched lines. A matched utterance has a nonempty match collection by construction. Positive and nonpositive proposals are distinct states.
UtrResult retains this plan even though production projection still changes
bullets only for untimed utterances. This distinction lets experiments inspect
what the same global alignment proposed for already timed neighbors without
claiming that BA3 changed those authoritative bullets.
For an offline replay over retained debug artifacts:
batchalign3 eval utr-alignment \
--chat recording_utr_input.cha \
--tokens recording_utr_tokens.json \
--fuzzy-threshold 0.85 \
--output recording_utr_alignment.json
The command performs no inference and does not mutate CHAT. It requires a
clean parse, fingerprints both inputs with BLAKE3, records the executable build
identity, and atomically publishes a complete report without overwriting an
existing output. Omit
--fuzzy-threshold for case-insensitive exact matching. Add
--participation exclude-marked-overlap to
replay the participation rule used by the first pass of two-pass UTR.
Offline report schema 2 adds the within-token word address and uses this lexical projection. The input token JSON format is unchanged. Older reports retain their original meaning and should be kept alongside a fresh replay.
The resulting proposal is evidence, not a final main-tier or %wor policy.
Complete lexical coverage does not by itself establish word-boundary accuracy,
and partial or unmatched states require an explicit abstention or fallback in
downstream research code.
Overlap Strategy Selection
When a CHAT file contains overlapping speech (+< linkers or ⌊ CA markers),
the standard global UTR alignment degrades because the monotonic matcher cannot
represent the temporal crossing of overlapping speakers.
Current default: auto always uses GlobalUtr. The experimental
two-pass strategy is available via --utr-strategy two-pass but is not
production-ready, it has not been validated on enough corpora and can
regress alignment on real files.
The two strategies are:
flowchart TD
start(["UTR alignment requested"])
scan{"Any utterance has\n+< linker or ⌊ CA\noverlap markers?"}
scan -->|No| global["GlobalUtr\n(crates/batchalign/src/chat_ops/fa/utr.rs)\nSingle monotonic DP\nover all words"]
scan -->|Yes| twopass["TwoPassOverlapUtr\n(crates/batchalign/src/chat_ops/fa/utr/two_pass.rs)"]
twopass --> density{"Overlap density\n> max_exclusion_density?"}
density -->|"Yes (>30%)"| global_anyway["Include +< utterances\nin global DP\n(too many to exclude)"]
density -->|"No (≤30%)"| pass1["Pass 1: Global DP\nexcluding +< utterances"]
pass1 --> pass2["Pass 2: Per-overlap recovery\nnarrow ASR window around\nprevious utterance's bullet"]
global --> result(["UtrResult"])
global_anyway --> result
pass2 --> result
Strategy selection (select_strategy() in crates/batchalign/src/chat_ops/fa/utr.rs):
| Strategy | When selected | Behavior |
|---|---|---|
GlobalUtr | No +< or ⌊ markers in file | Single monotonic DP over all words (original algorithm) |
TwoPassOverlapUtr | Any utterance has +< or ⌊ | Pass 1 excludes overlap utterances from global DP; Pass 2 recovers their timing from a narrow ASR window around the previous utterance |
The two-pass approach prevents overlap utterances from desynchronizing the
global alignment. Pass 2 uses CA markers (⌈⌉⌊⌋) when present to narrow the
search window further (±tight_buffer_ms around the CA onset position).
When overlap density exceeds max_exclusion_density (default 30%), excluding
overlaps would starve the global DP of context, so all utterances are included
in a single pass instead.
CLI flags for overlap control:
| Flag | Default | Effect |
|---|---|---|
--utr-strategy auto|global|two-pass | auto | Override automatic strategy selection |
--utr-ca-markers enabled|disabled | enabled | Whether Pass 2 uses CA markers for window narrowing |
--utr-density-threshold <0.0-1.0> | 0.30 | Overlap fraction above which two-pass falls back to global |
--utr-tight-buffer <ms> | 500 | Buffer around previous utterance for Pass 2 recovery window |
--utr-fuzzy <threshold> | 0.85 | Two-pass only: Jaro-Winkler similarity threshold |
The fuzzy threshold and overlap-density threshold are finite closed-interval
types. CLI and serialized configuration inputs outside 0.0 through 1.0
are rejected before alignment policy exists.
The two-pass defaults were tuned on SBCSAE, Jefferson NB, TaiwanHakka, and
APROCSA corpora but have not been broadly validated. Use --utr-strategy two-pass to opt in for experimentation.
%wor Suppression for CA Transcripts
When @Options: CA is set, align automatically suppresses %wor tier
generation. Conversation Analysis transcripts use prosodic notation
(⌈⌉⌊⌋, arrows, lengthening marks) that %wor cannot represent, so
generating it adds noise that CA researchers must manually remove. The
--nowor flag achieves the same effect for non-CA files.
End-Time Overlap Clamping
After alignment, enforce_monotonicity()’s Pass 2 resolves end overlap in
TWO sweeps, in this order (2026-09-01 review, item 15):
- Same-speaker, by SPEAKER STREAM, unconditionally. Each speaker’s own
bulleted utterances, in file order, are paired and resolved
CONSECUTIVELY WITHIN THAT SPEAKER’S OWN STREAM – an intervening
other-speaker utterance is skipped, never breaks the pairing. This runs
regardless of
--end-overlap-policy: E704 (CLAN 133, a speaker may not overlap themself) is defined on the speaker’s own sequence, not on physical line adjacency, and an intervening line (ordinary A-B-A dialogue) must not hide a same-speaker overlap from resolution. The word “adjacent” does NOT describe this sweep’s pairing; only file position within one speaker’s own stream does. - Then, additionally under
--end-overlap-policy clamp-all-adjacentonly (the default ispreserve-cross-speaker), every PHYSICALLY adjacent pair regardless of speaker. This sweep cannot undo sweep 1: every resolution below only SHRINKS the pair it touches, so a pair sweep 1 already resolved satisfies sweep 2’s own entry guard (no overlap left) and is silently skipped, not re-clamped.
Either sweep classifies from MEASURED word timings, never guessed, into one
of three cases (coverage_only, boundary_from_words, interleaved_words;
see Monotonicity warnings below for the full
breakdown), and either sweep’s BoundaryFromWords guards against creating a
fresh FILE-ORDER start violation the SAME way, regardless of which sweep
formed the pair (2026-09-01 review, item 16; see below): the next BULLETED
utterance in file order, any speaker, not “whatever this sweep would pair
next with” – that narrower reading was itself the item-16 regression,
since sweep 1’s own next same-speaker utterance can sit well past an
intervening different-speaker line the move would actually violate. UTR
token-range assignment is one source of overlap, but a rerun
can also expose stale prior bullets, segmentation conflict, conversational
overlap, or fresh FA evidence that crosses the next main-tier boundary. The
decision records the neutral cause adjacent_utterance_overlap (a legacy
name predating sweep 1; it names the CAUSE category, not the pairing rule)
plus which of the three resolutions applied. Only interleaved_words (a
genuine word conflict) requests review; the other two never touch a measured
word and never need one. This is current behavior, not evidence that a
clamped boundary is acoustically correct.
A BoundaryFromWords resolution can also move the FOLLOWING utterance’s
start forward, to its own measured word hull. When doing so would push it
to or past the start of the next BULLETED utterance IN FILE ORDER, ANY
SPEAKER (2026-09-01 review, item 16 – not that speaker’s own next
utterance, corrected from an earlier, narrower guard), the resolution falls
back to interleaved_words for that pair instead, so the move never
happens and nothing is stripped. This is Pass 1’s own rule (file-order
start monotonicity across every speaker) applied to Pass 2’s own output:
Pass 1 already ran and cannot see a start Pass 2 is about to create, so
Pass 2 must not undo what Pass 1 established. orchestrate::file_order_successor_start_ms
is the ONE function both Pass 2 sweeps and repair’s own boundary-averaging
call for this (2026-09-01 review, item 16): it walks forward from the pair’s
next utterance, live, to the first utterance that CURRENTLY has a bullet,
regardless of speaker, so an earlier pair’s strip in the SAME sweep is
already reflected. Real-data regression this closed: chatter’s E362 fired
1,353 times across 178 files when a same-speaker BoundaryFromWords moved
a start past an intervening different-speaker line’s start, because the
per-speaker-stream sweep’s guard (item 15) checked only that speaker’s OWN
next utterance, missing the intervening line entirely.
Source: strategy selection in crates/batchalign/src/chat_ops/fa/utr.rs,
two-pass config and algorithm in crates/batchalign/src/chat_ops/fa/utr/two_pass.rs.
Current debugging hook: When $BATCHALIGN_DEBUG_DIR is set, UTR writes the
pre-injection CHAT and the ASR timing tokens that fed inject_utr_timing().
That is enough to reproduce token-starvation failures offline. It is not yet a
full stage-by-stage trace: normalized word lists, DP match pairs, unmatched
utterance reports, interpolation windows, and post-monotonicity stripping still
need richer tracing support.
Fallback UTR: When the initial UTR pre-pass fails (ASR error) or is skipped
(--no-utr) and FA subsequently fails with a retryable error, the retry handler
attempts UTR once before the next retry. This recovers files where bad
interpolated timing caused FA failure. The utr_fallback_attempted flag ensures
at most one extra ASR call across all retries.
No engine fallback: When no UTR engine is configured (--no-utr), untimed
utterances fall back to proportional interpolation (see
Proportional FA Estimation).
UTR injects utterance-level bullets only: it does not set word-level timing. FA handles word-level alignment after UTR provides the boundaries.
Step 2: FA
FA takes the utterance boundaries (from UTR or from the original CHAT) and groups them into segments. For each segment, it extracts the corresponding audio chunk and runs the FA model (Whisper cross-attention DTW or Wave2Vec CTC alignment) to get precise word-level timestamps.
The FA model returns timestamps relative to the chunk start (0-based). These
must be converted to absolute timestamps by adding the group’s audio_start_ms
offset before injection into the AST.
FA grouping strategy
Groups are formed by group_utterances() in
crates/batchalign/src/chat_ops/fa/grouping.rs. Each group maps
to one FA worker call.
Grouping is driven by two independent constraints, a group is flushed and a new
one started when either is exceeded:
| Constraint | Limit | Rationale |
|---|---|---|
Time window (max_group_ms) | Per engine: the max_group field of the selected engine’s row in FA_ENGINES | Caps audio segment length sent to the FA worker. Larger windows give the model more context but increase latency and memory, and the engines do not agree on the trade-off: the wav2vec family’s CTC target length grows with the window, so it takes a narrower one than Whisper. group_utterances() is never told which engine will align its groups; the caller reads the window off the run’s engine (FaParams::max_group_ms(), which is FaEngineName::max_group_ms(), which is that row). The numbers are deliberately not restated here: read the rows in crates/batchalign/src/types/engines.rs. |
Label-byte cap (MAX_GROUP_LABEL_BYTES = 448) | 448 UTF-8 bytes | Whisper’s CTC forced-alignment backend (ctc_loss / ctc_best_path) has a maximum label sequence length of exactly 448 TOKENS; exceeding it produces a hard ValueError: Labels' sequence length N cannot exceed the maximum allowed length of 448 tokens. The cap is applied to EVERY engine’s groups, not only Whisper’s, because grouping is never told which engine will align them; the tightest engine’s limit is therefore used for all. The unit is a UTF-8 BYTE, and that is a deliberately conservative proxy for the token limit: every token occupies at least one byte, so a byte count bounds the token count from above. A character count does not, and outside ASCII it is smaller, so counting characters would loosen the cap against the very limit it stands in for. |
The label-byte limit exists because the time window alone is insufficient. Dense
speech (fast talkers, long-word languages like Spanish) can accumulate hundreds
of words, and thus thousands of label bytes, well inside a single time window.
The first time this was observed in production was on the biling-data corpus
(DiazCollazos/09.cha, Spanish), where group 0 carried 2043 label bytes in
10.97 seconds, and DiazCollazos/01.cha group 14 carried 2523 in 14.56
seconds. Both crashed Whisper FA.
flowchart TD
utt(["Next utterance\n(with timing bullet or estimate)"])
extract["collect_fa_words()\nExtract words → count UTF-8 bytes\n(utt_bytes)"]
over_time{"(current duration +\nnew duration) > max_group_ms\nor time went backwards?"}
over_chars{"current_bytes +\nutt_bytes > 448?"}
flush["Flush current group\nReset: current_bytes = 0\nExtend audio window into gap"]
add["Add utterance words to group\ncurrent_bytes += utt_bytes"]
more{"More\nutterances?"}
final["Flush final group\n(extend into trailing audio)"]
utt --> extract --> over_time
over_time -->|"yes"| flush
over_time -->|"no"| over_chars
over_chars -->|"yes and group non-empty"| flush
over_chars -->|"no"| add
flush --> add
add --> more
more -->|"yes"| utt
more -->|"no"| final
Known limit, the cap bounds a MERGE and not every group: it is consulted
only where two utterances are joined, so a SINGLE utterance whose own labels
exceed 448 bytes is never split. The flush guard is skipped
(!current_words.is_empty() is false) and the utterance is sent as its own
group regardless. The worker may fail for that group, but the error is scoped
to that group alone; the alternative, silently dropping the utterance, would
corrupt injection by skewing the word-cursor alignment for every subsequent
group, and splitting one utterance would mean splitting its audio window at a
position grouping cannot justify. So “no group exceeds the cap” is not what
this code guarantees.
Byte count vs. actual tokens: The 448-byte limit is a conservative proxy for a limit stated in tokens, and conservative in the safe direction. Every token of these tokenizers covers at least one UTF-8 byte, so staying under the byte cap guarantees staying under the token limit, for every script. The cost is over-splitting non-Latin text, roughly threefold for Devanagari, CJK and most Indic scripts and twofold for Cyrillic and Greek: more, smaller groups still align correctly, where an oversized group is a hard engine failure. Tightening this means asking the engine for its real tokenizer, not swapping the proxy for a looser one.
Implementation note, why the word count is computed before the split
decision: The split must know the new utterance’s byte count before deciding
whether to flush, so collect_fa_words() is called at the top of the loop
(before the flush check) and the words are held in extracted until after the
flush decision. This avoids calling collect_fa_words() twice.
Source: crates/batchalign/src/chat_ops/fa/grouping.rs,
constant MAX_GROUP_LABEL_BYTES and the LabelBytes newtype that is the only
way to produce a count comparable against it.
Failure points, recovery, and what the user sees
align has two distinct error scopes: group-level failures (affect one audio
window; other groups continue) and file-level failures (abort the whole
file). The diagrams below show every fallback path.
Full fallback map
The outermost loop is the file-level retry loop in
fa_pipeline.rs:process_one_fa_file(). The inner loop is the per-group
dispatch in fa/transport.rs:infer_groups_v2(). These two loops share one
entry arrow in the diagram: run_fa_from_ast() / process_fa_incremental().
flowchart TD
start(["process_one_fa_file()\nfa_pipeline.rs"])
media["Resolve audio\n(5-tier search)"]
parse["Parse CHAT → ChatFile AST\n(single parse, fa/mod.rs)"]
utr_check{"Untimed\nutterances?"}
utr_run["UTR pre-pass\nrun_utr_pass()\n(crates/batchalign/src/chat_ops/fa/utr.rs)"]
utr_warn["WARN: untimed utterances,\nno UTR engine, proportional\ninterpolation only"]
incremental{"--before PATH\nprovided?"}
fa_full["run_fa_from_ast()\n(fa/mod.rs)"]
fa_inc["process_fa_incremental()\n(fa/incremental.rs)"]
fa_groups["Group utterances\n+ per-group dispatch\n(fa/transport.rs)"]
fa_ok{"All groups\nresolved?"}
finalize["FaFinalized\noptional bullet repair first"]
mono["enforce_monotonicity_with_policy()\nstrip non-monotonic starts\nclamp ends per typed policy\n(chat_ops/fa/orchestrate.rs)"]
mono_warn["WARN monotonicity:\nend_clamped / start_stripped\nfor affected utterances"]
post_val["Post-validation\n(warn-only, never fatal)"]
out["Serialize → CHAT\nwrite output"]
fa_err{"Error type?"}
utr_fallback{"Retryable +\nuntimed +\nfallback UTR\nnot yet tried?"}
utr_fb["Fallback UTR:\nrun_utr_pass() once\nthen retry FA"]
backoff["Retry with\nexponential backoff"]
terminal["Terminal file error\nwith real message"]
start --> media --> parse --> utr_check
utr_check -->|"all timed"| incremental
utr_check -->|"untimed + engine"| utr_run --> incremental
utr_check -->|"untimed + no engine"| utr_warn --> incremental
incremental -->|"yes"| fa_inc --> fa_groups
incremental -->|"no"| fa_full --> fa_groups
fa_groups --> fa_ok
fa_ok -->|"yes"| finalize --> mono
mono --> mono_warn -.->|"(warn only)"| post_val --> out
fa_ok -->|"no"| fa_err
fa_err -->|"retryable worker error"| utr_fallback
utr_fallback -->|"yes"| utr_fb --> fa_groups
utr_fallback -->|"no"| backoff --> fa_groups
fa_err -->|"non-retryable\nor retry budget exhausted"| terminal
Source files:
crates/batchalign/src/runner/dispatch/fa_pipeline.rs,crates/batchalign/src/fa/mod.rs,crates/batchalign/src/fa/transport.rs,crates/batchalign/src/chat_ops/fa/orchestrate.rs(enforce_monotonicity, strip_e704_same_speaker_overlaps)
Per-group fallback detail
Each FA group goes through its own dispatch in infer_groups_v2().
Group-level failures either resolve silently (leaving words unaligned) or
propagate upward as a file-level failure.
flowchart TD
group(["FA group\n(audio window + words)"])
wor{"Reusable corroborated\n%wor timing?"}
raw{"Raw FA evidence\nadmitted?"}
replay["Replay raw response through\ncurrent Rust projection"]
derived{"Derived timing vector\nadmitted?"}
cached_timings["Reuse derived timings\n(compatibility fallback)"]
policy{"Cache policy permits\ninference?"}
required_fail["RequireCache failure\n(no dispatch authority)"]
build["build_forced_alignment_request_v2()\n(worker/request_builder_v2.rs)"]
empty{"EmptyAudioSegment?\n(0 PCM frames after ffmpeg)"}
skip_empty["WARN: group decoded no audio samples\nLeave words unaligned\n→ continue to next group"]
dispatch["dispatch_execute_v2()\n→ Python worker"]
parse{"parse_group_response()\nparse_forced_alignment_result_v2()"}
ok["Group timings resolved"]
err_kind{"Error kind?\n(is_fa_runtime_failure,\nfa_group_retry,\nis_whisper_model_unavailable)"}
ctc["Wave2Vec CTC fallback\n(see diagram below)"]
model_unavail["ModelUnavailable: \ncapability gap\nLeave words unaligned\n+ WARN in server log"]
runtime_fail["RuntimeFailure: \ndata-driven model error\nLeave words unaligned\n+ WARN in server log"]
other["Other error: \ninfrastructure failure\n→ propagate to file loop"]
group --> wor
wor -->|"yes"| ok
wor -->|"no"| raw
raw -->|"yes"| replay --> ok
raw -->|"absent/refused"| derived
derived -->|"yes"| cached_timings --> ok
derived -->|"no"| policy
policy -->|"UseCache/SkipCache"| build --> empty
policy -->|"RequireCache"| required_fail
empty -->|"yes"| skip_empty
empty -->|"no"| dispatch --> parse
parse -->|"OK"| ok
parse -->|"error"| err_kind
err_kind -->|"Wave2Vec engine\n+ CTC overflow"| ctc
err_kind -->|"ModelUnavailable:\nno whisper FA host"| model_unavail
err_kind -->|"RuntimeFailure:\nmodel exception"| runtime_fail
err_kind -->|"other"| other
Wave2Vec → Whisper CTC fallback
When the FA engine is Wave2Vec and the worker returns one of three specific
PyTorch CTC errors, infer_groups_v2 retries that single group with Whisper
FA. No other error triggers this retry.
flowchart TD
w2v["Wave2Vec worker response\n(parse_group_response fails)"]
reason{"fa_group_retry()\n(fa/transport.rs)"}
w2v --> reason
r1["Reason: targets length\nis too long for CTC\n, group has too many words\nfor CTC context window"]
r2["Reason: targets Tensor\nshouldn't contain blank index\n, word contains char that\nmaps to CTC blank token"]
r3["Reason: Kernel size\ncan't be greater than\nactual input size\n, audio segment < ~25ms\n(Wave2Vec conv layer 7\nneeds ≥ 2 samples after\n6 conv layers at 16 kHz)"]
none["None, no fallback:\nerror propagates to\nfile-level handler"]
reason -->|"'targets length is too long\nfor CTC'"| r1
reason -->|"'targets Tensor shouldn't\ncontain blank index'"| r2
reason -->|"'Kernel size can't be greater\nthan actual input size'"| r3
reason -->|"any other error"| none
whisper["Retry group with\nFaEngineName::Whisper\n(new request namespace\nto avoid artifact collision)"]
fallback_ok["Group timings resolved\n+ FaFallbackEventTrace\nrecorded in job traces"]
fallback_err{"Whisper response OK?"}
model_unavail_err["ModelUnavailable:\nno Whisper model loaded: \nleave words unaligned\n+ WARN in server log\n(is_whisper_model_unavailable())"]
runtime_fail_err["RuntimeFailure:\nmodel exception on this input, \nleave words unaligned\n+ WARN in server log\n(is_fa_runtime_failure())"]
other_whisper_err["Other error\n(infrastructure failure)\n→ file-level failure"]
r1 --> whisper
r2 --> whisper
r3 --> whisper
whisper --> fallback_err
fallback_err -->|"yes"| fallback_ok
fallback_err -->|"ModelUnavailable"| model_unavail_err
fallback_err -->|"RuntimeFailure"| runtime_fail_err
fallback_err -->|"other error"| other_whisper_err
The WARN log line for a successful fallback:
WARN fa_transport: FA engine hit a recoverable target constraint; retrying group on its fallback engine
group=24 start_ms=379515 end_ms=381395 reason="targets length is too long for CTC"
failed_engine="wav2vec" retry_engine="whisper"
A successful fallback appends a FaFallbackEventTrace to the job’s trace
payload. Inspect it with:
curl http://127.0.0.1:8001/jobs/JOB_ID/traces | python3 -m json.tool
Whisper fallback when worker has no Whisper model
The Python worker loads exactly one FA model at startup, controlled by
engine_overrides["fa"] in the worker bootstrap. When Wave2Vec is the primary
engine, whisper_fa_model in _WorkerState is None. If Wave2Vec hits a CTC
overflow and infer_groups_v2 dispatches the Whisper fallback, the worker
returns ExecuteOutcomeV2::Error { code: ModelUnavailable }.
is_whisper_model_unavailable() in crates/batchalign/src/fa/transport.rs
detects the distinctive "ModelUnavailable: no whisper FA host loaded" substring
and treats this as a worker capability gap, not a data error. The group’s
words are left unaligned (same treatment as an empty audio segment) and the
server logs:
WARN fa_transport: Whisper FA unavailable (worker has no Whisper model loaded);
leaving group words unaligned
group=24 start_ms=379515 end_ms=381395
The file is written with all other groups aligned normally.
To diagnose whether a worker has Whisper loaded, check the health endpoint:
if loaded_pipelines contains only profile:gpu:eng, Whisper FA is not
available and CTC-overflow groups will be left unaligned.
When Wave2Vec is the primary engine and CTC overflow occurs, the affected
utterances lose word-level timing. Use the default Wave2Vec FA engine
(--fa-engine whisper or omit --fa-engine) to avoid this entirely.
RuntimeFailure errors are always group-local
Any RuntimeFailure from the FA worker, regardless of the specific Python
exception, is treated as a group-level skip. This is an architectural
invariant, not a special case for known patterns.
Why: A RuntimeFailure means the Python worker successfully received and
parsed the request, then the model raised an exception while processing
this group’s specific words and audio. The failure is data-driven. Other
groups have different content; they will not trigger the same exception. There
is no value in aborting the file, the remaining groups can and should be
aligned normally.
Contrast: Infrastructure failures (ProcessExited = worker crash,
Protocol = IPC deserialization failure) indicate that the worker or channel
is broken. Every subsequent call would also fail. Those errors remain
file-level so the retry loop and fallback UTR path can attempt recovery.
Detection: is_fa_runtime_failure() in transport.rs matches any
ServerError::Validation message containing "RuntimeFailure:". This
substring is inserted by parse_forced_alignment_result_v2() when formatting
a ProtocolErrorCodeV2::RuntimeFailure response. It does not appear in
ModelUnavailable, Protocol, or IPC parse errors.
Ordering in infer_groups_v2():
fa_group_retry()is checked first, Wave2Vec CTC patterns still trigger the Whisper retry (which may produce timings).is_fa_runtime_failureis only reached when the fallback logic has already decided no retry is possible.is_whisper_model_unavailable()is checked beforeis_fa_runtime_failurein the Whisper fallback path, the capability-gap path emits a more specific warning message.
Log lines:
WARN fa_transport: FA group failed with model RuntimeFailure (data-driven);
leaving words unaligned
group=0 start_ms=0 end_ms=10970 error="..."
WARN fa_transport: Whisper FA fallback also failed with model RuntimeFailure;
leaving group words unaligned
group=7 start_ms=100000 end_ms=111000 error="..."
Summary: all recovery behaviors for a single group
| Condition | Scope | Outcome | Log |
|---|---|---|---|
| FA cache hit | Group | Reuse cached timings silently | , |
| Audio extraction produces no frames | Group | Leave words unaligned; continue | WARN: group decoded no audio samples |
| Wave2Vec CTC target overflow (3 patterns) | Group | Retry on the row’s fallback engine; record fallback trace | WARN: retrying group on its fallback engine |
| Whisper retry succeeds | Group | Group timings resolved | , |
Fallback retry: ModelUnavailable (worker has no such model) | Group | Leave words unaligned; continue | WARN: fallback FA engine unavailable … leaving group words unaligned |
Worker RuntimeFailure (any model exception: token overflow, shape error, OOM, etc.) | Group | Leave words unaligned; continue, is_fa_runtime_failure() demotes to group-level | WARN: FA group failed with model RuntimeFailure |
Whisper fallback also hits RuntimeFailure | Group | Leave words unaligned; continue | WARN: Whisper FA fallback also failed with model RuntimeFailure |
| Other worker error (retryable) | File | Retry with backoff; fallback UTR if untimed | WARN: FA error (raw) |
| Retry budget exhausted | File | Terminal failure with real error message | , |
Monotonicity warnings
After all groups are resolved, monotonicity enforcement makes Pass 1
(strip backward starts) then Pass 2, itself two sweeps (2026-09-01 review,
item 15): sweep 2a resolves same-speaker end overlap by SPEAKER STREAM
(unconditionally, any --end-overlap-policy, skipping intervening
other-speaker lines rather than requiring physical adjacency); sweep 2b
additionally resolves every physically adjacent pair, regardless of speaker,
under --end-overlap-policy clamp-all-adjacent only (the default,
preserve-cross-speaker, runs sweep 2b too, but it then skips every
cross-speaker pair and finds every same-speaker pair already resolved by
sweep 2a). See End-Time Overlap Clamping above
for the full account and the proof sweep 2b cannot undo sweep 2a. Each
stripping or resolving decision is recorded in structured evidence and emits
a WARN log line. BA3 does not project these records into %xalign or
%xrev (see Decision evidence). The
decision strategies have different severity and review priority:
| Decision | Cause | Needs review? | Action needed? |
|---|---|---|---|
end_clamped_coverage_only | Only the bullet’s inherited coverage overshot the next utterance’s start; no measured word conflicted | No | Informational; automatic |
end_clamped_boundary_from_words | Both bullets’ inherited boundary replaced by their measured word hulls; the words never conflicted | No | Informational; automatic |
end_clamped_interleaved_words | The words themselves interleave, or the next utterance has none: a genuine conflict | Yes | Adjudicate; the bullet and every affected word were clamped together |
start_stripped | Utterance start precedes previous accepted start, full timing removed | Yes | Review utterance; may indicate transcript/audio reordering |
The three end_clamped_* strategies are classified from what is MEASURED
(word timings), never from the bullet’s own extent, since the bullet at this
point may already be wider than its words (update_utterance_bullet’s
Preserve policy unions a bullet with prior coverage and never shrinks it).
Each strategy corresponds one-for-one to a MonotonicityEffect::EndClamped*
variant in structured evidence (fa/orchestrate.rs), and each variant embeds
one OverlapEdge (the two utterances involved: line index, ordinal, speaker,
on both sides) rather than repeating those seven fields per variant:
end_clamped_coverage_only: the previous utterance’s last measured word already ends at or before the next utterance’s start (or the utterance has no measured words at all). Only the bullet’s inherited coverage overshot; the bullet end moves to the word hull (or, with no words, to the next start directly). Words are untouched.end_clamped_boundary_from_words: the previous utterance’s last measured word ends after the next start, but the two utterances’ words do not interleave (the next utterance’s first measured word starts at or after that end). The boundary is itself measurable, so BOTH bullets take their word-hull edges instead of the arbitrary next-start clamp; the far side of each bullet is untouched. Words are untouched. This resolution is refused, falling back toend_clamped_interleaved_wordsinstead, when moving the next utterance’s start to its measured hull would reach or pass the start of the next BULLETED utterance in FILE ORDER, ANY SPEAKER (2026-09-01 review, item 16): that would undo Pass 1’s own file-order start-monotonicity guarantee, which already ran and cannot see a start Pass 2 is about to create, so the move never happens and nothing is stripped.end_clamped_interleaved_words: the two utterances’ measured words genuinely overlap in time, or the next utterance has no measured word to fix a boundary against (or the boundary-from-words move above was refused). A real conflict between segmentation and FA: the bullet is clamped to the next start AND every previous-utterance word past that bound is clamped with it (through the sameclamped_to_bulletroute postprocessing uses, so the clamp is a real, invariant-checked cut rather than a second hand-rolled.min()), flagged for review.
WARN monotonicity: strategy="end_clamped_interleaved_words" speaker=PAR line_idx=59
reason="end_truncated_by=2160ms clamped_to=136005
cuts_word_timing=true resolution=interleaved_words
words_trimmed=1 words_dropped=0 cause=adjacent_utterance_overlap"
WARN monotonicity: strategy="start_stripped" speaker=INV line_idx=23
reason="start_before_previous=130000 previous_start=131500"
words_trimmed and words_dropped are two different facts, not one count: a
trimmed word kept a shorter positive extent and still has a timing; a dropped
word’s start was already at or past the bound, so nothing survives the cut
and a MEASURED (or transcript-carried) timing is thrown away. The prior
single words_clamped count could not tell these apart, so a caller reading
the log could not tell whether a review was looking at a word that merely
got shorter or one that lost its timing outright. Both are carried the same
way through MonotonicityEffect::EndClampedInterleavedWords and its
FaTimingDecisionTrace wire form, and the dropped side is not a count at all:
it is one record per word, carrying the extent that was lost.
Those discarded extents are also written to the run’s evidence artifact as a
flat dropped_word_timings section, so a reviewer can recover every
measurement this pass threw away without walking the tagged effect union. Each
entry names the line and utterance, the speaker, the tier, the word’s position
on that tier, the measured start and end, and the bound it exceeded. See
Decision provenance.
A note on why the words are cut rather than kept and marked: the natural
alternative is to preserve the timing and annotate the overlap. No typed CHAT
construct expresses that here. The discarded timings live on %wor, which is a
flat list of words and separators that carries no annotations at all; and
CHAT’s overlap annotations [<] / [>] assert simultaneous speech by two
DIFFERENT speakers, while under the default policy this resolution only fires
on a pair sharing one speaker code, where a speaker overlapping themself is
exactly what the validator’s speaker-self-overlap rule rejects. An untimed
%wor slot honestly says the moment is unknown; the evidence artifact is where
the measurement survives.
Neither end_clamped_coverage_only nor end_clamped_boundary_from_words
records identify an alignment defect. They are routine: the bullet’s
inherited coverage was reconciled against the words that were actually
measured, and no word timing changed (end_clamped_boundary_from_words may
move a word’s CONTAINER, i.e. the bullet, without moving the word itself).
end_clamped_interleaved_words is different: segmentation and word timing
genuinely disagree, and the output no longer contains every admitted word
interval on the previous utterance. BA3 keeps
that distinction in the decision reason and needs_review; the FA evidence
sidecar retains the original and clamped ends for controlled listening rather
than attributing every case to UTR. %wor, when written, is ALWAYS
regenerated after monotonicity resolves, never before, on every path: fresh
alignment (apply_fa_results_with_projection_policy), the all-reusable fast
path (refresh_reusable_alignment +
projection_without_injection_with_touched), and per-utterance partial reuse
(refresh_reusable_utterances, folded into the fresh-injection write phase
via FaApplied::also_touched). add_wor_tier is pub(crate), and in a
PRODUCTION build it has exactly one caller, that one write phase
(FaApplied::then_enforce_monotonicity); its only other callers are
#[cfg(test)] (2026-09-01 review, item 12), so a word clamped by
end_clamped_interleaved_words can never be left stale on %wor while the
main tier (or vice versa) shows the uncut value, on any of those paths. The
write phase also does not trust its own candidate list: it re-derives, from
each utterance’s CURRENT words, whether there is anything timed to write at
all, so an utterance that lost every word to a clamp or a strip (Pass 1,
Pass 2, or repair_bullets’s LIS removal) gets no %wor tier, never an
untimed one (2026-09-01 review, item 9). The defect this classification
replaces: the pass used to move only the bullet end back to the next
utterance’s start, after %wor had already been written, so the measured
words ended after their own bullet (in one 253-file corpus, 30,858 word slots
did) and, under the former any-speaker default, real cross-speaker overlap
was cut as well.
start_stripped is a genuine alignment concern, the utterance’s start
timestamp precedes the previous accepted start, which means time went backwards
in the file. This happens when text and audio order diverge (overlapping speech,
post-hoc transcript restructuring). The utterance’s timing is completely removed
and its structured decision record has needs_review=true.
These warnings are not errors: they indicate normal behavior on overlapping
or densely-annotated speech. High volumes of start_stripped warnings on a
file that previously aligned cleanly are a signal that the transcript was
restructured after the last alignment run.
See the Monotonicity Invariant section for full detail.
Post-processing
After FA injects raw word timings into the AST, three post-processing steps run in this order for each utterance:
flowchart TD
inject_words["inject_timings_for_utterance()\nSets word.inline_bullet on each\naligned word in the utterance"]
inject_words --> postprocess["postprocess_utterance_timings()\nChains word end times;\nclamps word timings to utterance bullet range"]
postprocess --> update["update_utterance_bullet()\nRecomputes utterance bullet\nfrom word inline_bullets"]
update --> source{"utterance bullet\nBulletSource?"}
source -->|"Utr\n(UTR provisional hint)"| overwrite["Overwrite: bullet = word span\nFA is authoritative.\nUTR estimate discarded."]
source -->|"Authoritative\n(hand-linked or FA-derived)"| union_op["Union: bullet = min(word.start, existing.start)\n ..max(word.end, existing.end)\nNever shrink, preserves\nfiller/gesture coverage"]
source -->|"None\n(untimed, UTR also failed)"| set_new["Set: bullet = word span"]
overwrite --> mark["Result marked Authoritative"]
union_op --> mark
set_new --> mark
mark --> wor["add_wor_tier()\nGenerates %wor dependent tier\nfrom word inline_bullets"]
Why BulletSource matters: utterance bullets can come from two very
different origins with opposite desired behaviors after FA:
-
Authoritative bullets (hand-linked by the researcher, or set by a previous FA run) may cover content that FA cannot align, a leading filler (
&-uh) that FA returnsNonefor, or a trailing gesture (&=laughs) that has no alignable word. Overwriting these bullets with the FA word span would silently shrink them and lose the hand-annotated timing context. The union ensures the bullet covers at least as much as before. -
UTR hint bullets (
BulletSource::Utr) are rough grouping estimates derived from ASR token streams. They are provisional, their purpose is to give FA a window to work in, not to define the final timing. Once FA produces precise per-word timings, the hint should be discarded and the bullet set directly from the FA word span. This is the self-healing property: valid FA word timings always produce a valid utterance bullet, independent of how accurate the UTR estimate was.
BulletSource is a non-serialized field (#[serde(skip)]), it never appears
in CHAT output and has no effect on the file format. The distinction lives only
in memory during the align pipeline.
Order matters: postprocess_utterance_timings runs first so it can use the
UTR bullet for bounding word end times. If update_utterance_bullet ran first,
it would recompute from raw onset-only timings (Whisper FA gives only start
times), producing a bullet too tight for proper end-time chaining.
Word end times for an onset-only engine
Whisper FA returns onset times only: each token has a start time and no end time. An end must therefore be derived, and the only end available is the next word’s onset, so the parser chains them: each word’s end = the next word’s start.
This is not a mode, and there is no alternative treatment. A word whose end
equals its own start has no duration, which is the absence of a timing rather
than a timing; a %wor tier of such words is the symptom, not a setting.
The last word of each utterance has no next word to chain to. In the
parser it takes a named fallback (LAST_WORD_FALLBACK_MS, 500 ms); in
post-processing its end is extended to the utterance bullet end (from UTR or
original CHAT) when one is available.
Word timing clamping policy
After end-time chaining, postprocess_utterance_timings() optionally clamps
word timings to the utterance bullet range. This clamping is intentionally
restricted because two very different sources can produce an utterance
bullet, with opposite implications for whether clamping is safe.
The two bullet sources:
-
BulletSource::Authoritative: the bullet was parsed from CHAT text. After a previous FA run (or after a researcher links audio manually in CLAN), the bullet covers the full speech span for that utterance. Clamping FA word timings to it is safe and desirable: it prevents a freshly-aligned word from wandering outside the known boundary. -
BulletSource::Utr: the bullet was injected by UTR at runtime (never serialized to disk). UTR’s estimates are rough grouping hints, not confirmed boundaries. A UTR bullet may be far narrower than the actual speech. Rev.AI, for example, can produce a 220 ms hint for a 3-second utterance when it only matched the first word. Clamping FA word timings to a UTR hint would discard every correctly-aligned word beyond the first.
The BulletSource persistence problem. BulletSource is a runtime-only
field, it is not serialized to CHAT text. When a file goes through the
transcribe + utseg pipeline, UTR injects utterance bullets, those bullets
are serialized to CHAT text, and then on the next align run they are parsed
back as BulletSource::Authoritative. Even though they originated from UTR
timestamps, there is no record of that fact after re-parsing.
After transcribe + utseg, ASR-derived bullets may be as narrow as the ASR
engine’s first-word match, a systematic property of Rev.AI and similar engines
that emit short tokens only for the words they are confident about. If align
clamped FA word timings to these narrow ASR-derived bullets, it would silently
drop timings for every word after the first, producing output that appeared
aligned but missed most of the utterance.
The %wor discriminator. The presence of a %wor tier distinguishes
first-time alignment (post-transcribe, no %wor) from re-alignment (previous
FA run, %wor present). %wor is generated only by FA, so its presence
guarantees:
- A previous FA run already aligned this utterance correctly, and
- The utterance bullet was set from FA word timings and is wide enough to cover the full speech span.
Clamping is therefore only applied when BOTH conditions hold:
bullet.source == BulletSource::Authoritative: not a runtime UTR hint, ANDutterance.wor_tier().is_some(): this is a re-alignment, not a first-time alignment.
When either condition fails, FA word timings are injected as-is. The
update_utterance_bullet() call that follows then overwrites the narrow bullet
with the actual FA word span, the self-healing property described above. This
means valid FA timings always produce a valid utterance bullet, regardless of how
accurate or narrow the prior estimate was.
Practical consequence. On the transcribe → align workflow (the most
common first-time alignment case), clamping is never applied. All FA word
timings are accepted. The utterance bullet is widened to cover them. On
subsequent align re-runs (e.g., after transcript editing), clamping is
applied using the now-authoritative FA-derived bullet, preventing edited words
from being placed outside the established audio window.
Source: crates/batchalign/src/chat_ops/fa/postprocess.rs:37,
postprocess_utterance_timings().
What is fast today vs later
Today the implemented fast paths are:
- skip FA entirely when
%woralready provides complete reusable word timing - per-utterance partial
%worreuse on plain reruns: when the whole-file check fails (e.g. a user edited a few utterances), detect which utterances still have clean%wor, refresh those, and only send stale FA groups through workers. Groups where all utterances are reusable are preserved without cache lookup or worker call. Remaining groups resolve through raw-evidence replay, a derived-timing compatibility fallback, or an authorized inference miss. - skip UTR when every utterance already has timing
- use a unique exact-subsequence UTR match when the transcript and ASR word streams line up without ambiguity
The next possible optimization is more selective escalation inside UTR: identify local divergence regions (“trouble windows”) and run global DP only there. That is not implemented yet. The current global-DP path remains the correctness baseline for difficult files.
Why Two Steps
FA models (both Whisper and Wave2Vec) work best on short audio segments. Feeding a 30-minute recording directly into the model produces poor alignment because the attention/emission matrix gets too large and diluted. Chunking is necessary.
To chunk correctly, you need to know where each utterance sits in the audio. That is what UTR provides. For already-timed input, the existing bullets serve the same purpose.
Untimed Utterance Handling
Four tiers of coverage for untimed utterances, in order of preference:
flowchart TD
start{Untimed\nutterances?} -->|No| done([All timed: skip UTR])
start -->|Yes| engine{UTR engine\nconfigured?}
engine -->|Yes| ratio{">50% timed\n+ audio >60s?"}
ratio -->|Yes| partial["Tier 1a: Partial-window ASR\nASR only on untimed regions\n(saves time on first run)"]
ratio -->|No| full["Tier 1b: Full-file ASR\nASR on entire audio"]
partial --> inject["inject_utr_timing()\n~100% coverage"]
full --> inject
engine -->|No| interp["Tier 2: Proportional interpolation\nEstimate from neighbors\n~96% coverage"]
inject -->|"unmatched > 0"| interp
inject -->|"all matched"| done2([Proceed to FA])
interp --> done2
done2 --> fa_fail{FA failed?}
fa_fail -->|Yes + untimed| fallback["Tier 3: Fallback UTR\nrun_utr_pass() once\nbefore next retry"]
fallback --> retry([Retry FA])
fa_fail -->|No| output([Output])
- UTR with partial-window (default, mostly-timed files): When >50% of utterances are timed and audio exceeds 60 seconds, ASR runs only on the untimed windows. Each window is extracted via ffmpeg and cached independently. First run saves time proportional to the untimed fraction; repeat runs hit cache.
- UTR full-file (default, mostly-untimed files): ASR runs on the full audio. The result is cached for instant repeat runs.
- Proportional interpolation: When UTR is disabled (
--no-utr) or when UTR’s ASR inference fails, the grouping algorithm interpolates untimed utterances between neighboring timed utterances by word count, with a 2-second buffer. Achieves ~96% coverage. - Fallback UTR: When FA fails with a retryable error and untimed utterances were not recovered, UTR is attempted once before the next retry. This can recover files where bad interpolated timing caused the FA failure.
- Skip: When
total_audio_msis unavailable and no timed neighbors exist, untimed utterances are excluded from FA grouping.
This is smarter than ba2’s skip logic: ba2 skipped UTR for the entire file if any utterance had timing (potentially missing untimed ones). ba3 skips UTR only if all utterances are timed, and when UTR can’t match a specific utterance, proportional interpolation provides a fallback.
Engine Selection
| Engine | Model | Response format | Languages | Default? |
|---|---|---|---|---|
wav2vec | MMS_FA CTC forced alignment | WordLevel (word text + start/end ms) | any | Yes |
whisper | Whisper large-v2 cross-attention DTW | TokenLevel (token text + onset seconds) | any | No |
cantonese | MMS_FA CTC forced alignment over jyutping | WordLevel | any (romanizes only for yue) | No |
qwen3_fa | Qwen/Qwen3-ForcedAligner-0.6B-hf | WordLevel | yue, zho, cmn, eng | No |
Select one with --fa-engine <name>. The names above are the canonical ones,
the spellings --help advertises. Each engine also answers to its historical
spellings (wav2vec_fa, wave2vec, whisper_fa, cantonese_fa,
wav2vec_canto, wav2vec_fa_canto); qwen3_fa additionally answers to
qwen3-fa and qwen3, which is a convenience for that one engine and not a
house style.
Every fact in that table is declared in one place, FA_ENGINES in
crates/batchalign/src/types/engines.rs, one row per engine. The set of
accepted spellings is derived from those rows rather than written out
separately, so it cannot fall behind them.
Two different mechanisms keep that declaration honest, and it is worth being precise about which does what:
- A new FIELD is a compile error in every row.
FaEngineSpecrequires every field and has noDefault, so nothing can be left blank. - A new VARIANT is a compile error in the pairing list. The
spec()lookup andFA_ENGINESare both generated by thefa_engine_table!macro from one line per engine, and the match it generates is exhaustive over the engine enum. A variant missing from that list does not compile, and a variant in it is necessarily in the table. - Three further properties are checked while the crate compiles, in a
constblock beside the table: no engine has two rows, no spelling is accepted by two rows (the resolver is first match, so a duplicate would silently belong to whichever row is listed earlier), and every fallback target is language-general.
All of them return chunk-relative timestamps. The Rust orchestration
(parse_fa_response) handles offset addition for every format.
qwen3_fa: the Qwen3 aligner on its own
qwen3_fa is the SAME model the Qwen3-ASR engine already loads to produce its
own word timestamps (Qwen/Qwen3-ForcedAligner-0.6B-hf, the forced-alignment
companion of the Qwen3-ASR family). Selecting it here runs that aligner against
a transcript you already have, instead of one the ASR just produced. Both paths
call one shared module,
batchalign/inference/qwen_forced_alignment.py, so there is no second copy of
the aligner call to drift.
It reports a word’s start AND its end, so its durations are measured rather
than derived from the next word’s onset (FaTimingResolution::WordIntervals).
Languages, and it is EVERY declared language, not just the primary. Only
the four ISO-639-3 codes above, because those are the codes
QWEN_LANG_LABELS maps to a label the model accepts. A file declaring any
other language is refused at admission, by name, with the engines that would
work listed in the message; it is never silently realigned with a different
engine.
Admission reads the WHOLE @Languages: header. The first code is the
transcript’s primary language, but the rest are not decoration: an utterance
switches to one with a [- deu] precode and a single word with word@s:deu,
and those words travel to the aligner in the same groups as the primary
language’s, under the same single language label. So @Languages: eng, deu
is refused for qwen3_fa exactly as @Languages: deu, eng is. Until
2026-09-07 admission read languages.first() only, which admitted the first
spelling and refused the second over identical content, and aligned every
German utterance under the English label. A declared entry that is not a
parseable ISO 639-3 code is also refused for this engine, because we cannot
show it is supported; the language-general engines ignore the header entirely
and are unaffected by either rule. That holds for the FIRST entry too: when it
will not parse, --lang supplies the primary but the entry itself stays in
the declaration and still has to be supportable, so @Languages: not-a-code, eng and @Languages: eng, not-a-code are refused alike. Resolving the header
against --lang is DeclaredLanguages::from_header, its only constructor, so
the align dispatch cannot resolve a primary that disagrees with the header it
validated.
The upstream checkpoint advertises a wider set (French, German, Italian,
Japanese, Korean, Portuguese, Russian, Spanish), and Japanese and Korean
additionally need optional tokenizer packages, so widening our map is a
measurement rather than a typo fix. The two lists that have to agree are
QWEN_LANG_LABELS (Python) and the language_support field of the engine’s
row in FA_ENGINES, crates/batchalign/src/types/engines.rs.
A word the engine declines to time is REPORTED. A qwen3_fa group can
come back with some words timed and others not: a CHAT word whose characters
the aligner’s tokenizer keeps none of owns no unit and so has no span. Those
words are counted as untimed_by_host on the FA warn line, beside the counts
of timings we rejected. They reach the output the same way a rejected timing
does, with no bullet, so without their own field an operator could not tell
“the engine declined three words” from “the engine timed everything”.
Tokenization, and the fold. The aligner segments the transcript itself and
does not negotiate: CJK characters individually, space-delimited words
otherwise, and every character its rule does not keep (it keeps letters,
digits, apostrophes and CJK) silently dropped WITHOUT ending the current
token. So the CHAT word 你好 is two aligner units, and black+bird is one
unit spelled blackbird, because cleaned_text() keeps + and non-filler
_ and the aligner keeps neither.
Its units and our CHAT words are therefore two different spaces, and the
engine aligns on ITS units and then FOLDS them back onto ours
(WordSegmentation.fold in batchalign/inference/qwen_forced_alignment.py).
A word’s timing is its first constituent unit’s start and its last one’s end.
The fold is exact rather than heuristic: each word is segmented with the
aligner’s own tokenizer, and the concatenation is checked against the whole
transcript’s segmentation, so a language whose tokenizer is not
word-decomposable (Japanese and Korean run a morphological analyser over the
whole string) is refused loudly instead of mis-attributing spans.
Measured on 05b_clip.wav (scripts/prove_qwen3_fa.py, 2026-09-07): the same
audio and words written as 26 single characters gives 26 units and 26 timings;
written as 11 multi-character words it gives the same 26 units folded onto 11
timings, with 咁搞笑 spanning 560-880 ms, exactly its three characters’
560-640, 640-720 and 720-880.
Until that fold existed the engine required the aligner’s segmentation to EQUAL
our word list, which is true only of a character-tokenized transcript. It
refused most real CJK, and it refused ordinary English containing + or _.
A word the fold cannot reach is UNTIMED, never padded. A CHAT word the
aligner’s tokenizer keeps no character of owns no unit, so it comes back with
no timing and a named reason (UntimedReason.NO_ALIGNABLE_CHARACTERS) rather
than borrowing a neighbour’s span. The rest of the group is timed normally.
The reason is typed on the Python QwenWordFold; the worker wire carries only
“no timing” for that word (indexed_timings[i] = None, which is the same slot
every other unaligned-word path uses), because
IndexedWordTimingResultV2 is shared by all four FA backends and the only
consumer downstream of it leaves an untimed word without a bullet whatever the
reason was. What crosses is an honest UNKNOWN, never a fabricated span.
No CTC fallback, and that is a decision. Two halves. The three CTC failures
that send a wave2vec group to Whisper (target length, blank index, window
shorter than the feature extractor) are unreachable for this model. And its own
characteristic failure, a word it can time no part of, is no longer a
group-level failure at all: the fold times the rest and reports that one word
untimed. Retrying the whole group on Whisper would replace measured Qwen3
timings with Whisper ones for every word that DID align, and the wire records
timings per word without recording which engine produced each, so the
substitution would be invisible afterwards. One untimed word is the smaller and
the honest loss. The FaFallbackPolicy::NoFallback row in
crates/batchalign/src/types/engines.rs states this, and fa_group_retry in
crates/batchalign/src/fa/transport.rs reads it.
Offset Handling
This is the most critical correctness invariant in the pipeline.
Every FA model receives an audio chunk extracted from position audio_start_ms
to audio_end_ms in the full recording. The model returns timestamps relative
to the chunk start (time 0 = audio_start_ms in the full recording).
Before injecting timestamps into the CHAT AST, the offset must be added:
- TokenLevel (Whisper):
absolute_ms = time_s * 1000 + audio_start_ms - WordLevel (Wave2Vec):
absolute_ms = start_ms + audio_start_ms
Failing to add the offset produces timestamps that are internally consistent (words are correctly spaced relative to each other) but placed at the wrong absolute position in the recording.
Caching
Two independent cache layers affect alignment:
UTR ASR cache
UTR ASR results are cached in the analysis cache (CacheTaskName::UtrAsr).
Two cache key schemes are used:
flowchart LR
subgraph full ["Full-file cache key"]
ff_input["BLAKE3(utr_asr_v2 | ASR provider | audio_identity | lang)"]
ff_input --> ff_entry["Single AsrResponse"]
end
subgraph partial ["Segment cache keys"]
seg_input["BLAKE3(utr_asr_segment_v2 | ASR provider | audio_identity | start_ms | end_ms | lang)"]
seg_input --> seg_entry["Per-window AsrResponse"]
end
Full-file keys are used for mostly-untimed files. Segment keys are used when partial-window mode activates (>50% timed, audio >60s). Full-file and segment entries are separate: a full-file entry does not guarantee a segment hit. Provider identity is mandatory in both key constructors. Old normalized entries without provider identity are retained on disk but are not automatically replayed; they cannot establish which ASR backend produced their tokens. Separately retained raw Rev evidence still has its own provider-aware request identity.
The current outer cache lookup also checks the FA worker’s engine-version partition. This conservative extra partition can cause an ASR miss when only FA changes; it is not an attestation of the loaded ASR model revision. Retain model provenance when comparing runs.
--override-media-cache bypasses lookups but still stores results for future use.
FA cache
FA keeps two per-group layers under the same semantic group key:
- an immutable worker-protocol response, preferred on reads and replayed through the current Rust timing projection, inside an envelope that proves the requested engine, selected-worker version, semantic key, and word count;
- a derived
WordTimingvector in a second versioned envelope, retained as a compatibility fallback when raw evidence is absent or refused. Historical bare vectors are no longer admitted because they do not identify the worker version or distinguish direct inference from fallback output.
The key is
BLAKE3("{audio identity}|{start}|{end}|{words}|{gap healing}|{engine}"), so a
different engine, a different audio window, or a different gap-healing policy
cannot collide. The --override-media-cache flag bypasses this cache.
Both caches use the same SQLite database (the analysis cache, see Filesystem Paths).
What invalidates alignment cache?
| Change | UTR cache | FA cache |
|---|---|---|
| Edit transcript text | Stays cached (audio unchanged) | Groups with changed words re-run |
| Re-record audio | Re-runs (audio identity changed) | Re-runs (audio identity changed) |
Change --fa-engine | May miss when the worker-version partition changes | Misses (engine is part of FA key) |
Change --lang | Re-runs (lang is part of UTR key) | Re-runs (lang is part of FA key) |
Change --utr-engine | Misses: ASR provider is part of the key | Reuse depends on resulting windows and words |
| Second run, nothing changed | Can reuse matching retained entries | Can reuse matching retained entries |
Audio identity is computed from the file’s canonical path, modification time, and size, not a content hash. Symlinks and alternate path spellings resolve to the same identity. If you move or rename the audio file, the cache will miss even if the content is identical. Conversely, overwriting a file in place with different content will miss only if the modification time or size changes (which the OS updates on write).
Why fallback traces are live-run evidence today
The admitted in-memory response records the requested engine, effective engine, and fallback reason together, so a successful Wave2Vec-to-Whisper fallback is fully observable in that run’s trace. It does not cross the cache boundary. The primary Wave request namespace contains the selected Wave worker version but not the effective Whisper model version; replaying it later would therefore claim more provenance than the stored facts prove.
Only direct, version-identified FA evidence can construct the replayable typestate used by both raw and derived cache writers. Fallback timings remain valid for the current output, but a later run repeats inference. This is a deliberate correctness tradeoff until the request identity can carry both the primary and effective fallback capabilities.
When you specifically need to reproduce a new live worker pass, bypass both FA cache layers for that run:
batchalign3 align \
--override-media-cache-tasks forced_alignment \
--debug-dir /tmp/ba-debug \
-o output/ \
file.cha
That forces a new FA inference pass and makes the fallback event observable in the trace payload if the condition is reproduced.
Diagnostics and trace inspection
align now has two useful diagnostic layers:
- Better terminal/server errors: per-group FA parse failures are surfaced with group index and audio window instead of being swallowed and only appearing later as a generic missing-timings failure.
- Optional trace capture:
--debug-dir PATHenablesdebug_traces, which stores FA timeline traces including fallback events.
flowchart TD
run["Run align with --debug-dir"]
mode{"direct or\nserver?"}
direct["DirectHost\nexports debug-traces.json\nin job staging dir"]
server["GET /jobs/{id}/traces\nreturns JobTraces JSON"]
fa_trace["fa_timeline"]
fallback["fallback_events[]\n(group_index, from_engine,\nto_engine, reason,\naudio_start_ms, audio_end_ms)"]
run --> mode
mode -->|direct| direct --> fa_trace
mode -->|server| server --> fa_trace
fa_trace --> fallback
For explicit server mode:
curl http://127.0.0.1:8001/jobs/JOB_ID/traces | python3 -m json.tool
For direct mode, the CLI prints stable debug handles after submission; the trace
payload is exported as debug-traces.json in the job staging directory.
This is especially useful for confirming:
- whether a file succeeded entirely from cache,
- whether a Wave2Vec group retried with Whisper,
- which group/audio window triggered the fallback,
- whether the final output was produced after fallback or after a plain cache hit.
Known Pitfalls
Empty audio windows
An FA window can decode to zero PCM frames even when ffmpeg exits successfully. A window outside the recording is one possible cause; a very short window inside the recording can also produce no frames. Empty output alone does not establish which cause applies.
extract_prepared_audio_segment_f32le checks frame_count == 0 and returns
PreparedArtifactErrorV2::EmptyAudioSegment. The transport maps that owned
window/path evidence to ServerError::EmptyFaAudioSegment, leaves the group’s
words unaligned, and continues with the next group. No empty descriptor is
sent to the model, where a convolution could fail on a zero-length tensor.
WARN fa_transport: FA group decoded no audio samples; leaving words unaligned
group=N start_ms=X end_ms=Y path=<audio file>
Inspect the reported window, actual recording duration and source timing before changing the transcript. Do not infer a truncated recording from this warning or shorten the recording as a workaround. Correct timing only when the source evidence supports it; the conservative output keeps words whose timing is unknown.
Implementation: crates/batchalign/src/worker/artifacts_v2.rs
(EmptyAudioSegment), request_builder_v2.rs (build-error mapping), and
fa/transport.rs (group skip in infer_groups_v2).
Whisper pipeline chunking
The HuggingFace Whisper pipeline processes long audio in 25-second chunks with
3-second overlap (chunk_length_s=25, stride_length_s=3). The pipeline is
responsible for converting chunk-relative timestamps to absolute. However, this
conversion has been unreliable in some configurations, notably when batch_size
is passed to the pipeline constructor. The batch_size parameter was removed
from the constructor to avoid this issue (inference uses batch_size=1
regardless).
Untimed input vs timed input
For timed input (production CHAT from CLAN): UTR is skipped, FA uses the existing bullets directly. This path is well-tested and reliable.
For untimed input (raw transcripts without timing): UTR must first discover where each utterance lives in the audio. This depends on Whisper ASR producing correct absolute timestamps, which depends on the HF pipeline’s chunking working correctly. This path is more fragile.
Warning reference
Selected current warnings emitted during align are listed below. A recovered
warning does not itself abort the file, but a later error can still prevent
completion. Read the final job status and structured decisions as well as logs.
| Log source | Message pattern | Meaning and response |
|---|---|---|
fa/transport.rs | FA group decoded no audio samples | Extraction returned zero frames. Inspect the window, duration and source timing; the group remains unaligned. |
fa/transport.rs | Worker process exited during FA group | This request lost its worker. The exit alone does not prove OOM or bad input; review process evidence and the unaligned group. |
fa/transport.rs | FA engine hit a recoverable target constraint; retrying group on its fallback engine | A recognized target constraint triggers the retry. failed_engine and retry_engine name both ends; the target comes from the failing engine’s row, never from a literal in the log call. |
fa/transport.rs | fallback FA engine unavailable … leaving group words unaligned | The retry worker lacks the loaded model named by retry_engine; review the group’s missing timing. |
fa/transport.rs | FA group failed with model RuntimeFailure / fallback FA engine also failed with model RuntimeFailure | The model returned a runtime failure and the group remains unaligned. Inspect the attached error; this category alone does not establish the cause. |
fa/mod.rs, fa/incremental.rs | FA cache batch lookup failed | A cache read failed. Inspect the attached error before changing storage; inference may continue through cache misses. |
runner/dispatch/fa_pipeline.rs | FA failed with untimed utterances; attempting fallback UTR | The pipeline attempts fallback UTR before retrying FA. |
runner/dispatch/fa_pipeline.rs | Fallback UTR recovered timing | Fallback UTR produced timing for the FA retry; this is not final alignment success. |
Timing-clamp and timing-removal decisions are structured review evidence.
Inspect their needs_review flags and affected words rather than relying on a
historical log-message spelling.
Comparison with BA2
Understanding what BA2 did is useful context for why the fallback machinery exists and why it is currently incomplete.
BA2 forced alignment
BA2 was a pure-Python pipeline. Forced alignment used separate, mutually
exclusive engine classes: WhisperFAEngine and Wave2VecFAEngine in
batchalign/pipelines/fa/. The dispatcher chose one engine at pipeline
creation time based on the user’s --engine flag. There was no fallback.
If the chosen engine failed on a group, the file failed.
| Aspect | BA2 (Jan 2026) | BA3 (current) |
|---|---|---|
| Default FA engine | Whisper (cross-attention DTW) | Wave2Vec (word-level start+end) |
| Wave2Vec available | Yes, via --engine wave2vec_fa | Yes, via --fa-engine wav2vec |
| Both models loaded simultaneously | No, one at a time by design | No, one at a time, same reason |
| Wave2Vec CTC overflow → fallback | None: file failed | Retry that group with Whisper |
| CTC overflow handling | User had to rerun with --engine whisper_fa | Automatic per-group retry |
| Silent file drop on CTC overflow | No, file errored visibly | No, affected groups leave words unaligned; file completes |
| Fallback telemetry | None | FaFallbackEventTrace in job traces |
| FA result caching | Per-file Python shelve | Per-group BLAKE3 SQLite cache |
What BA3 improved
The Wave2Vec CTC fallback (added March 2026) handles three real-world failure modes that BA2 users had to manually work around: groups with very long word sequences, groups with characters that map to the CTC blank token (typically hyphenated or cross-linguistic tokens), and groups shorter than ~25 ms that crash Wave2Vec’s convolutional feature extractor.
BA3’s per-group Whisper retry means a single difficult utterance no longer aborts an entire file, 90+% of a file that previously failed can now complete.
What BA3 regressed
The fallback implementation has a critical gap: it assumes Whisper is always
available as a fallback model, but the worker loads only one FA model at
startup. When Wave2Vec is the primary engine and CTC overflow occurs, the
Whisper fallback dispatch returns ModelUnavailable, which currently propagates
as a file-level failure. BA2 users who selected Wave2Vec would see an explicit
error; BA3 users see a silent file drop, a regression in observability.
is_whisper_model_unavailable() in fa/transport.rs provides the
graceful-degradation behavior BA2 had via its explicit-error path: the affected
utterances lose word-level timing, but the file completes.
Default engine and timing resolution
The two forced-alignment engines do not report the same thing, and the
difference decides what a %wor tier can express:
| engine | reports | word duration |
|---|---|---|
| Wave2Vec | word start AND end | measured by the engine |
| Whisper FA | token ONSETS only | derived from the next onset |
Because Whisper FA reports only when a word starts, a word’s end has to be
inferred from its neighbour. Selecting it as the alignment engine without that
inference in place yields zero-duration words: %wor entries whose start and
end are equal. Wave2Vec is therefore the default, and Whisper FA is an explicit
opt-in via --fa-engine whisper.
Aligning the same material through both engines shows the difference directly: Wave2Vec yields word durations in the 250 to 500 ms range conversational English occupies, while Whisper FA yields 0 ms for every word unless the onset-to-interval step runs.
The CTC fallback described above still catches genuine Wave2Vec refusals, which are rare: a merged group is capped at 448 label bytes, and MMS_FA offers one 20 ms frame per target, so reaching the ceiling takes more than 50 label bytes per second against the 12 to 15 that speech produces.
Design Rationale
We evaluated simpler alternatives:
- Single-pass full-audio FA: Would avoid the UTR dependency, but FA models degrade on long audio. Not viable for recordings over ~30 seconds.
- Lightweight boundary detection (VAD + text alignment): Would be cheaper than full Whisper ASR for UTR, but more complex to implement and less accurate. The current design works correctly for production data (which is already timed), and UTR only runs on the uncommon untimed case.
- Single Whisper pass for both timing and alignment: Would halve compute time, but Whisper ASR output has different characteristics than Whisper FA output (ASR may hallucinate or miss words; FA is constrained to the known transcript).
The current two-step design (UTR for boundaries, FA for word timing) is the standard approach in the field. It works correctly when both steps produce valid absolute timestamps.
Monotonicity Invariant
CHAT requires that utterance-level timing bullets increase monotonically through the file (E362). CLAN players seek into the audio by bullet position; a regression means the player would seek backwards, which is undefined behavior.
The fundamental limitation
The alignment engine is a monotonic matcher: it assumes that words appearing later in the transcript also appear later in the audio. This assumption holds for most CHAT files, but breaks down when the transcript’s text order diverges from the audio’s temporal order.
The most common cause is overlapping speech annotated with &* markers.
CHAT convention embeds one speaker’s words inside another’s utterance:
*PAR: I'm hoping to play here &*INV:yeah in a month or two .
In the audio, INV’s “yeah” occurs between PAR’s words. But in the text, INV’s word is interleaved into PAR’s utterance. When the monotonic matcher tries to align this against the ASR’s flat temporal word sequence, it cannot represent the crossing – INV’s word in the ASR sits between PAR’s words that are on the same text line. The matcher must either skip the interleaved word or lose sync with subsequent words.
When this happens across many utterances in a dense overlapping region, the matcher loses sync entirely and leaves whole blocks of utterances untimed.
What align does about it
UTR’s DP alignment preserves LCS ordering: if CHAT word at reference position i matches ASR word at payload position p, then reference position j > i can only match payload position q > p. When text and audio order diverge, some utterances get no UTR timing. FA’s proportional estimation can then assign a correct-but-earlier timestamp to those untimed utterances, breaking monotonicity.
Post-FA enforcement pass: After all utterances have been
force-aligned, enforce_monotonicity() (at
crates/batchalign/src/chat_ops/fa/orchestrate.rs:213) walks
utterances in text order, tracking the last accepted start
timestamp. Any utterance whose start precedes the previous accepted
start has its timing stripped entirely, utterance bullet, inline
word bullets, and the %wor tier are all removed (the structured
start_stripped decision records the event; see the
Monotonicity warnings table). The
utterance is left as plain untimed text, identical to how it would
look before alignment.
This is the conservative choice: no information is corrupted, only alignment coverage is reduced. The correctly-timed surrounding utterances retain their full word-level alignment.
Pre-serialization validation gate: As an additional safety net,
the post-validation walk at the end of
process_one_fa_file() (in
crates/batchalign/src/runner/dispatch/fa_pipeline.rs) checks the
full output against the talkbank-model validators (including E362
monotonicity) before serialization. The walk is warn-only, output
is always serialized so it can be inspected, but the warnings are
the early signal that the post-FA enforcement layer above missed
something.
Real-world impact
In testing on hand-edited APROCSA aphasia protocol files with dense overlapping
speech, alignment loss reached 36.5% of utterances (232 of 636), clustered in
large contiguous blocks of up to 25 consecutive untimed utterances. The
severity scales with the density of &* markers and the degree of
text/audio order divergence.
Files most affected:
- Aphasia protocols with frequent backchannels and completions from investigators and relatives
- Conversation analysis transcripts with dense turn overlap annotation
- Any file where a reviewer has restructured utterance order after ASR
Files least affected:
- Single-speaker or low-overlap recordings
- Transcripts in temporal order (most CLAN-produced files)
- Files re-run through
alignwithout structural editing
What users should do
See the troubleshooting guide for practical guidance on identifying and working around untimed utterances.
Roadmap
Two improvements are under consideration:
-
Backbone extraction: stripping
&*segments before UTR alignment and interpolating their timing afterward. This is cheap to implement and helps for moderate-overlap files, but does not solve the worst cases where transcript restructuring (not just&*markers) is the dominant divergence. -
Per-speaker UTR: running ASR per speaker channel and matching each speaker’s utterances independently. This is the correct solution for heavily restructured transcripts but requires diarization infrastructure and is more complex to implement.
See Overlap-Aware Alignment Improvements
for the design proposals and honest impact estimates. A complementary
unimplemented “trouble-window” approach would preserve existing
timing during re-runs on hand-edited files; today, that workflow is
covered by align --before (incremental alignment that reuses
unchanged utterances).
Known limitations
- FA cannot recover utterances under about 200 ms of audio. The Whisper
and Wave2Vec FA models need a minimum number of frames to produce reliable
timing, so an utterance narrower than that (a single short backchannel, a
brief interjection) is left untimed. The proportional-FA-estimation fallback
may still place an estimated bullet on it when
total_audio_msis available; otherwise it stays untimed. (Restored 2026-09-07: this limit was deleted as collateral of a warning-table cull and then existed nowhere in the book, which left a user whose brief interjections come back untimed with no documented cause.) - Empty audio windows leave words unaligned. A very short window or one
outside the recording can produce no PCM frames. The prepared-audio extractor
returns
EmptyAudioSegment, and the group is skipped with all-Nonetimings. Review the window and original recording before changing timing. Keep the words untimed when no source-supported correction is available. - Same-speaker overlap windows can lose timing. If two utterances
from the same speaker overlap (E704),
strip_e704_same_speaker_overlaps()drops the earlier one’s timing rather than emit a contradiction. The later utterance keeps its timing.
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Speaker Identity Evidence
Status: Current Last updated: 2026-09-09 13:24 EDT
Field-by-field reference for the <stem>_speaker_identity.json artifact
written by speaker-identify.
The user page covers when and why to run the command; this page is what a
consumer parses.
Schema version
provenance.schema_version is 2.
It is bumped when a reader that understood the previous version would misread this one. Adding an optional field is not a bump; changing what an existing field means is.
Version 2 added tracks, track_contrasts and provenance.permutation,
all required. A version-1 reader that ignored them would go on judging a
speaker code by the mean of its line scores, which is the reading the
track-level fields exist to replace, so the addition is a bump rather than
an optional field. A version-1 file has no track fields at all; a consumer
that reads both versions must type that absence rather than treat it as “no
tracks”.
Top level
| Field | Type | Meaning |
|---|---|---|
provenance | object | How this file was made. See below. |
utterances | array | One entry per utterance of the scored tiers, in transcript order. |
tracks | array | One entry per speaker code among those utterances, scored as ONE voice. See below. |
track_contrasts | array | One entry per enrolled voice: how far its best track stands out, with a permutation p-value. See below. |
provenance
Written unconditionally, into the artifact itself, at the moment it is made. Every number in the file depends on a choice somebody made, and a reader who cannot reconstruct those choices treats the file as unreproducible.
| Field | Type | Meaning |
|---|---|---|
schema_version | integer | See above. |
interpretation | string | The standing caveat that scores are agreement, not accuracy. Present because the file is what gets forwarded; the documentation is not. |
transcript | string | The transcript this run read, as named. |
media | string | The recording resolved beside it. |
prepared_sample_rate_hz | integer | Sample rate of the single decode every span indexed into. |
embedding_backend | string | pyannote. |
embedding_model_revision | string | pyannote-embedding:<40-hex Hub commit>, read from the packaged model manifest. |
embedding_dimension | integer | Width of every vector compared, as the worker reported it. |
embedding_minimum_frames | integer | The model’s own minimum span length, as the worker reported it. |
match_threshold | number | The threshold the caller stated. Taken from the policy that produced the verdicts, so the file cannot state one number and have been decided under another. |
tiers | array of string | Tiers scored. ["*"] means every tier. |
enrollments | array | Each { label, start_ms, end_ms }, in recording order. |
permutation | object | { seed, count } behind every track_contrasts p-value, so each is reproducible byte for byte. |
produced_by | string | Build identity of the batchalign3 that wrote this. |
embedding_dimension and embedding_minimum_frames are reported by the
worker, not constants on the Rust side. They are properties of the loaded
model file, so a constant here would go on agreeing with a model that had moved.
utterances[]
| Field | Type | Meaning |
|---|---|---|
utterance_index | integer | Zero-based, counting utterances of the scored tiers. |
line | integer | One-based line of the main tier in the transcript. |
speaker | string | The speaker code the transcript currently carries. |
start_ms, end_ms | integer | The bullet, absent entirely when the utterance has none. |
scores | array | { label, score } for every enrolled voice. Empty for an unscored utterance. |
verdict | object | Tagged on verdict. See below. |
scores carries every enrolled voice, not only the best one, so a consumer
choosing a different threshold never has to re-run inference.
score is a cosine similarity in [-1, 1]. It is refused at both ends of the
pipeline if it is not: a NaN, which the model returns for input it cannot
measure, cannot reach this file.
verdict
Internally tagged on verdict.
{ "verdict": "matches", "label": "INV", "score": 0.81 }
{ "verdict": "no_match", "best": { "labels": ["INV"], "score": 0.44 } }
{ "verdict": "no_match", "best": { "labels": ["CHI", "INV"], "score": 0.90 } }
{ "verdict": "unscored", "reason": "no_bullet" }
{ "verdict": "unscored", "reason": "no_comparable_embedding" }
{ "verdict": "unscored", "reason": "too_short_for_embedding", "frames": 400, "minimum_frames": 1680 }
matchescarries exactly onelabel. A match with two labels has no representation, which is how a tie is prevented from resolving to a speaker.no_match’sbest.labelsnormally holds one label. It holds more when the evidence ties, including when the tied score clears the threshold.unscored’sreasonis flattened alongsideverdict, with its own fields. The five reasons aretoo_short_for_embedding(withframes,minimum_frames),no_bullet,no_comparable_embedding,audio_missing(withstart_ms,end_ms,recording_ms) andoverlaps_enrollment(withlabel).no_comparable_embeddingmeans every attempted comparison was refused, so the system does not mislabel that state as missing timing.
An unscored utterance carries no score field at all, rather than a zero.
A zero similarity is a real measurement, and a file that used one to mean
“not measured” would be indistinguishable from one that measured zero.
tracks[]
Internally tagged on kind. A track is the speaker code the transcript
carries; it is a claim that these lines belong together, and this section
scores the claim as a voice.
{ "kind": "voiced", "track": "PAR0", "lines_embedded": 212, "lines_refused": 3,
"centroid": [0.031, -0.118, "..."], "scores": [{ "label": "INV", "score": 0.71 }] }
{ "kind": "unvoiced", "track": "CHI", "lines_refused": 5 }
voiced: at least one line embedded.centroidis the unit-normalized mean of the track’s unit line vectors,embedding_dimensionwide, written so a later cross-session question needs no re-run.scoresis the centroid’s cosine to every enrolled voice, the same shape as a line’s.lines_refusedcounts the track’s lines the run could not embed; they are counted, never imputed.unvoiced: every line of the track was refused. There is no voice to score and noscoresfield at all.
Per-line vectors are not written. At 256 floats per line they would add several megabytes per session for no consumer this artifact has.
track_contrasts[]
Internally tagged on kind, one entry per enrolled voice.
{ "kind": "tested", "label": "INV", "best": "PAR0", "runner_up": "PAR1",
"observed_margin": 0.42, "permutations": { "seed": 0, "count": 1000 },
"at_or_above": 0, "p_value": 0.000999 }
{ "kind": "one_track", "label": "INV", "track": "PAR0" }
{ "kind": "no_voiced_track", "label": "INV" }
tested: two or more voiced tracks.observed_marginis the best track’s centroid score minus the runner-up’s on the labelling the transcript carries. The null shuffles line-to-track membership with track sizes preserved, recomputes every centroid and the margin, andat_or_abovecounts the shuffles whose margin reached the observed one.p_valueis(at_or_above + 1) / (count + 1), so the observed labelling counts once and the value can never be zero. A tie with the observed margin counts, which is why a perfectly separable session at a thousand draws reports about0.001rather than exactly the floor.one_track: a single voiced track. No margin exists, so nothing is invented.no_voiced_track: nothing was embedded on any track.
The p-value answers “are the tracks distinguishable voices, and is one of
them this enrolled voice, beyond what random membership would give?”. It is
not the probability that best is the enrolled speaker, and like every
number here it is agreement under one model against a human-claimed span.
Consuming it
The transcript is unchanged, so a consumer that wants to re-tier reads this file, decides its own mapping from label to CHAT speaker code, and applies it. Two facts make that safe to automate:
lineandutterance_indexlocate the utterance without re-parsing anything ambiguous.- Nothing is omitted: every utterance of the scored tiers appears, so a count of entries equals a count of utterances.
Do not infer a decision from scores alone without reading provenance: the
same numbers under a different enrollment mean something different.
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Whisper Usage in Batchalign
Status: Current Last updated: 2026-09-15 12:12 EDT
Overview
Whisper is used in three distinct roles within batchalign:
- Transcription (ASR) – Converting audio to text via the
transcribecommand - Forced Alignment (FA) – Using Whisper’s encoder cross-attention for
word-level timestamp alignment via the
aligncommand - Utterance Timing Recovery (UTR) – Re-transcribing audio to improve
forced alignment quality, automatically added by the
aligncommand
Each role loads a separate model instance. In a full align pipeline, two
Whisper models may be loaded simultaneously (FA + UTR).
ASR engines
Rev.AI is the production default – the Whisper variants are local
alternatives for when a commercial API is not wanted. Two of them run in a
Python worker (whisper, whisper_hub) and one is Rust-native (whisper_rs,
whisper.cpp, run in-process).
Two further names, whisperx and whisper_oai, are accepted by
--asr-engine and not implemented: nothing in this workspace runs WhisperX
or the OpenAI Whisper API. Submitting either is refused at job submission,
with a message naming the engines that do work. Until 2026-09-07 engine
selection ended in a catch-all arm that mapped both onto stock local Whisper
without saying so, so a job asking for one of them ran a different engine and
recorded whisper in its provenance.
Rev.AI (default)
batchalign3 transcribe input/ output/ --lang=eng
- Rev.AI is no longer implemented in
inference/asr.py - Uses the Rev.AI commercial HTTP API through the Rust native client
(
crates/batchalign/src/revai/, wired fromcrates/batchalign/src/revai/) - Supports speaker diarization natively
- Requires an API key (
batchalign3 setupor~/.batchalign.ini) - No local model loading, no GPU needed
Not implemented: whisper_oai and whisperx
# Both of these are refused, and say so:
batchalign3 transcribe input/ -o output/ --asr-engine whisper-oai --lang=eng
batchalign3 transcribe input/ -o output/ --asr-engine whisperx --lang=eng
whisper_oai(historical spellingwhisper-oai) names the OpenAI Whisper API;whisperxnames the WhisperX library. Neither has an implementation anywhere in this workspace: no worker engine, no Rust backend, no dependency.- Both remain accepted NAMES so that stored jobs and the hidden BA2 aliases
(
--whisperx,--whisper-oai) still parse and can be refused with an accurate message, rather than failing as an unknown name. - Selection is a total match over the engine enum with no catch-all arm, so adding a variant without an implementation fails to compile. The refusal lists the engines that do work, derived from that same match.
- Use
whisper(HuggingFace, below),whisper_hub(a per-language fine-tune) orwhisper_rs(Rust-native, in-process) instead. The default remains--asr-engine revwhen no ASR override is given AND no per-language default applies (see “Per-language defaults” below).
HuggingFace Whisper (--asr-engine whisper)
batchalign3 transcribe input/ -o output/ --asr-engine whisper --lang=eng
- HuggingFace Whisper engine in
inference/asr.py(_infer_whisper()) - Uses HuggingFace
transformers.pipeline("automatic-speech-recognition") - Loads via
load_whisper_asr()ininference/asr.py(returnsWhisperASRHandle) - Uses language-specific model resolution (see below)
- Supports
bfloat16(CUDA) withfloat16fallback - Chunk length 25s with 3s stride for long files
- Device selection: CUDA > CPU (
MPSis intentionally excluded; seedeveloper/apple-mps-workarounds.md)
Native Whisper (--asr-engine whisper_rs)
BATCHALIGN_WHISPER_RS_MODEL=/path/to/ggml-large-v3.bin \
batchalign3 transcribe input/ -o output/ --asr-engine whisper_rs --lang=eng
Rust-native Whisper via whisper.cpp (the whisper-rs bindings), run
in-process in the server rather than through a Python worker. It is the
first non-Rev.AI ASR engine that is Rust-owned (is_rust_owned).
- Build-gated. Requires the
whisper-rs-backendCargo feature at compile time; it is NOT built by default because whisper.cpp is a C/C++ build. Selectingwhisper_rsin a build without the feature returns a clear “native Whisper path is not available in this build” error. - Model. Point
BATCHALIGN_WHISPER_RS_MODELat a ggml.binmodel (for example fromggerganov/whisper.cpp). One model per process: the loadedWhisperContextis cached process-wide, so changing models needs a restart (a second model path returnsModelPathChangedrather than reloading). - Acceleration. macOS builds always enable Metal; CoreML
(
whisper-rs-coreml, needs a sibling<model>-encoder.mlmodelcbundle) and CUDA (whisper-rs-cuda) are additive opt-in features. - Language. Requires a resolved
--lang; whisper.cpp language auto-detection is not wired on this path yet, so--lang autoreturns a validation error. Use Rev.AI (or a resolved language) for auto-detect. - Output parity. The chunk output is lowered to the shared
AsrResponsedomain through the same converter the Python Whisper worker uses, so identical chunks produce identical downstream CHAT. - Because it is Rust-owned with no pool-managed Python worker, it does not appear in worker-admission accounting; the model loads in the server process.
Per-language defaults
The fallback dispatch when no --asr-engine is set
AND no Rev.AI key is configured is not unconditionally Whisper. The
worker resolver consults a per-language default table
(_LANG_DEFAULTS in batchalign/worker/_model_loading/asr.py) before
falling through to Whisper. Currently:
yue(Cantonese) → FunASR/SenseVoice (per the 2026-05 Cantonese ASR benchmark, where vanilla Whisper-large-v3 was the worst-measured engine on TalkBank Tier 3 child speech)- all other languages → Whisper (the documented historical fallback)
To override the per-language default, pass an explicit
--asr-engine <engine>. The override always wins.
Model Selection
The default --asr-engine whisper engine loads openai/whisper-large-v3
across every language; the model id is wired at
batchalign/inference/asr.py:120 (model: str = "openai/whisper-large-v3").
There is no per-language fine-tune table on this engine.
Per-language fine-tunes are opt-in via the separate --asr-engine whisper_hub backend. A planned job resolves the model from
crates/batchalign/src/model_manifest.rs::WHISPER_HUB_DEFAULTS, which pins
each default to an exact hub commit and is seeded reactively, one entry at a
time, with dated provenance comments (today the only seeded entry is
mal → thennal/whisper-medium-ml). A language with no entry there is refused
at planning with WhisperHubHasNoDefaultModel, directing the user to pass an
explicit model_id via --engine-overrides. See
Whisper Hub ASR.
whisper_rs resolves its model from BATCHALIGN_WHISPER_RS_MODEL when set,
which runs it unpinned. Without that variable it fetches
ggerganov/whisper.cpp/ggml-large-v3.bin at the exact repository commit this
build pins (NATIVE_WHISPER_REVISION in
crates/batchalign/src/model_manifest.rs), and weights that resolve from any
other commit are refused with NativeWhisperRevisionMismatch rather than run:
the transcript’s stamp would otherwise name a revision that did not produce it,
and nothing downstream could detect the substitution.
Auto-Detect Mode (--lang auto)
When --lang auto is passed, the language and task keys are omitted
from Whisper’s generate_kwargs, allowing the model to auto-detect the spoken
language from the audio. This enables transcription of bilingual or
code-switched recordings (e.g., English/Spanish) where forcing a single
language would cause the model to skip or garble content in the other language.
batchalign3 transcribe bilingual_audio/ -o output/ --asr-engine whisper --lang auto
How it works:
graph LR
A["--lang auto"] --> B["iso3_to_language_name()"]
B --> C["'auto' sentinel"]
C --> D["gen_kwargs()"]
D --> E["Omit 'language' key"]
E --> F["Whisper auto-detects\nfrom first 30s of audio"]
Behavior per engine:
| Engine | --lang auto behavior |
|---|---|
whisper (HuggingFace) | Uses openai/whisper-large-v3 (multilingual); omits language from kwargs |
rev (Rev.AI) | Rev.AI has its own auto-detection via the API |
Limitations:
- Whisper auto-detects from the first ~30 seconds of audio, so the dominant language in the opening segment drives detection for the whole file
- Language-specific fine-tuned models (e.g.,
talkbank/CHATWhisper-en) are not used in auto mode, the generic multilingual model is loaded instead - Downstream stages (
morphotag,align) still need an explicit language for their own model selection;autocurrently applies only to ASR transcription
The TalkBank fine-tuned model (talkbank/CHATWhisper-en) is trained on
conversational speech with CHAT-specific patterns (utterance boundaries, speaker
overlap).
Forced Alignment
batchalign3 align input/ output/ --lang=eng
- Whisper FA engine in
inference/fa.py(infer_whisper_fa()) - Loads via
load_whisper_fa()(returnsWhisperFAHandle) - Always uses
openai/whisper-large-v2– no language-specific resolution - Loads the full
WhisperForConditionalGenerationmodel withattn_implementation="eager" - Uses cross-attention alignment heads + dynamic time warping (DTW) to extract per-token timestamps
- The encoder output and DTW alignment run in Python; the DP alignment of
Whisper tokens against CHAT words runs in Rust (
batchalign_core.add_forced_alignment) - Results are cached by audio chunk + text hash
How FA Works
- Whisper processes an audio chunk with the transcript as forced decoder input
- Cross-attention weights are extracted from designated alignment heads
- Attention matrix is normalized (mean/std) and median-filtered
- Dynamic time warping aligns decoder tokens to audio frames (20ms resolution)
- Token-level timestamps are mapped back to words
- Current Rust FA handling matches Whisper token timings to CHAT words by deterministic in-order stitching; unmatched words remain explicit untimed slots rather than triggering transcript-wide remap.
Utterance Timing Recovery (UTR)
UTR is automatically added whenever align is run (unless --no-utr).
It re-transcribes the full audio file to get word-level timestamps, then uses
those timestamps to improve forced alignment quality.
Two UTR engines exist:
Whisper UTR (default)
- Whisper UTR loads via
load_whisper_asr()inbatchalign/inference/asr.py:119and reuses theWhisperASRHandletype. - The same stock checkpoint is used for every language:
openai/whisper-large-v3, pinned to an exact hub commit incrates/batchalign/src/model_manifest.rs. UTR engines are not language-keyed in BA3 (see Language Code Resolution §“Model Resolution (UTR)”). Per-language fine-tunes for UTR are not wired in the current resolver. - Results cached by audio file identity (BLAKE3 of path + size), under a namespace naming the UTR engine AND the models it pinned, so changing any of those models makes the older rows unreadable instead of silently reusable. A plan with any floating model is ineligible for the cache and neither reads nor writes it.
- Hands timed words to
batchalign_core.add_utterance_timing(Rust).
Rev.AI UTR (alternative)
- Rev.AI UTR uses the Rust-owned
batchalign::revaiclient directly - Same API key as the Rev.AI ASR engine
- Timed words are handled entirely in Rust (server-side)
Post-Processing Pipeline
All ASR engines normalize their output through the Rust post-processing pipeline
in crates/batchalign-transform/src/asr_postprocess/:
- Compound word merging – joins words like
["ice", "cream"]into"icecream"using a known compound list (crates/batchalign-transform/data/compounds.json; 3,660 raw entries → 3,584 unique pairs after dedup, asserted atcrates/batchalign-transform/src/asr_postprocess/compounds.rs:84) - Number-to-words – converts digits to words using language-specific
lookup tables (
crates/batchalign-transform/data/num2lang.json; 46 languages today) plus Chinese/Japanese viacrates/batchalign-transform/src/asr_postprocess/num2chinese.rs - Retokenization into utterances:
- With utterance engine (English, Chinese, Cantonese): uses a BERT model to predict utterance boundaries
- Without: splits on punctuation (
.,?,!, etc.)
- CHAT generation via
batchalign_core.build_chat()– constructs valid CHAT from structured JSON (participants, utterances, words with timestamps)
The utterance segmentation engine is a separate model loaded alongside the ASR
engine. Available for: English (talkbank/CHATUtterance-en), Mandarin
(talkbank/CHATUtterance-zh_CN), Cantonese
(PolyU-AngelChanLab/Cantonese-Utterance-Segmentation).
Memory and Performance
A full align pipeline loads up to two Whisper checkpoints (FA + UTR
with the Whisper backend):
| Component | Model | Approx. Memory |
|---|---|---|
| FA (Whisper) | openai/whisper-large-v2 | ~3 GB |
UTR (--utr-engine whisper) | openai/whisper-large-v3 (pinned) | ~3 GB |
Switching UTR to Rev.AI (--utr-engine rev) avoids the second model
load entirely. The ASR transcribe command loads one Whisper model
(~3 GB) plus optionally an utterance segmentation BERT model (~400 MB).
All models use lazy loading – imports and model weights are loaded on first use, not at CLI startup.
Whisper Models in Use (Summary)
| Context | Model ID | Size |
|---|---|---|
ASR (--asr-engine whisper, all languages) | openai/whisper-large-v3 | large-v3 |
ASR (--asr-engine whisper_hub, opt-in fine-tunes) | per _RESOLVER["whisper_hub"] or --engine-overrides model_id | varies |
| FA | openai/whisper-large-v2 | large-v2 |
UTR (--utr-engine whisper, all languages) | openai/whisper-large-v3, pinned to an exact commit | large-v3 |
Implications for whisper.cpp Migration
What would be straightforward
- ASR transcription: whisper.cpp supports large-v2 and large-v3 with GGML quantization. Direct replacement for the OpenAI Whisper and HuggingFace Whisper engines.
- UTR: Same encoder architecture, same word-level timestamps.
Landed 2026-07-28 (fully-supported-and-default directive)
whisper-rs-backendis a DEFAULT Cargo feature: every build carries thewhisper_rsengine (the non-default gate had silently dropped the engine from rebuilt binaries).- Model auto-resolution:
BATCHALIGN_WHISPER_RS_MODELstill overrides, but without it the defaultggml-large-v3.binis fetched once fromggerganov/whisper.cppvia hf-hub and cached. - Language auto-detect:
Autono longer errors; whisper.cpp’s own detection runs and the detected code is mapped back through the same closed language table used for explicit input.
What would require work
- Fine-tuned models: any HuggingFace fine-tune seeded into
_RESOLVER["whisper_hub"](today onlythennal/whisper-medium-ml) would need conversion to GGML format and quality validation before whisper.cpp could load it. - Forced alignment: PILOT PARITY ACHIEVED (2026-07-29). whisper.cpp
cannot teacher-force an arbitrary transcript, so the FA port goes
through the CANDLE arm, reproducing the HF algorithm exactly:
teacher-forced forward pass,
alignment_headscross-attentions, per-(head,frame) standardization over tokens, median filter, head-mean cost matrix (row 0 flattened), DTW at 20 ms frames. Pieces: the shared numeric core (whisper_native/fa_dtw.rs, unit-tested), a vendored capture-enabled model + driver + parity harness inbatchalign-whisper-pilot(fa_model.rs,fa.rs,bin/fa_parity). Measured parity on large-v2/JFK vs the production Python path: token sequences identical, max |delta| 0.040 s, mean 0.014 s. The critical subtlety, do not lose it: HF’smodel(labels=...)appliesshift_tokens_rightbefore the decoder, so attention rowkis produced from input tokenk-1; the Rust decoder input must be[sot] + labels[..n-1]while timings zip with the UNSHIFTED labels. Landed since: the numeric core lives in the leaf cratebatchalign-fa-core(shared without the server stack);FaAssets::load/alignis the promotion seam (load once, align per call); capture is restricted to the alignment-head layers; an ignored-by-default equivalence test guards the vendored model against upstream candle drift; per-job model selection (whisper_rs_modelengine-override extra) andsetup --prefetch-whisper-rscomplete the ASR-side surface. Remaining for production: theFaInferItem-shaped dispatch behind the FA engine seam (withFaAssetscached per model+device) and corpus-scale parity (the 114 aligned IISRP sessions are the designated parity corpus). - Utterance segmentation BERT models: Unrelated to Whisper, would remain in Python regardless.
What would not change
- Rev.AI engine: Already fully Rust (
crates/batchalign/src/revai/, called directly by the server). - Post-processing pipeline: Already Rust (
crates/batchalign-transform/src/asr_postprocess/). - CHAT generation: Already Rust (
batchalign).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
whisper_hub ASR engine
Status: Current Last updated: 2026-09-15 12:12 EDT
What it is
whisper_hub is an ASR engine variant that loads a community Whisper
fine-tune from the Hugging Face Hub by model_id. It exists because
stock openai/whisper-* checkpoints produce unusable output on some
languages where Rev.AI also fails, and a per-language fine-tune is the
only path to coherent transcription.
whisper_hub is parallel to the other ASR engine variants:
| Engine | What it loads | When to use |
|---|---|---|
rev (default) | Rev.AI cloud API | Languages where Rev.AI quality is good (English, Spanish, most European). |
whisper | Stock openai/whisper-large-v3 via HF transformers | Languages where stock Whisper handles the acoustic + language combo well. |
whisper_rs | whisper.cpp in-process (Rust-native) | Local transcription with no Python worker. |
whisperx, whisper_oai | Nothing: not implemented | Never. Both are accepted names with no implementation and are refused at submission. |
whisper_hub | HF community fine-tune by model_id | Languages where both Rev.AI and stock Whisper fail. |
tencent, aliyun, funaudio | Cantonese providers | Chinese variants only. |
Quick start
# Uses the per-language default model_id resolved from the Rust manifest,
# crates/batchalign/src/model_manifest.rs. For Malayalam that's
# thennal/whisper-medium-ml, at the commit the manifest pins.
batchalign3 transcribe input/ output/ --lang mal --asr-engine whisper_hub
To override the model for a language that already has a default, or to pick a model for a language we haven’t seeded yet:
batchalign3 transcribe input/ output/ \
--lang mal --asr-engine whisper_hub \
--asr-engine whisper_hub --engine-overrides '{"model_id": "other/mal-model"}'
Per-language defaults
The per-language default model_id table lives in
crates/batchalign/src/model_manifest.rs::WHISPER_HUB_DEFAULTS, together with
the exact hub commit each default is pinned to. It is intentionally small and
seeded reactively from empirical evaluation, a language only gets a default
after we’ve confirmed the chosen fine-tune produces coherent output.
| Language (ISO-639-3) | Default HF model_id | Notes |
|---|---|---|
mal (Malayalam) | thennal/whisper-medium-ml | See “Evaluation below.” |
Rust owns that table because an id has to be known BEFORE a load in order to
pin its revision, and a second copy on the worker side could only disagree with
it. A planned job therefore resolves from the manifest, and an entry added only
to batchalign/models/resolve.py leaves the job refused: planning fails with
ModelPlanError::WhisperHubHasNoDefaultModel, naming the language, rather than
loading something unpinned. The Python table and its
WhisperHubModelNotFoundError remain for direct callers that have no control
plane.
Any other language requires passing --asr-engine whisper_hub --engine-overrides '{"model_id":"..."}', instead of falling back to a stock
Whisper checkpoint that would silently produce garbage.
What the run records about identity
An id the manifest pins is loaded at that exact commit, and the transcript’s
stamp names the model with its revision. An id the manifest does not know,
which is any model_id passed through --engine-overrides that is not a seeded
default, is carried as a FLOATING identity: it loads, and the revision recorded
for it is whatever the worker reports for the weights it actually resolved, so
an override never leaves the transcript naming the engine alone. A floating
identity can never build a cache key.
Why a per-language table and not auto-discovery?
HuggingFace lists dozens of Whisper fine-tunes per language. Their
advertised WER is self-reported and wildly inconsistent, their test
sets vary, and some checkpoints (e.g.,
DrishtiSharma/whisper-large-v2-malayalam) have a broken
generation_config that refuses to load via HF transformers without a
consumer-side workaround. Auto-picking by download count or name
match would ship the first plausible-looking thing to users with no
quality signal.
The table is hand-curated so every default is traceable to an actual
empirical comparison. The escalation path to automated probing lives
in revai-language-quality-strategy.md.
Evaluation behind the Malayalam seed
A 73-second Malayalam sample was transcribed by four candidates:
| Model | Malayalam-script chars | Repetition | Usable |
|---|---|---|---|
thennal/whisper-medium-ml | 100.0 % | 0.01 | Yes |
kavyamanohar/whisper-small-malayalam | 100.0 % | 0.04 | Yes, noisier |
openai/whisper-large-v3 | 27.5 % | 0.27 | No (hallucinates “Thank you for watching.”) |
openai/whisper-medium | 13.4 % | 0.73 | No (Khmer + Gurmukhi character loops) |
Rev.AI on the same file returned 55 tokens of Hangul + Gurmukhi + Latin
- U+FFFD, zero Malayalam script. That result drove the deny-list
entry in
revai/preflight.rs::REVAI_KNOWN_BROKEN, which now recommendswhisper_hubfor Malayalam specifically.
Artifacts live in an operational workspace outside this public repo.
Caveats:
- Single audio file. A native Malayalam reader should compare word-level accuracy before shipping this default for a large corpus.
- CPU inference (no GPU numbers).
thennal/whisper-medium-mltook 353 seconds on a development machine’s CPU for 73 seconds of audio (4.8× real-time slower). GPU should be ~3-5× faster than real-time.
Fine-tune gotchas
HF Whisper fine-tunes differ from stock OpenAI checkpoints in one
critical way: they bake language and task into their own
generation_config. Passing them again via generate_kwargs produces
gibberish, the model applies two competing prompts.
whisper_hub handles this by passing language="auto" through to the
shared load_whisper_asr(), which makes the handle’s gen_kwargs("auto")
branch fire and omit those overrides. Do not replicate the
language=<concrete> path used by stock Whisper when adding a new
fine-tune loader.
When this engine stops being a fit
If the per-language table grows past ~10 entries and we’re re-testing
defaults often, it’s time to escalate to a probe harness, see Option C
in revai-language-quality-strategy.md.
Cross-references
- Command flag wiring:
cli-reference.md - Language code mapping:
language-code-resolution.md - Rev.AI deny-list strategy (why this engine exists):
revai-language-quality-strategy.md - How to add a new engine variant:
../developer/adding-engines.md - Stock Whisper page (baseline behavior):
whisper-asr.md
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Rev.AI Language Quality Strategy
Status: Reference (the Options/Decision/Escalation framing below is
preserved for context; the live behavior is the Option A hand-curated
deny-list enforced at validate_language_support() in
crates/batchalign/src/types/request.rs:190)
Last updated: 2026-05-20 20:33 EDT
The deliberation framing on this page (five options, decision rationale, escalation triggers) is historical analysis preserved here for context. The current code-level behavior is the Option A hand-curated deny-list described in §“Implementation notes (Option A)”; that is the authoritative section to read first.
Background
Rev.AI’s transcription API advertises support for ~70 languages via an ISO-639-1 language hint parameter. In practice the quality of those models varies dramatically: some are production-grade (English, Spanish, French), some are usable-but-noisy, and some return output that is unusable for any downstream CHAT pipeline.
The failure mode we care about is not “ASR accuracy is mediocre”, CHAT can
tolerate many transcription errors and still be useful. The failure mode is
output that cannot be represented as CHAT at all: tokens containing Unicode
replacement characters (U+FFFD), tokens in the wrong script entirely, bare
punctuation returned as if it were a word, digit sequences mixed into
alphabetic tokens. These violate CHAT word-legality rules (E220, E330,
etc.) at the final “belt after the braces” validator in
transcript_from_asr_utterances, and the user sees confusing per-token
validation errors with no way to tell that the ASR backend, not the
transcript, is the source of the problem.
Observed failure mode
A short Malayalam audio sample submitted to Rev.AI with language=ml
returned text elements comprising:
- Hangul characters mixed with Malayalam vowel signs (e.g.
모두െ). - A long tail of bare Gurmukhi/Punjabi tokens (
ਅਤੁਂਦੇ,ਵਾਲੇ, …), an entirely unrelated script. - Stray Latin words posing as Malayalam (
occurrence,Moo,Take,Me,ganhar,segueiasm). - Cyrillic fragments (
анти). - U+FFFD replacement characters embedded in tokens (
);�,philan�ുടഖ഻ിറ്,ക�антиച്). - Bare punctuation as a “word” (
);�).
None of this is usable. The audio content was coherent Malayalam; Rev.AI’s
Malayalam model produced the garbage. Our language mapping
(try_revai_language_hint) was correct, mal → ml: so this is not a
client-side bug.
The downstream CHAT validator (ChatWordText::try_from_lang via
asr_postprocess) correctly refuses these tokens, but the user-visible
result is a cryptic [E220] "611었" is not a legal word in language(s) "mal" rather than an actionable “Rev.AI’s Malayalam model is broken; use
--asr-engine whisper.”
Problem
Given no published quality manifest from Rev.AI, how should batchalign3
decide which (engine, language) pairs to accept, which to reject, and
how to tell users which alternative to use?
Options considered
The options below are ordered from cheapest to most principled. Each has a distinct cost / latency / coverage tradeoff.
Option A: Hand-curated deny-list (reactive)
Maintain a small, committed static table in revai/preflight.rs listing
(language, reason, recommended_engine) tuples for pairs we have observed
to be broken. validate_language_support() rejects matching job
submissions at preflight with an error message naming the recommended
alternative and linking to this doc.
- Cost: trivial, one static, one branch, one doc entry per incident.
- Latency to catch a bad pair: unbounded. Depends on a user reporting the breakage.
- Coverage: only pairs we have already been burned by.
- Succession friendliness: high, the table is in git with dated provenance comments; the successor understands the shape in minutes.
- Risk: the static table becomes stale if Rev.AI fixes a language and we never retest (a previously-broken pair would remain denied).
Option B: Runtime script-coherence gate (per-file backstop)
After Rev.AI returns tokens for a job, but before CHAT assembly, run a
pure function check_asr_script_coherence(tokens, lang) that looks up the
declared language’s canonical script and fails the file with a typed
AsrQualityError if the token distribution contradicts it (too many
tokens in the wrong script, or any U+FFFD in any token). No-op for Latin-
script languages (English legitimately code-switches).
- Cost: ~100 LOC plus a 16-entry script table. No external resources.
- Latency: catches bad pairs on the first affected file, no waiting for a bug report.
- Coverage: catches only cross-script failures. Rev.AI can still return same-script gibberish (wrong-word-in-right-script) that this gate would pass but a human would reject.
- Succession friendliness: medium, the threshold tuning (what fraction counts as “cross-script”) is a judgment call that ages poorly.
- Risk: false positives on real code-switching transcripts.
Option C: Empirical capability probe (Stanza-parallel)
Periodic harness that submits a small reference audio clip (e.g. from
Mozilla Common Voice) in each Rev.AI-supported language, scores the
result on proxies that don’t require gold transcripts (script coherence,
U+FFFD rate, CHAT-legality pass rate, token-length distribution), and
emits a committed revai_language_quality.json table. Preflight loads
the table at startup and rejects pairs classified broken, with a
provenance field showing when the classification was last confirmed.
This mirrors how Stanza per-language processor availability is computed
at worker startup from resources.json (_stanza_capabilities.py),
Rev.AI has no such upstream manifest, so we produce our own.
- Cost: high, reference corpus procurement, harness implementation (~1-2 days), recurring Rev.AI API spend (small per run, multiplicative over time), one operator-day per scheduled refresh.
- Latency: catches bad pairs at the next refresh interval (weeks).
- Coverage: every Rev.AI-supported language, re-verified on cadence. Detects both breakage and recovery, a language Rev.AI fixed gets automatically un-denied.
- Succession friendliness: high, the procedure is documented, the output is data-in-git, the decision rule is mechanical.
- Risk: reference audio may not match real-world acoustic conditions; proxies may miss same-script gibberish just like Option B.
Option D: Require explicit opt-in for non-characterized languages
Reject every Rev.AI language we haven’t explicitly characterized,
requiring an --accept-rev-ai-quality-risk flag to submit. Users
self-select into accepting unknown-quality output.
- Cost: trivial in code, high in UX friction.
- Latency: zero, everything we haven’t cleared is denied.
- Coverage: total, at the cost of blocking legitimate use of languages we just haven’t tested yet.
- Succession friendliness: poor, the friction pushes users off Rev.AI entirely rather than generating the signal we’d use to characterize more languages.
- Risk: users stop reporting issues because the flag makes breakage “their fault.”
Option E: Consume Rev.AI’s advertised list as-is (status quo)
Do nothing. The try_revai_language_hint table continues to translate
ISO-639-3 codes to Rev.AI codes and submits whatever the user asked for.
Breakage surfaces as E220 / E330 validation errors on individual tokens.
- Cost: zero.
- Latency: infinite (nothing is caught).
- Coverage: none.
- Succession friendliness: poor, a successor debugging a Malayalam transcribe failure will not know to look at Rev.AI quality rather than our CHAT validator.
- Risk: recurring confused-user incidents.
Decision
Adopt Option A now. Keep Options B and C on the table as the escalation path.
Rationale:
- We have exactly one data point today (Malayalam). Building a probe harness (Option C) to characterize a single known-broken language has a poor cost/benefit ratio, and a runtime gate (Option B) without a validated script-coherence threshold risks false positives on real code-switching.
- Option A costs ~15 lines of code and one doc update. It directly resolves the reported incident.
- The static table carries provenance comments, so a successor reads each entry’s rationale and understands why it exists and when it should be re-evaluated.
Escalation triggers
Move from Option A to Option B (add the runtime gate) when:
- We accumulate ≥3 reported bad
(engine, language)pairs and want to catch novel regressions on the first file rather than the first report.
Move from Option A/B to Option C (build the probe harness) when:
- Rev.AI changes its model or adds/removes supported languages (announced via their changelog) and we need re-characterization on a cadence rather than ad-hoc.
- We extend this policy beyond Rev.AI to Whisper / Tencent / Aliyun and the per-backend manual curation cost exceeds one engineer-day per quarter.
- A downstream user (an external professor, a successor) asks “which languages does this actually work on?” and our only answer is “whichever ones we haven’t gotten a bug report for.”
Implementation notes (Option A)
- Static table lives in
crates/batchalign/src/revai/preflight.rs. - Preflight hook is
validate_language_support()incrates/batchalign/src/types/request.rs, in the existing Rev.AI block immediately after thetry_revai_language_hint(lang).is_none()check. - Error message names the offending language, explains the quality reason, recommends a specific alternative engine, and links to this doc.
- Every entry carries a dated provenance comment with the incident date, a one-line description of what Rev.AI returned, and a link to the evidence (kept in an operational workspace, never inline in this public repo).
Recommended alternative: whisper_hub
The deny-list error message currently recommends --asr-engine whisper_hub
for languages where Rev.AI is broken. This recommendation presumes
empirical evidence that a community fine-tune works for that language.
See whisper-hub-asr.md for the engine, its
per-language default model table, and the evidence behind each
recommendation.
When adding a new deny-list entry, confirm first that the recommended alternative actually works on a real sample, else you’re redirecting users from one broken engine to another. If no working alternative exists yet for the offending language, the deny-list entry should name that fact explicitly in the error message rather than point at a random fallback.
Cross-references
- Language code mapping:
language-code-resolution.md - Whisper (the current recommended alternative):
whisper-asr.md - Stanza capability model (the analogy for Option C): see
batchalign/worker/_stanza_capabilities.pyandstanza-limitations.md.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Retrace Detection
Status: Current behavior reference Last verified: 2026-05-21 15:20 EDT
This page documents how current batchalign retrace detection works. It does not preserve branch-era side-by-side implementation archaeology.
CHAT convention
CHAT marks repeated word sequences with [/] for partial retracing. The
angle-bracket form denotes a repeated phrase (a multi-word unit); a
run of identical single-word repetitions uses separate [/] markers:
- single-word retrace:
the [/] the dog . - repeated single word:
a [/] a [/] a side of the point . - multi-word phrase retrace:
<I want> [/] I want a cookie .
Angle brackets are only used when two or more different words have been repeated together as a phrase.
Current detection rule
Retrace detection uses sliding-window repeated-sequence matching over the
lexical content words of an utterance, with language-specific safeguards
such as a higher minimum n-gram length for Chinese and Cantonese. The
comparison is case-insensitive: the detector lowercases a comparison key
per content word (content_keys in apply_retrace_detection) while the
stored word text keeps the case that the ASR provider returned, so CHAT
output continues to show "I [/] I" rather than "i [/] i".
Fillers do not produce retrace markers
Filled pauses (&-um, &-uh, &-ur: any token carrying the &-
prefix after stage 7 disfluency replacement) participate in n-gram
matching but are never re-typed to WordKind::Retrace. A bare
repetition of fillers is filler behavior, not a false start, and is
emitted as plain fillers with no [/] marker.
flowchart TD
Word["content word at matched position"]
Gate{"text starts with "&-"?\n(filler / filled pause)"}
Skip["leave as WordKind::Regular\nno [/] emitted for this token"]
Mark["set WordKind::Retrace\nserializer emits [/]"]
Word --> Gate
Gate -->|"yes"| Skip
Gate -->|"no"| Mark
Worked examples:
| Input (post-disfluency) | Retrace marks set | CHAT output |
|---|---|---|
&-um &-um I went | none | &-um &-um I went . |
I I went | first I | I [/] I went . |
&-um I &-um I went | first I only | &-um I [/] &-um I went . (bigram repeat detected; the fillers embedded in the match stay as fillers) |
The gate lives at the marking step in apply_retrace_detection
(crates/batchalign-transform/src/asr_postprocess/cleanup.rs), using the
is_filler(text) helper that checks the &- prefix. WordKind does
not carry a dedicated filler variant; disfluency replacement (stage 7)
rewrites um to &-um before retrace detection runs, so the prefix is
the stable filler marker for this check.
Current implementation properties
The current implementation is structured to avoid two common older failure modes:
- larger repeated spans are preferred over smaller fragmentary matches
- overlap-safe claiming prevents the same region from being marked repeatedly by conflicting matches
Current formatting rule
Retrace formatting is structure-driven. The serializer reads the
consecutive run of WordKind::Retrace words in each utterance and emits
one of three shapes:
flowchart TD
Run["Consecutive retrace run\n(WordKind::Retrace)\nin build_word_utterance()"]
Len{"run length N?"}
Same{"all N words share the\nsame lexical text\n(case-insensitive)?"}
Single["One AnnotatedWord\nw [/]"]
Unigram["N AnnotatedWords in sequence\nw [/] w [/] ... [/] w"]
Group["One AnnotatedGroup\n<w1 w2 ...> [/] w1 w2 ..."]
Run --> Len
Len -->|"N == 1"| Single
Len -->|"N > 1"| Same
Same -->|"yes: unigram repeat"| Unigram
Same -->|"no: multi-word phrase"| Group
Three worked examples:
| Input (main-tier words) | Run shape | CHAT output |
|---|---|---|
the the dog | N=1 | the [/] the dog |
a a a side | N=2, same | a [/] a [/] a side |
I want I want a cookie | N=2, diff | <I want> [/] I want a cookie |
Bracket choice follows the structured representation rather than ad-hoc
string postprocessing. Verified against
build_word_utterance() in
crates/batchalign-transform/src/build_chat/utterances.rs and
apply_retrace_detection() in
crates/batchalign-transform/src/asr_postprocess/cleanup.rs.
Known limits
- current retrace detection targets exact repetition, not richer reformulation
analysis such as
[//] - non-lexical or already-heavily-annotated content may reduce what can be recognized as a retrace candidate
- overlap-heavy or highly noisy utterances may still require manual review
Legacy note
Earlier versions of this page compared older Python and newer Rust implementations in detail. For public docs, the important point is the current behavioral contract: retraces are detected structurally and formatted from structure, not by fragile detokenize-time heuristics.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Multilingual Support
Status: Current behavior reference
Last verified: 2026-03-19
Current multilingual boundary
Batchalign supports multilingual corpora primarily through:
- file-level language metadata
- per-utterance language directives
- conservative handling of per-word code-switch markers
This is the current public contract. It is more explicit than older BA2-era single-language assumptions, but it is not the same as full per-word bilingual analysis.
Current morphosyntax behavior
Current morphosyntax handling is language-aware at the utterance level.
Practical consequences:
- utterances can be processed with language-aware routing rather than assuming the entire file is one language
- code-switched words marked at word level are handled conservatively rather than being forced through a possibly wrong language model
- cache and payload handling keep language as part of the processing boundary
Current output consequences
Users working with multilingual corpora should expect:
- better behavior than a file-wide single-language assumption
- clearer distinction between utterance-level language handling and word-level foreign-language marking
- some foreign/code-switched words to remain conservatively represented instead of receiving overconfident morphology
Current limits
Batchalign does not currently promise:
- full per-word routing into multiple language-specific NLP pipelines
- perfect code-switch analysis inside one utterance
- complete elimination of manual review for difficult bilingual material
Related references
- Language-Specific Processing, how each pipeline stage diverges per language
- Language Code Resolution, ISO mapping, model resolution
- Language Routing, per-utterance routing into Stanza, per-word routing limits, auto-detection
- Language Data Model
- L2 & Language Switching
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Language-Specific Processing Overview
Status: Current Last updated: 2026-09-07 07:04 EDT
This page is the single entry point for understanding how batchalign3 handles non-English languages. It maps every stage of the processing pipeline to the language-specific behavior at that stage.
Pipeline Stages and Language Divergence
Every audio file flows through the same pipeline. At each stage, the pipeline checks the language code and may take a different path:
flowchart TD
input["Audio + lang code"]
resolve["Model Resolution\n(lang → fine-tuned model)"]
asr["ASR Transcription"]
compound["Compound Merging"]
numexp["Number Expansion\n(12 table langs + Chinese/Japanese)"]
cantonorm["Cantonese Normalization\n(lang=yue only)"]
rtlpunct["RTL Punctuation\nNormalization"]
retok["Retokenization\n(punctuation-based)"]
utseg["Utterance Segmentation\n(3 dedicated models)"]
morpho["Morphosyntax\n(Stanza + per-lang workarounds)"]
fa["Forced Alignment\n(Whisper/Wave2Vec/Cantonese FA)"]
input --> resolve --> asr --> compound --> cantonorm
cantonorm --> numexp --> rtlpunct --> retok --> utseg --> morpho --> fa
style cantonorm fill:#f9e2b0
style numexp fill:#d4edda
style rtlpunct fill:#d4edda
style morpho fill:#cce5ff
style fa fill:#e2d5f1
Where Each Language Diverges
Stage 1: Model Resolution
The default --asr-engine whisper loads openai/whisper-large-v3
across every language (batchalign/inference/asr.py:120). UTR is
engine-based: --utr-engine whisper loads openai/whisper-large-v3 at the
commit the manifest pins,
--utr-engine rev uses Rev.AI’s cloud API, and
--utr-engine tencent routes to Tencent. There is no
per-language fine-tune resolver wired into --asr-engine whisper or
the UTR engines; per-language fine-tunes are opt-in through the
separate --asr-engine whisper_hub engine (see
Whisper Hub ASR for the seeded entries, today
only mal → thennal/whisper-medium-ml).
| Engine | Model |
|---|---|
--asr-engine whisper (default) | openai/whisper-large-v3 for all languages |
--asr-engine whisper_hub | per-language HuggingFace fine-tune via _RESOLVER or explicit --engine-overrides model_id |
--utr-engine whisper | openai/whisper-large-v3 for every language, pinned to an exact commit |
See Language Code Resolution and Whisper ASR for the full picture.
Stage 2: Number Expansion
Digit strings in ASR output are converted to language-appropriate word forms.
| Language group | Method | Example |
|---|---|---|
| Mandarin (zho, cmn) | num2chinese (simplified) | 10000 → 一万 |
| Cantonese (yue), Japanese (jpn) | num2chinese (traditional) | 10000 → 一萬 |
| Table languages | NUM2LANG JSON lookup | 5 → “five” (eng), “cinco” (spa), “cinq” (fra) |
| All others | Pass-through (no expansion) | 42 → “42” |
The table-driven languages are enumerated in
crates/batchalign-transform/data/num2lang.json (46 entries today;
re-derive via python3 -c "import json; print(sorted(json.load(open('crates/batchalign-transform/data/num2lang.json'))))"
rather than maintaining a parallel list here).
See Number Expansion for details on the Chinese character conversion algorithm and the table-based approach.
Stage 3: Cantonese Text Normalization (yue only)
This stage only activates when lang=yue. It applies two transformations:
- Simplified → Traditional Chinese via
ferrous-opencc(embedded OpenCCs2hkconversion tables) - 31-entry domain replacement table for Cantonese-specific character corrections (e.g., 系→係, 呀→啊, 中意→鍾意)
This runs in the core Rust pipeline (batchalign), not in a
separate plugin package. Every ASR engine’s output benefits from it
automatically.
See Cantonese Processing for the full replacement table and architecture.
Stage 4: RTL Punctuation Normalization
Arabic/Persian/Urdu punctuation is normalized to ASCII equivalents:
| RTL | ASCII |
|---|---|
| ؟ | ? |
| ۔ | . |
| ، | , |
| ؛ | ; |
Additionally, Japanese full-width period (。) is normalized to ., and
Spanish inverted punctuation (¿, ¡) is removed.
Stage 5: Utterance Segmentation
Three languages have dedicated BERT-based utterance segmentation models:
| Language | Model | Source |
|---|---|---|
| English | talkbank/CHATUtterance-en | TalkBank fine-tuned |
| Mandarin | talkbank/CHATUtterance-zh_CN | TalkBank fine-tuned |
| Cantonese | PolyU-AngelChanLab/Cantonese-Utterance-Segmentation | PolyU |
All other languages fall back to punctuation-based splitting (., ?,
!, and CHAT-specific terminators like +..., +/.).
Stage 6: Morphosyntax (Stanza + Workarounds)
Stanza is the backbone for POS tagging, lemmatization, and dependency parsing. Language-specific workarounds correct systematic errors:
| Language | Workarounds | Reference |
|---|---|---|
| English | 201-entry irregular-form table, contraction MWT hints, GUM package | Non-English Workarounds §E1-E3 |
| French | 20-entry pronoun-case lookup, 158 APM noun forms, MWT overrides | §F1-F3 |
| Japanese | Order-dependent verb-form override chain, combined package, comma normalization | Japanese Morphosyntax, §J1-J3 |
| Hebrew | HebBinyan/HebExistential feature extraction | Hebrew Morphosyntax |
| Italian | “l’” MWT suppression, “lei” merge | §I1-I2 |
| Portuguese | “d’água” MWT forcing | §P1 |
| Dutch | Possessive “’s” MWT suppression | §D1 |
Cross-language infrastructure:
| Feature | What | Reference |
|---|---|---|
| MWT dispatch | Capability-driven via should_request_mwt() against the cached Stanza catalog | §X1 + Stanza Limitations Defect 5 |
| ISO 639-3 → 639-1 mapping | iso3_to_alpha2() + _ISO3_OVERRIDES (Stanza-specific overrides) for codes like yue/cmn/zho → zh-hans, nor → nb | Language Code Resolution |
| Number expansion | Table-driven via num2lang.json + num2chinese.rs for CJK | Number Expansion |
Stage 7: Forced Alignment
| Engine | Languages | Method |
|---|---|---|
wav2vec_fa | All (default) | MMS FA CTC alignment; reports word start and end |
whisper_fa | All | Whisper large-v2 cross-attention DTW; reports token onsets only |
wav2vec_canto | Cantonese only | Hanzi→jyutping romanization + Wave2Vec MMS |
The Cantonese FA engine converts Chinese characters to tone-stripped jyutping romanization before alignment, because Wave2Vec MMS was trained on romanized text. See Cantonese Processing.
Language Code Flow
flowchart LR
cli["CLI: --lang=yue"]
iso3["ISO 639-3\n(3-letter, internal)"]
resolve["Model resolver\n(yue → fine-tuned model)"]
stanza["Stanza mapping\n(yue → zh)"]
pipeline["Pipeline stages\n(yue triggers Cantonese norm)"]
cli --> iso3
iso3 --> resolve
iso3 --> stanza
iso3 --> pipeline
batchalign3 uses ISO 639-3 (3-letter codes) internally everywhere. Conversion to 2-letter codes only happens at the Stanza boundary. See Language Code Resolution.
Cantonese Normalization Is Now Core
Older Python-only code paths did not apply Cantonese normalization uniformly
across every ASR path. Current batchalign3 implements simplified-to-traditional
conversion plus the Cantonese replacement table once in Rust core and applies
it as a shared ASR post-processing stage, so every ASR engine benefits from
the same normalization contract.
Since 2026-09-16 “once” is literal: AlignedNormalization
(crates/batchalign-transform/src/asr_postprocess/cantonese.rs) is the only
route to normalized Cantonese text, and the server applies it a single time per
monologue, before any stage splits the words. The provider bridges and the
character tokenizer used to normalize as well, which mattered because the
transformation is not idempotent: 聯繫 normalized twice becomes 聯係.
Related Pages
- Language Code Resolution, ISO mapping, model resolution
- Cantonese Processing, normalization, char tokenization, FA
- Hebrew Morphosyntax, HebBinyan, HebExistential
- Japanese Morphosyntax, verb forms, combined package
- Number Expansion, num2chinese, NUM2LANG tables
- Utterance Segmentation, per-language models
- Non-English Workarounds, workaround and convention catalog
- Whisper ASR, engine selection, model IDs
- Cantonese Language Support, engines, normalization, word segmentation, FA
- Cantonese and CJK, Architecture, engine dispatch, normalization pipeline, segmenter selection
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Language Code Resolution
Status: Current Last updated: 2026-09-16 08:18 EDT
This page documents how batchalign3 maps language codes to models, Stanza pipelines, and processing behavior.
Design Principle: No Silent Fallbacks
batchalign3 never silently substitutes a different language when the requested language is unsupported. Every engine boundary must either:
- Succeed: map the code correctly and proceed, or
- Fail explicitly: return a clear error naming the unsupported language, the engine that rejected it, and suggested alternatives
Silent fallbacks (e.g., defaulting to English when a language is unknown) produce output that looks plausible but is completely wrong. A clear error is always better, users can recover from “language not supported” but cannot recover from a transcript in the wrong language.
This policy applies to all engine boundaries: Rev.AI, Whisper, Stanza, Tencent, Aliyun, and any future engines. See the migration book for the history of how this policy was established after finding and fixing two silent fallback regressions.
Internal Representation: ISO 639-3
batchalign3 uses 3-letter ISO 639-3 codes everywhere internally:
- CLI:
--lang=eng,--lang=yue,--lang=heb - CHAT headers:
@Languages: eng,@Languages: yue - Cache keys, IPC payloads, batch items
- Worker task bootstrap:
python -m batchalign.worker --task morphosyntax --lang eng
The 3-letter code is the source of truth. Conversion to other formats happens only at external boundaries.
ISO 639-3 → ISO 639-1 (Stanza)
Stanza uses ISO 639-1 alpha-2 codes (with a few non-standard variants
like zh-hans/zh-hant/nb). Batchalign uses ISO 639-3 internally,
so the worker has to map one to the other before handing a lang
argument to stanza.Pipeline. The conversion lives in
batchalign/worker/_stanza_loading.py::iso3_to_alpha2() and resolves
in three layers:
-
Stanza-specific overrides:
_ISO3_OVERRIDESin_stanza_capabilities.py. The single source of truth for codes where the standard ISO-1 mapping does not correspond to a Stanza catalog key.iso3_to_alpha2()imports this dict (it does not redefine its own copy, see “Drift hazard” below).ISO-639-3 Stanza key Why not the standard alpha-2 yuezh-hansCantonese routes through Stanza’s Chinese model; zh-hansis the catalog key.cmnzh-hansMandarin. Same target. zhozh-hansChinese (generic). nornbNorwegian. pycountry says no; Stanza shipsnb(Bokmål) only.msamsMalay. pinned for stability. -
pycountry: for every other code with a standard ISO-639-3 ↔ ISO-639-1 counterpart (mar→mr,swa→sw,eng→en, etc.). This must be the fallback rather than a duplicate hardcoded dict, see “Drift hazard.” -
Pass-through with warning: for codes that have no standard alpha-2 (genuinely missing from pycountry). Length-2 codes are assumed to already be alpha-2 and pass through silently; everything else logs a warning before being handed to Stanza.
Drift hazard
A previous version of this function had its own hardcoded mapping
dict instead of using pycountry. That dict was missing many codes
(notably Marathi mar → mr). The capability table built via
pycountry correctly recognized mar as supported, but iso3_to_alpha2
returned "mar" verbatim, and stanza.Pipeline(lang="mar", ...)
crashed with “Language mar is currently unsupported” because Stanza’s
catalog is keyed by alpha-2.
This was a two-table-drift bug: two independent pieces of code
had to agree on the iso3 → alpha-2 mapping but didn’t. The fix is
structural, not data-driven: there is now one override dict
(_ISO3_OVERRIDES), and iso3_to_alpha2 imports it rather than
maintaining a parallel copy. Adding a new Stanza-specific override
means editing one dict in one place.
Do not reintroduce a second override dict here. If a new
Stanza-specific iso3 case comes up (e.g. a future Stanza release
labels Catalan differently), add it to _ISO3_OVERRIDES only.
For full architecture and incident history see Stanza Capability Registry.
Model Resolution (ASR)
The default HuggingFace Whisper engine (--asr-engine whisper) loads
openai/whisper-large-v3 across every language, see
batchalign/inference/asr.py::_infer_whisper and the default in
batchalign/inference/asr.py:120. There is no per-language fine-tune
table on this engine.
Per-language fine-tunes are opt-in via the separate --asr-engine whisper_hub engine, whose resolver lives at
batchalign/models/resolve.py::_RESOLVER["whisper_hub"]. The resolver
is seeded reactively from empirical evaluation, entries are added one
at a time with dated provenance, not speculatively. The table a planned job
resolves from is the Rust manifest
(crates/batchalign/src/model_manifest.rs::WHISPER_HUB_DEFAULTS), which pins
each entry to an exact hub commit. As of this writing the only seeded entry is
mal → thennal/whisper-medium-ml; a language absent from the manifest is
refused at planning with WhisperHubHasNoDefaultModel, directing the user to
pass an explicit model_id via --engine-overrides. See
Whisper Hub ASR for the engine, its evidence, and
the recommendation to add new entries.
Other ASR engines ignore language for model selection:
- Rev.AI: cloud API, handles language internally
--asr-engine whisper_rs: the model comes fromBATCHALIGN_WHISPER_RS_MODEL, else ggml-large-v3
Model Resolution (UTR)
UTR (Utterance Timing Recovery) is engine-based, not
language-keyed. The CLI selects a UTR backend with --utr-engine {rev,whisper,tencent} (--utr-engine-custom is a deprecated alias for cloud
backends). Each engine carries its own model identity:
| Engine | Model / Backend |
|---|---|
--utr-engine whisper | openai/whisper-large-v3 (stock Whisper), pinned to an exact hub commit |
--utr-engine rev | Rev.AI cloud API |
--utr-engine tencent | Tencent Cloud UTR backend (Chinese variants) |
There is no per-language UTR-model resolver, --utr-engine whisper
loads the same checkpoint regardless of --lang. See
Whisper ASR §“Utterance Timing Recovery” for the
full picture.
Model Resolution (Utterance Segmentation)
| Language | Code | Model |
|---|---|---|
| English | eng | talkbank/CHATUtterance-en |
| Mandarin | cmn/zho | talkbank/CHATUtterance-zh_CN |
| Cantonese | yue | PolyU-AngelChanLab/Cantonese-Utterance-Segmentation |
| All others | * | None (punctuation-based fallback) |
Pipeline Stage Dispatch
The 3-letter code drives behavior at multiple pipeline stages:
flowchart TD
lang["lang code (3-letter)"]
asr["ASR model\nresolution"]
num["Number expansion\n(zho/cmn → simplified,\nyue/jpn → traditional,\n12 table langs,\nothers → passthrough)"]
canto["Cantonese norm\n(yue only)"]
rtl["RTL punct\n(ara, heb, fas, urd)"]
retok["Retokenize\n(jpn: 。→.)"]
stanza["Stanza\n(3→2 letter mapping)"]
mwt["MWT dispatch\n(39 currently enabled)"]
workaround["Lang workarounds\n(eng, fra, jpn, ita, por, nld, heb)"]
fa["FA engine\n(yue: wav2vec_canto option)"]
lang --> asr
lang --> num
lang --> canto
lang --> rtl
lang --> retok
lang --> stanza --> mwt
lang --> workaround
lang --> fa
MWT Language Dispatch
Multi-Word Token (MWT) processing is capability-driven. The
loader at batchalign/worker/_stanza_loading.py:40::should_request_mwt
consults the Stanza catalog table built at worker startup from
stanza.resources.common.load_resources_json() (see
batchalign/worker/_stanza_capabilities.py) and requests the mwt
processor only when that table reports has_mwt=True for the
language. The earlier hardcoded MWT_LANGS set was deleted, see
Stanza Limitations Defect 5 for the full
rewrite rationale, and test_stanza_config_parity.py:82 for the AST
scan that prevents reintroduction.
See Non-English Workarounds §X1 for the per-language consequences.
Rev.AI Language Codes
Rev.AI uses a mix of ISO 639-1 and specific codes. The translation lives in
crates/batchalign/src/revai/preflight.rs:
The translation now uses ~75 explicit entries in try_revai_language_hint()
with a fallback to "auto" (Rev.AI auto-detection) + warning log for
unknown codes. See crates/batchalign/src/revai/preflight.rs.
Truncation fallback: not used
A truncation fallback (&other[..2]) for unknown ISO 639-3 codes is
not used; it produces wrong codes for many languages (e.g., pol → po
instead of pl, hak → ha which doesn’t exist). The current
behavior is:
- A comprehensive explicit mapping table (~75 entries covering all
Rev.AI-supported languages) in
revai/preflight.rs - A
try_revai_language_hint()function that returnsNonefor unsupported languages (enabling callers to report clear diagnostics) - A fallback to
"auto"(Rev.AI’s auto-detection) with a warning log for unknown codes, rather than silently submitting a wrong code
Whisper Language Strings
Some engines use human-readable language names rather than ISO codes. The mapping in the worker:
special = {"yue": "Cantonese", "cmn": "chinese"}
For all other languages, the worker resolves the ISO-639-3 code through
pycountry and passes the lower-cased language name to Whisper.
No silent English fallback
If pycountry has no entry for an ISO 639-3 code, the Whisper code path
raises ValueError with a clear message naming the unrecognized code,
rather than silently defaulting to "english".
See the no-silent-fallback policy.
Cross-Engine Language Support Summary
ISO 639-3 to ISO 639-1 has one owner: LanguageCode3::to_iso_639_1()
(crates/batchalign-types/src/iso639_part1/), whose table is generated by
scripts/generate_iso639_1_table.py from pycountry, which derives in turn from
the ISO 639-3 code tables published by iso639-3.sil.org. The conversion is
explicitly PARTIAL: only about 184 of roughly 7,900 ISO 639-3 languages have a
two-letter code, so it returns a closed Iso639Part1Lookup whose absent arm is
named NoTwoLetterCode. It is not an Option, because an Option invites
unwrap_or(three_letter_code), and substituting the three-letter code is
exactly how a provider came to be asked for a model named 16k_cmn.
Chatter owns the ISO 639-3 REGISTRY (is_valid_iso639_3, “is this a real
code”), but not this conversion: its vendored table keeps identifiers and a
retirement status and deliberately drops SIL’s Part1 column. Moving the
conversion there is a reasonable future step.
What remains per engine is each provider’s own ACCEPTED CODE SET, which is a
fact about that provider rather than about the standard, and which therefore
still differs between engines. Rev.AI accepts cmn (not an ISO 639-1 code) for
Chinese; whisper.cpp accepts yue as its own token; Stanza ships Bokmål only,
so it wants nb where the standard says no. Those tables stay separate, and
may be derived from the owner above but are not replaced by it:
flowchart TD
lang["LanguageCode3\n(ISO 639-3)"] --> stanza["Stanza\n55-entry explicit table\nfallback: truncate + warn"]
lang --> revai["Rev.AI\n~75 explicit entries\nfallback: auto-detect + warn"]
lang --> whisper["Whisper\npycountry lookup\nhard error on unknown"]
lang --> tencent["Tencent\nHan script: 16k_zh_large\nelse 16k_ plus ISO 639-1\nrefused when there is none"]
lang --> aliyun["Aliyun\nCantonese-only\n(yue)"]
lang --> funasr["FunASR\nISO 639-3 passed through"]
lang --> num["Number Expansion\n12 table languages\nothers: passthrough"]
lang --> utr["UTR Strategy\neng → auto/two-pass\nothers → global"]
%% All engines now have explicit validation, no red highlights needed
Pre-Validation: Language Support Diagnostics
Requirement: Every engine must validate language support before processing begins and produce a clear, actionable diagnostic. Users should never see a cryptic HTTP 400 from Rev.AI or get silently wrong English transcriptions because Whisper didn’t know their language.
The Problem
When a user passes --lang hak (Hakka), errors surface at different
points depending on the engine, all too late:
| Engine | When error surfaces | Error message | User impact |
|---|---|---|---|
| Rev.AI | At job submission | Clear error with alternatives | None, user redirected to Whisper/Tencent |
| Whisper | At inference time | ValueError: Unrecognized ISO 639-3 code | Clear error, no silent fallback |
| Stanza | At job submission | Clear error listing all 55 supported codes | None, immediate feedback |
| Tencent | At job submission | Clear error with alternatives | None, user redirected to Whisper/Rev.AI |
| Aliyun | At job submission | Clear error: Cantonese only | None, user redirected |
Required Behavior
Validation should happen at job submission time (POST /jobs), before
any audio processing or model loading. The diagnostic should name the
engine, the unsupported language, and suggest alternatives:
Error: Language 'hak' (Hakka) is not supported by Rev.AI ASR.
Supported alternatives:
- Use --asr-engine whisper for Hakka (Whisper supports all languages)
- Use --lang auto to let Rev.AI auto-detect the language
- Use --asr-engine tencent for Chinese/Hakka via Tencent ASR
Per-Engine Language Support: Can We Query at Runtime?
No engine provides a runtime “what languages do you support?” API. All language support is determined by static tables, either hardcoded in our mapping code or documented on vendor websites. This means we must maintain our own validation tables.
Rev.AI (cloud ASR)
- No API endpoint to query supported languages
- Supported languages documented on Rev.AI’s website only
- Our mapping:
try_revai_language_hint()inrevai/preflight.rs(~75 entries) - Validation approach:
try_revai_language_hint(lang)returnsNonefor unsupported languages; callers log a warning and fall back to"auto" - Important runtime nuance: Rev
"auto"is a real second request path, not just a late alias for English. If Rev language ID resolves to English before submission, BA3 uses the explicit-English request settings. If language ID fails and BA3 submits true Rev auto, later downstream stages may still resolve the transcript to English, but the provider request was different. - Example validation:
if try_revai_language_hint(&lang).is_none() { warn!("Language {lang} not in Rev.AI supported set; using auto-detection"); }
Whisper (local HuggingFace ASR)
- No programmatic language list in the model API
- The model’s
GenerationConfighasis_multilingual: truebut no language list; supported languages are implicit in the tokenizer’s language tokens - Our mapping:
iso3_to_language_name()inbatchalign/inference/asr.pyusespycountrylookup with special cases foryue/cmn - Fixed (2026-03-19): Unknown codes now raise
ValueErrorinstead of silently falling back to English. - Whisper
large-v3nominally supports 99 languages, but quality varies dramatically (English/European languages are much better than others)
Stanza (morphosyntax)
- Stanza has a
stanza.resourcesmodule that lists available models; batchalign queries it at worker startup (stanza.resources.common.load_resources_json()) and caches a per-language capability table inbatchalign/worker/_stanza_capabilities.py. - Our ISO-639-3 → alpha-2 mapping:
iso3_to_alpha2()inbatchalign/worker/_stanza_loading.py:63, with Stanza-specific overrides in_ISO3_OVERRIDES(_stanza_capabilities.py:50). - Validation approach: Check the cached capability table before
attempting to load the pipeline, the same path that drives
should_request_mwt()and the rest of per-processor availability.
Tencent (cloud ASR)
- No API endpoint listing supported languages
- The
EngineModelTypeis chosen in RUST, at plan time, bymodel_manifest::tencent_engine_model_type. Every Chinese variety takes16k_zh_large, decided byWritingSystem::of_language_code(the same Han-script table translation rendering uses); every other language takes16k_plus its ISO 639-1 code, from the one conversion described above. - A language with no ISO 639-1 code is refused at plan time, by name,
because Tencent names its models by two-letter code and there is nothing
correct to send.
--lang autois refused for the same reason. - This replaced a Python derivation whose own five-code Chinese list omitted
Mandarin, so
cmnasked for a16k_cmnmodel that Tencent does not define and the job failed at the provider. Tencent was never Chinese-only: the derivation always had a non-Chinese branch.
Aliyun (cloud Cantonese ASR)
- No API endpoint; hardcoded to Cantonese only
- Already raises
ValueErroriflang != "yue": same timing issue
Google Translate / SeamlessM4T
- Google supports ~130 languages; SeamlessM4T ~96 languages
- No pre-validation in batchalign3: language is passed through directly
- Both have online documentation of supported languages but no programmatic query
NeMo / Pyannote (speaker diarization)
- Language-independent: diarization works on any language
- No validation needed
Cached Language Support Table
Since no engine provides a runtime query API, we maintain a time-stamped reference table of known language support. This table should be updated whenever we upgrade an engine dependency or discover new support/regressions.
Last verified: 2026-03-19
| ISO 639-3 | Language | Rev.AI | Whisper | Stanza | Tencent | Notes |
|---|---|---|---|---|---|---|
eng | English | en | english | en | , | Best quality across all engines |
spa | Spanish | es | spanish | es | , | |
fra | French | fr | french | fr | , | |
deu | German | de | german | de | , | |
ita | Italian | it | italian | it | , | |
por | Portuguese | pt | portuguese | pt | , | |
nld | Dutch | nl | dutch | nl | , | |
jpn | Japanese | ja | japanese | ja | , | MWT disabled |
kor | Korean | ko | korean | ko | , | MWT disabled |
rus | Russian | ru | russian | ru | , | |
ara | Arabic | ar | arabic | ar | , | RTL punctuation |
tur | Turkish | tr | turkish | tr | , | |
zho | Chinese | cmn | chinese | zh | 16k_zh_large | Maps to cmn/zh depending on engine |
cmn | Mandarin | cmn | chinese | zh | 16k_zh_large | UTSeg model available |
yue | Cantonese | , | cantonese | zh | 16k_zh_large | Fine-tuned Whisper; Aliyun/CantoneseFA; UTSeg model |
hak | Hakka | , | hakka | , | 16k_zh_large | Rev.AI unsupported; Stanza unsupported |
pol | Polish | pl | polish | pl | , | |
ces | Czech | cs | czech | cs | , | |
ron | Romanian | ro | romanian | ro | , | |
hun | Hungarian | hu | hungarian | hu | , | |
heb | Hebrew | he | hebrew | he | , | Fine-tuned Whisper; RTL punctuation |
hin | Hindi | hi | hindi | hi | , | |
cym | Welsh | cy | welsh | cy | , | |
srp | Serbian | sr | serbian | sr | , | |
afr | Afrikaans | af | afrikaans | af | , | |
fin | Finnish | fi | finnish | fi | , | |
dan | Danish | da | danish | da | , | |
swe | Swedish | sv | swedish | sv | , | |
nor | Norwegian | no | norwegian | nb | , |
(Partial list, full table covers ~75 Rev.AI languages. , = not supported.)
Maintenance policy: Update this table whenever:
- A new engine version is deployed (check changelogs for language additions)
- A user reports a language failure (add the language with its support status)
- We add a new engine to batchalign3
Implementation Plan
A validate_language_support(lang, command, engines) function should:
- Check each engine the command will use against its supported language set
- Return a structured diagnostic listing which engines support the language and which don’t, with suggested alternatives
- Be called at job submission time (Rust server), before any processing
- Optionally be callable from the CLI for
batchalign3 setup --check-lang hak
This is tracked as a future improvement. The Rev.AI mapping table
(try_revai_language_hint) provides the pattern to follow for other engines.
Adding a New Language
To add language-specific behavior for a new language:
- Stanza mapping: Add to
_ISO3_OVERRIDESinbatchalign/worker/_stanza_capabilities.pyonly when the standard alpha-2 mapping does not match Stanza’s catalog key. - Rev.AI mapping: Add an explicit entry in
crates/batchalign/src/revai/preflight.rs::try_revai_language_hint(do NOT rely on the truncation fallback). - ASR model: Optionally add a fine-tune, with the exact hub commit to pin
it to, to
crates/batchalign/src/model_manifest.rs::WHISPER_HUB_DEFAULTSwith a dated provenance comment; users select it via--asr-engine whisper_hub. That is the table a planned job resolves from, so an entry added only tobatchalign/models/resolve.pyleaves the job refused withWhisperHubHasNoDefaultModel. - Number expansion: Add an entry to
crates/batchalign-transform/data/num2lang.json, or handle the language incrates/batchalign-transform/src/asr_postprocess/num2text.rs. - Utterance segmentation: Optionally train a BERT boundary
model and add it, with the exact hub commit to pin it to, to
crates/batchalign/src/model_manifest.rs::UTSEG_BOUNDARY_MODELS. That one edit both makes the language routable and pins what it loads. - Morphosyntax workarounds: Add a
crates/batchalign-transform/src/morphosyntax/lang_XX.rsfile if Stanza produces systematic errors for this language (see existinglang_en.rs,lang_fr.rs,lang_it.rs,lang_ja.rs). - MWT dispatch: No code change required; capability-driven
should_request_mwt()picks up Stanza’s catalog automatically. - Test with
benchmark: Verify the language code is accepted by all engines in the pipeline (ASR, morphotag, compare).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Language Handling in CHAT: Complete Data Model
Status: Current Last updated: 2026-05-20 20:21 EDT
Overview
CHAT supports multi-level language specification for multilingual corpora and code-switching analysis:
- File level:
@Languagesheader declares all languages used in the file - Tier level:
[- lang]directive switches utterance language - Word level:
@sand@s:langmarkers for code-switched individual words
This document explains how these layers work together and how our data model represents them.
1. File-Level Languages: @Languages Header
Purpose
Declares the primary, secondary, and optionally tertiary languages used throughout the file.
Syntax
@Languages: eng, spa, fra
^^^ ^^^ ^^^
1° 2° 3°
- Primary language (1st position): Default for all tiers and words
- Secondary language (2nd position): Referenced by bare
@sshortcut - Tertiary+ languages (3rd+ positions): Must use explicit
@s:langmarkers
Data Model
Header enum:
pub enum Header {
Languages { codes: LanguageCodes },
// ...
}
pub struct LanguageCodes(pub Vec<LanguageCode>);
Extraction (from ChatFile):
let declared_languages: Vec<LanguageCode> = chat_file
.headers()
.find_map(|h| {
if let Header::Languages { codes } = h {
Some(codes.0.clone()) // LanguageCodes tuple struct
} else {
None
}
})
.unwrap_or_else(|| vec![primary_lang.clone()]);
Example
@Languages: eng, spa
@Participants: CHI Child, MOT Mother
@ID: eng|corpus|CHI|...
@ID: eng|corpus|MOT|...
*CHI: I want biberon@s please .
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Primary language (eng)
^^^^^^^
Secondary language (spa), marked with @s
2. Tier-Level Language: [- lang] Directive
Purpose
Switches the language for an entire utterance. Used when a speaker produces a full utterance in a different language.
Syntax
*MOT: hola cómo estás [- spa] ?
^^^^^^^^^^^^^^^
Entire utterance is Spanish
The directive applies to the entire tier (all words in the utterance).
Data Model
MainTierContent struct:
pub struct MainTierContent {
pub content: Vec<UtteranceContent>,
pub terminator: Option<Terminator>,
pub language_code: Option<LanguageCode>, // ← [- lang] directive
// ...
}
language_code: None→ Use primary language from @Languageslanguage_code: Some("spa")→ This utterance is Spanish
Morphosyntax Processing
When Rust builds batch payloads for Stanza, it includes the tier language:
let utterance_lang = utt.main.content.language_code
.clone()
.unwrap_or_else(|| primary_lang.clone());
// Include in batch payload
MorphosyntaxBatchItem {
words: vec!["hola", "cómo", "estás"],
lang: utterance_lang, // "spa"
// ...
}
Python then routes the entire utterance to the Spanish Stanza model.
Skipping vs. Processing
skipmultilang flag:
true: Skip utterances with[- lang]directive (only process primary language)false(default): Process utterances in their declared language
let skip = skipmultilang
&& utt.main.content.language_code.is_some()
&& utt.main.content.language_code.as_ref() != Some(&primary_lang);
if !skip {
// Process with Stanza model for utterance_lang
}
3. Word-Level Language: @s Markers
Purpose
Marks individual code-switched words within an utterance.
Syntax
| Marker | Meaning | Example |
|---|---|---|
@s | Shortcut: secondary language from @Languages | biberon@s → Spanish |
@s:spa | Explicit single language | biberon@s:spa → Spanish |
@s:eng+fra | Multiple languages (word legal in both) | cafe@s:eng+fra |
@s:eng&spa | Ambiguous (could be either language) | no@s:eng&spa |
Data Model: CHAT Syntax Layer
Word struct:
pub struct Word {
// ...
pub lang: Option<WordLanguageMarker>,
}
pub enum WordLanguageMarker {
/// Bare @s (shortcut to secondary language)
Shortcut,
/// Single explicit language @s:spa
Explicit(WordLanguage),
/// Multiple languages @s:eng+fra (legal in all)
Multiple(Vec<WordLanguage>),
/// Ambiguous languages @s:eng&spa (unclear which)
Ambiguous(Vec<WordLanguage>),
}
pub struct WordLanguage {
pub code: Option<LanguageCode>, // ISO 639-3 code
pub variant: Option<String>, // Optional variant like :spa%mex
}
This is the CHAT syntax representation - it preserves the exact marker as written in the file.
Data Model: Semantic Resolution Layer
LanguageResolution enum (from resolve_word_language()):
pub enum LanguageResolution {
/// Single definite language (after resolving @s shortcut)
Single(LanguageCode),
/// Multiple languages (code-mixing): @s:eng+fra
Multiple(Vec<LanguageCode>),
/// Ambiguous between languages: @s:eng&spa
Ambiguous(Vec<LanguageCode>),
/// No language could be resolved (error)
Unresolved,
}
This is the semantic representation - it resolves shortcuts and produces actual language codes.
Resolution Algorithm
resolve_word_language() function:
pub fn resolve_word_language(
word: &Word,
tier_language: Option<&LanguageCode>,
declared_languages: &[LanguageCode],
) -> (LanguageResolution, Vec<ParseError>)
Resolution rules:
-
@s shortcut → Secondary language from @Languages
- Example:
@Languages: eng, spa→@sresolves tospa - Error if used in tertiary language tier (E244)
- Example:
-
@s:spa → Single(spa)
-
@s:eng+fra →
Multiple([eng, fra]) -
@s:eng&spa →
Ambiguous([eng, spa]) -
No marker → Use tier language (or primary if no tier directive)
Example Resolution
Input CHAT:
@Languages: eng, spa, fra
*CHI: I want biberon@s and croissant@s:fra please .
Resolution:
"I"→ No marker → Single(eng) (tier default)"want"→ No marker → Single(eng)"biberon@s"→ Shortcut → Single(spa) (secondary language)"and"→ No marker → Single(eng)"croissant@s:fra"→ Explicit → Single(fra)"please"→ No marker → Single(eng)
4. Batch Payload: Rust → Python
Current Implementation (Semantic Resolution)
When building morphosyntax batch payloads, Rust:
- Resolves each word’s language using
resolve_word_language() - Sends semantic resolution, not CHAT syntax
Batch payload struct:
#[derive(serde::Serialize, serde::Deserialize)]
struct MorphosyntaxBatchItem {
words: Vec<String>, // Word texts
terminator: String, // ".", "?", "!"
special_forms: Vec<(
Option<FormType>, // @c, @s, @b markers
Option<LanguageResolution>, // Resolved language
)>,
lang: LanguageCode, // Tier-level language
}
Example JSON payload:
{
"words": ["I", "want", "biberon", "please"],
"special_forms": [
[null, null], // "I" - no marker, primary lang
[null, null], // "want"
["S", {"Single": "spa"}], // "biberon@s" → resolved to Spanish
[null, null] // "please"
],
"lang": "eng" // Utterance language
}
What Python receives (semantics, not syntax):
"spa"- resolved language code, notShortcutenum variant{"Multiple": ["eng", "fra"]}- multiple languages, not+syntax{"Ambiguous": ["eng", "spa"]}- ambiguous, not&syntax
Why Semantic Resolution?
Problem with sending syntax:
Python would receive Shortcut and need to:
- Know the @Languages header order
- Know the current tier language
- Implement the same resolution logic as Rust
Solution with semantic resolution: Rust handles all resolution complexity once; Python receives clean language codes ready for routing.
5. Current Morphosyntax Behavior
Per-Utterance Language Routing ✅ IMPLEMENTED
Mechanism: [- lang] directive
Routing: Group utterances by lang field, send entire batches to appropriate Stanza model
# Python batch callback groups by language
by_lang = defaultdict(list)
for item in batch:
by_lang[item["lang"]].append(item)
# Route to language-specific models
for lang_code, items in by_lang.items():
if lang_code == "spa":
results = stanza_es.process(items)
elif lang_code == "fra":
results = stanza_fr.process(items)
# ...
Status: Fully working since 2026-02-15.
Per-Word Language Routing ✅ IMPLEMENTED WITH FALLBACK
Current behavior:
- The primary morphosyntax pass still emits
L2|xxxas the safe intermediate placeholder for language-marked words. talkbank-transformthen extracts deferred@spositions, plans contiguous spans plus host-side attachment, and asksbatchalignonly for the secondary Stanza worker dispatch.- Successful secondary results are merged back into
%mor/%gra; only unresolved, ambiguous, unsupported, or explicitly opted-out cases remainL2|xxx.
Why keep L2|xxx as an intermediate / fallback?
- the primary model still cannot be trusted for foreign-word morphology
L2|xxxis the honest fallback when no single trustworthy secondary route exists- splice/lowering needs a safe placeholder before secondary dispatch completes
What’s still limited:
@s:eng+spa/@s:eng&spado not dispatch because there is no single target- unsupported secondary languages remain
L2|xxx(see “Unsupported non-primary languages” below for the handling contract)
Unsupported non-primary languages:
morphotag only requires the primary @Languages code to be
Stanza-supported; files whose primary is unsupported are skipped with a
typed diagnostic before the pipeline runs. When the primary IS
supported, unsupported non-primary content is processed cleanly with an
L2|xxx fallback rather than crashing the worker:
[- UNSUPPORTEDLANG]whole-utterance precodes, the utterance is grouped underUNSUPPORTEDLANG, the worker partitions that group out of the dispatch list (partition_groups_by_stanza_support), and every word receivesL2|xxx.@s:UNSUPPORTEDLANGper-word markers, the secondary L2 dispatch span is short-circuited the same way; the host primary analysis is preserved and the marker’s slot staysL2|xxx.
Other utterances and spans in the same file that target supported languages continue to receive real morphology.
Validation / repair policy around that behavior:
- explicit
@s:LANGstill resolves and dispatches even whenLANGis absent from@Languages, but validation emits warn-only E254 so the header drift is visible - whole-utterance same-language all-
@sruns now raise E255 and must be normalized to[- lang]rather than treated as acceptable shorthand chatter debug fix-sis the repair path for both cases: it rewrites the qualifying whole-utterance pattern, clears bare@sshortcuts on fillers and nonwords as well as on regular words (so that the new[- LANG]precode does not flip filler resolution), and appends missing explicit languages to@Languages. The predicate only fires when every word-bearing item, including fillers, nonwords, and retraced material, carries an explicit language attribution resolving to the same target.
Current boundary:
- per-word routing for resolvable
@swords is implemented - conservative fallback remains for the unresolved / unsupported cases
6. Special Cases and Edge Cases
Tertiary Languages Need Explicit Markers
Valid:
@Languages: eng, spa, fra
*CHI: I want croissant@s:fra .
^^^^^^^^^^^^^^^
Explicit @s:fra marker required
Invalid (error E244):
@Languages: eng, spa, fra
*CHI: [- fra] je veux croissant@s .
^^^^^^^
@s shortcut not allowed in tertiary tier
Why: @s shortcut only resolves to secondary language (2nd position). Tertiary languages must use explicit @s:lang.
Multiple/Ambiguous Languages
Multiple (+): Word is valid in ALL listed languages
*CHI: I want cafe@s:eng+fra .
^^^^
English "café" or French "café" - both valid
Ambiguous (&): Unclear which language the word belongs to
*CHI: no@s:eng&spa quiero .
^^
English "no" or Spanish "no"? Ambiguous.
Validation: Both forms require the word to be valid in ALL listed languages.
No @Languages Header
If @Languages is missing:
- Inferred from
@IDheaders (first 3-letter language code) - Falls back to primary language passed to processing function
7. Validation and Errors
Language Resolution Errors
| Error | Trigger | Example |
|---|---|---|
| E244 | @s shortcut in tertiary language tier | [- fra] word@s when fra is 3rd+ language |
| E254 | Explicit @s:LANG language missing from @Languages | @Languages: eng with hola@s:spa |
| E255 | Whole-utterance same-language all-@s pattern where [- lang] should be used | hola@s como@s estas@s . |
| E361 | Invalid language code | word@s:xyz (xyz not in ISO 639-3) |
| Unresolved | No language context available | Word with @s but no @Languages header |
Validation Context
let (resolved, errors) = resolve_word_language(
&word,
tier_language,
&declared_languages,
);
Errors are collected during resolution and reported via the validation system.
8. Summary Tables
Language Scope Levels
| Level | Syntax | Scope | Data Model Field |
|---|---|---|---|
| File | @Languages: eng, spa | All utterances | Header::Languages { codes } |
| Tier | [- spa] | Single utterance | MainTierContent.language_code |
| Word | @s:spa | Single word | Word.lang |
Word Language Markers
| Marker | Meaning | Syntax Enum | Resolved Enum | Example |
|---|---|---|---|---|
| (none) | Tier default | None | Single(tier_lang) | hello |
@s | Secondary lang | Shortcut | Single(spa) | biberon@s |
@s:spa | Explicit | Explicit(spa) | Single(spa) | biberon@s:spa |
@s:eng+fra | Multiple | Multiple([eng,fra]) | Multiple([eng,fra]) | cafe@s:eng+fra |
@s:eng&spa | Ambiguous | Ambiguous([eng,spa]) | Ambiguous([eng,spa]) | no@s:eng&spa |
Morphosyntax Processing Status
| Feature | Scope | Status | Implementation |
|---|---|---|---|
| Per-utterance routing | [- lang] | ✅ Implemented | Rust batches by tier language, Python routes to Stanza |
| Per-word routing | @s:lang | ✅ Implemented with fallback | Transform-layer L2 planning + secondary dispatch; unresolved/unsupported cases remain L2|xxx |
9. Code References
Rust (talkbank-model)
- Language syntax markers:
../chatter/crates/talkbank-model/src/model/content/word/language.rs:WordLanguageMarkerenum (Shortcut, Explicit, Multiple, Ambiguous) - Resolution logic:
../chatter/crates/talkbank-model/src/validation/word/language/resolve.rs:resolve_word_language()function - Header language list:
../chatter/crates/talkbank-model/src/model/header/header_enum/header.rs:Header::Languages { codes } - Tier language directive:
../chatter/crates/talkbank-model/src/model/content/main_tier.rs:MainTierContent.language_codefield
Rust (talkbank-transform - payload & injection)
- Batch payload definition:
crates/batchalign-transform/src/morphosyntax/payload.rs:29-55:MorphosyntaxBatchItemstruct (words, terminator, special_forms, lang) - L2|xxx placeholder for unresolved words:
crates/batchalign-transform/src/morphosyntax/injection.rs: replaces pos withPosCategory::new("L2")when word has language marker but isn’t routed - L2 splice logic:
crates/batchalign-transform/src/morphosyntax/l2/splice.rs: replaces L2|xxx with real morphology after secondary dispatch
Rust (batchalign - orchestration)
- Batch collection & language grouping:
crates/batchalign/src/morphosyntax/mod.rs:72::run_morphosyntax_implis the orchestration entry; per-utterance language grouping for the secondary L2 path lives atcrates/batchalign/src/morphosyntax/batch.rs:31::dispatch_secondary_l2(file is 208 lines total)
Python (stateless inference only)
- Inference function:
batchalign/inference/morphosyntax.py:batch_infer_morphosyntax(): calls Stanzanlp()per language, returns raw UD output - Per-utterance language routing: Python worker receives
langfield, groups batch items by language, routes to appropriate Stanza model
10. Future Work (Low Priority)
The remaining low-priority work is no longer “per-word routing exists or not”; it is policy/refinement work on top of the implemented L2 dispatch path:
- warning/reporting surfaces for unresolved
@s:eng+spa/@s:eng&spa - policy-sensitive normalization around all-
@sutterances and headers - broader secondary-language quality work for weak or unsupported models
References
- CHAT Manual: https://talkbank.org/0info/manuals/CHAT.html
- ISO 639-3 Language Codes: https://iso639-3.sil.org/
- Related docs: Language Code Resolution, L2 Morphotag, L2 Handling
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
L2 and Language Switching
Status: Current behavior reference Last updated: 2026-05-06 20:33 EDT
Current behavior
Batchalign distinguishes between:
- utterance-level language directives
- word-level language markers such as
@sand@s:lang
L2 dispatch is on by default. The legacy L2|xxx behavior is
available via the --no-l2-morphotag opt-out.
%mor behavior A: real morphology via secondary dispatch (default)
For @s or @s:lang words, the pipeline routes each word to a
secondary-language Stanza model and merges the response with the
primary model’s structural analysis. The %mor tier carries real
POS/lemma/features:
# Default
%mor: ... adp|auf noun|film noun|study-Plur .
%mor behavior B: conservative L2|xxx (opt-out)
With --no-l2-morphotag, @s words are blanked:
- the word is recognized as foreign/code-switched
%moroutput isL2|xxx- no lexical/morphological analysis is preserved inside
%mor
This is the legacy behavior researchers cite in work published before the L2 morphotag feature landed, and remains the honest fallback when a secondary Stanza model is known to be weak.
# --no-l2-morphotag
%mor: ... adp|auf L2|xxx L2|xxx .
Validated at scale: 99.96% dispatch rate (16,838 / 16,845 @s
words successfully routed to a secondary-language Stanza model;
7 fell back to L2|xxx) across 19 language pairs in the
l2-eval-runs/2026-04-15/per-pair.csv aggregate eval.
Contractions expand correctly (it's@s:eng →
pron|it~aux|be), phrasal verbs are recognized (wake up@s →
verb|wake part|up). See
L2 Morphotag: Per-Word Code-Switching Analysis
for the full design and merge algorithm.
Utterance-level versus word-level behavior
Utterance-level
Utterance-level language directives affect utterance handling and routing boundaries.
Word-level
Word-level language markers identify foreign/code-switched words and, during
morphotag, do trigger per-word secondary-language routing when the target
language resolves cleanly and a supported Stanza path exists.
Current limits
The remaining conservative fallbacks are:
--no-l2-morphotagopts back into legacyL2|xxx- unresolved / ambiguous markers such as
@s:eng+spaor@s:eng&spado not dispatch to one secondary model - unsupported target languages still preserve the code-switch signal via
L2|xxxrather than inventing morphology
Unsupported non-primary languages
morphotag only requires the primary @Languages code to be
Stanza-supported; files whose primary is unsupported are skipped with
a typed diagnostic before the pipeline runs. When the primary IS
supported, non-primary content targeting an unsupported language is
processed cleanly with an L2|xxx fallback:
[- UNSUPPORTEDLANG]whole-utterance precodes, the utterance is grouped underUNSUPPORTEDLANG, the worker partitions that group out of Stanza dispatch (partition_groups_by_stanza_support), and every word receivesL2|xxx.@s:UNSUPPORTEDLANGper-word markers, the secondary L2 dispatch span is short-circuited the same way; the host primary analysis is preserved and the marker’s slot staysL2|xxx.
The worker never crashes on an unsupported secondary, and other utterances or spans in the same file targeting supported languages continue to receive real morphology.
Validation and normalization policy
- Whole-utterance same-language all-
@spatterns are rejected by validation (E255). The accepted transcript form is utterance-level[- lang], notword@s word@s ...for an entire utterance. - Explicit
@s:LANGstill dispatches toLANGeven whenLANGis missing from@Languages, but validation emits warn-only E254 so the header mismatch is visible. - Batchalign does not silently rewrite either case during morphotag. Use
chatter debug fix-sto normalize whole-utterance@sruns and append missing explicit languages to@Languageswithout touching already-correct files. The fix-s predicate verifies that every word-bearing item on the main tier, words, fillers (&~/&-/&+), nonwords, AND retraced material, carries an explicit language attribution resolving to the same target, and clears bare@sshortcuts on fillers and nonwords as part of the rewrite (otherwise the new[- LANG]precode would flip their resolved language).
Related references
- L2 Morphotag: Per-Word Code-Switching Analysis , full design, merge algorithm, phrasal-verb diagram
- Language Routing, per-utterance + per-word routing, auto-detection
- Language Data Model
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
MWT (Multi-Word Token) Handling
Status: Current Last updated: 2026-05-20 20:19 EDT
What Is MWT?
Multi-Word Tokens (MWT) are contractions that represent multiple syntactic words in a single orthographic token. English examples:
| Contraction | Expanded | %mor output |
|---|---|---|
| don’t | do + n’t | aux|do-Fin-Ind-Pres~part|not |
| I’m | I + ’m | pron|I-Prs-Nom-S1~aux|be-Fin-Ind-Pres-S1 |
| that’s | that + ’s | pron|that-Dem~aux|be-Fin-Ind-Pres-S3 |
| can’t | can + n’t | aux|can-Fin-S~part|not |
| where’s | where + ’s | adv|where~aux|be-Fin-Ind-Pres-S3 |
In CHAT %mor notation, expanded MWT components are joined with ~
(post-clitics) or $ (pre-clitics).
Current Stanza Tokenization Policy
Current batchalign3 follows the Python-master-style Stanza configuration
(tokenize_no_ssplit=True) rather than the older Rust
tokenize_pretokenized=True approach.
Why
Without MWT expansion, Stanza analyzes contractions as single words, producing linguistically incorrect POS tags:
| Word | With MWT (correct) | Without MWT (incorrect) |
|---|---|---|
| don’t | AUX “do” + PART “not” | ADV “don’t” |
| I’m | PRON “I” + AUX “be” | ADV “im” |
| that’s | PRON “that” + AUX “be” | AUX “that” |
| can’t | AUX “can” + PART “not” | INTJ “cant” |
On a Brown/Eve transcript (010600a.cha), Python master produces 644
~ joins. Without MWT, Rust produced 1. Of the differing %mor lines,
99.2% differed solely because of MWT.
The Two Stanza Tokenizer Modes
tokenize_pretokenized=True (original Rust approach):
Stanza’s tokenizer is completely bypassed. Each whitespace-separated
token becomes a single Stanza Token with a single Word. The MWT
processor still runs, but because “don’t” was never split by the
tokenizer, MWT sees it as atomic and does not expand it.
tokenize_no_ssplit=True (Python master, now our approach):
Stanza’s neural tokenizer runs, it splits text into tokens, including
splitting contractions (“don’t” -> “do” + “n’t”), but does not insert
sentence boundaries. The MWT processor then annotates these splits
with range IDs (id: [2, 3]).
Why tokenize_pretokenized Was Originally Chosen
The Rust implementation originally chose tokenize_pretokenized=True
for a specific reason: guaranteed 1:1 token mapping. With the
tokenizer bypassed, the number of input tokens exactly equals the
number of Stanza tokens, making it trivially safe to zip Stanza’s
output back onto CHAT AST words.
The problem: this guarantee comes at the cost of losing MWT entirely, which means all contractions in English (and French, Italian, etc.) get wrong POS tags.
What We Changed
- MWT-capable languages (English, French, Italian, etc.): Switch
to
tokenize_no_ssplit=True+ atokenize_postprocessorcallback that merges spurious tokenizer splits back to original CHAT words. English uses the GUM MWT package (package={"mwt": "gum"}). - Non-MWT languages (Japanese, Chinese, Korean, etc.): Keep
tokenize_pretokenized=Truefor safety. The neural tokenizer would re-segment already-tokenized CJK text unpredictably.
MWT eligibility is capability-driven:
should_request_mwt(alpha2, get_cached_capability_table()) at
batchalign/worker/_stanza_loading.py:40 consults the cached Stanza
catalog (batchalign/worker/_stanza_capabilities.py) and requests the
mwt processor only when the table reports has_mwt=True for the
language. The earlier hardcoded MWT_LANGS set was deleted, with
test_stanza_config_parity.py:82 guarding against reintroduction.
The Core Problem: Stanza Creates “Words” That Don’t Exist in CHAT
When Stanza’s neural tokenizer runs, it can:
- Split contractions (intended): “don’t” -> “do”, “n’t”
- Split compounds (unintended): “ice-cream” -> “ice”, “-”, “cream”
- Normalize text (unintended): “cafe” for “café” (accent stripping)
- Re-segment (unintended): “l’homme” -> “l’”, “homme” (French)
These Stanza-created tokens are NOT valid CHAT words. CHAT’s word
grammar is strict (e.g., bare - is not a valid word). If these tokens
leaked into the CHAT main tier, the file would become unparseable.
Our Safety Guarantee: New Tokens Never Reach CHAT
The architecture enforces a hard type boundary between Stanza tokens and CHAT words:
CHAT main tier words (Word in Rust AST)
│
├─ extract_nlp_words() ──> list of strings sent to Python
│
├─ Python batch callback ──> Stanza processes strings ──> UdWord JSON
│
└─ map_ud_sentence() ──> Vec<Mor> ──> assigned to %mor dependent tier
At no point does a Stanza token become a CHAT Word. The Rust types
are distinct:
Word(CHAT AST node): Lives on the main tier. Created only during CHAT parsing. Immutable during morphosyntax processing.UdWord(Stanza output): Deserialized from Stanza’s JSON. Consumed bymap_ud_word_to_mor()to produceMornodes. Never stored in the CHAT AST.Mor(morphology node): Lives on the %mor dependent tier. Contains POS category, lemma, and features. OneMorper original CHAT word (with MWT components joined as clitics via~/$).
The Rust compiler enforces this separation, there is no function that
converts a UdWord into a Word. Even if Stanza splits “ice-cream”
into three tokens, the result is a single Mor node assigned to the
original “ice-cream” Word.
How Python Master Gets This Wrong
Python master has a --retokenize mode (ud.py:902-1004) that does
allow Stanza tokens to leak into the CHAT main tier:
# Python master, retokenize mode:
ut, end = chat_parse_utterance(
" ".join([i.text for i in sents[0].tokens]) + " " + ending,
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# This creates Word objects from STANZA TOKENS, not original CHAT words
mor, gra, None, None
)
# ...
doc.content[indx] = Utterance(content=ut, ...) # Overwrites main tier!
If Stanza normalizes “café” to “cafe” or splits “ice-cream” into three tokens, those new forms end up in the CHAT file. This is a data integrity bug.
By default, the main tier is always the original parsed CHAT, untouched by
Stanza. A --retokenize option exists (documented in args.rs) that
retokenizes the main tier to match UD tokenization; it bypasses the cache.
How We Handle Spurious Tokenizer Splits
When Stanza splits a compound word (“ice-cream” -> “ice”, “-”, “cream”), we merge those tokens back before they reach downstream processors.
Mechanism: Character-Position Mapping
The inference/_tokenizer_realign.py module implements a tokenize_postprocessor
callback that Stanza calls after tokenization but before POS tagging.
The key insight: Stanza’s tokenizer only re-splits the same characters , it never reorders, adds, or removes characters. This means:
concat(stanza_tokens, no_spaces) == concat(original_words, no_spaces)
This invariant lets us use a simple O(n) character-position map instead of an O(n*m) DP edit-distance alignment.
Algorithm
-
Build a character-to-word-index array from the original CHAT words:
"ice-cream know" -> [0,0,0,0,0,0,0,0,0, 1,1,1,1] i c e - c r e a m k n o w -
Build a character-to-token-index array from Stanza’s tokens:
"ice - cream know" -> [0,0,0, 1, 2,2,2,2,2, 3,3,3,3] i c e - c r e a m k n o w -
For each original word, collect which Stanza tokens have characters in that word’s range:
word 0 ("ice-cream") -> tokens [0, 1, 2] (need merging) word 1 ("know") -> tokens [3] (fine as-is) -
Merge multi-token groups back into single tokens.
What Happens When Characters Don’t Match
If Stanza normalizes text (e.g., accent stripping), the character sequences won’t match. In that case, we bail out immediately:
if ref_str != tok_str:
L.debug("Character mismatch, skipping")
return stanza_tokens # Return unchanged, no merging
This is strictly safer than the old DP approach, which can produce an alignment even when the character sequences differ (treating mismatches as edit operations), potentially allowing normalized forms to leak through.
Why Not DP Alignment (Like Python Master)?
Older Python master (pre-Rust migration) used a Levenshtein edit-distance DP aligner to match Stanza tokens back to original words at the character level. Current batchalign3 does not use that approach for retokenization:
| Property | DP Alignment (old) | Character-Position Map (current) |
|---|---|---|
| Complexity | O(n*m) with Hirschberg optimization | O(n) linear scan |
| Ambiguity | Equal-cost alignments broken arbitrarily | Deterministic, each character has exactly one position |
| Normalization | Accepts mismatches as edit operations | Rejects immediately on any character difference |
| Failure mode | May return wrong alignment silently | Returns tokens unchanged (safe fallback) |
How MWT Contractions Pass Through (Intentionally)
The postprocessor merges spurious splits but does not merge MWT contractions. When Stanza splits “don’t” into (“do”, “n’t”), these arrive as a tuple (Stanza’s internal MWT marker), not plain strings. The postprocessor treats tuples as MWT and returns them unchanged.
Python-side hint preservation. Rust handles MWT correctly in
crates/batchalign-transform/src/morphosyntax/injection.rs. On the
Python side, if _tokenizer_realign.py::_realign_sentence flattened
the (text, True) tuples to plain strings via _conform(tok) before
the Rust char-DP aligner ran, MWT would silently skip Range-token
expansion for every contraction. _realign_sentence overlays
Stanza’s original tuples onto aligner output where lengths match and
no merging happened, so the hint survives realignment and Stanza’s
MWT processor continues to honor it. Applies to every language for
which should_request_mwt() returns True in
batchalign/worker/_stanza_loading.py. See
Stanza Limitations, Defect 2 for the full
trace and re-evaluation criteria.
Stanza’s MWT processor then annotates these with Range markers
("id": [2, 3]), which the Rust code in sentence_mapping.rs handles:
// sentence_mapping.rs: map_ud_sentence()
UdId::Range(start, end) => {
// Group component words under one CHAT word index
for j in 0..count {
ud_to_chat_idx.insert(start + j, chat_idx);
}
chat_idx += 1; // One CHAT word, multiple UD words
}
The component words are assembled into a single Mor with clitic
markers:
// mapping_helpers.rs: assemble_mors()
// "do" (AUX) + "n't" (PART) -> aux|do~part|not
if is_clitic(text) {
post_clitics.push(mapped);
} else {
head = Some(mapped);
}
Thread Safety
The TokenizerContext is shared between the Stanza inference host (which sets
original_words) and the postprocessor (which reads them). Both execute under
the same nlp_lock:
# batchalign/inference/morphosyntax.py
with nlp_lock:
if tok_ctx is not None:
tok_ctx.original_words = word_lists # Set before nlp()
doc = nlp(combined) # Postprocessor reads during this call
if tok_ctx is not None:
tok_ctx.original_words = [] # Clear after
Comparison: What Each Approach Does With Edge Cases
Compound Splitting: “ice-cream” -> ["ice", "-", "cream"]
| Stage | Python Master | Our Approach |
|---|---|---|
| Stanza output | 3 tokens | 3 tokens |
| Postprocessor | DP aligns chars, merges back to 1 token | Char-position map, merges back to 1 token |
| %mor result | 1 Mor node for “ice-cream” | 1 Mor node for “ice-cream” |
| Main tier | Retokenize mode: risk of 3 words | Always original (1 word) |
Contraction: “don’t” -> ["do", "n't"]
| Stage | Python Master | Our Approach |
|---|---|---|
| Stanza output | 2 tokens with MWT Range [2,3] | Same |
| Postprocessor | Kept as MWT tuple | Kept as MWT tuple |
| %mor result | aux|do~part|not | aux|do~part|not |
| Main tier | Original “don’t” | Original “don’t” |
Possessive Apostrophe: “Claus’”
Stanza’s English GUM MWT model treats possessive apostrophes as MWT
contractions. For Claus', it produces two MWT components
[Claus (PROPN), ' (PUNCT)]. We follow Python master’s behavior:
| Stage | Python Master | Our Approach |
|---|---|---|
| Stanza output | Claus' MWT → [Claus, '] | Same |
| Postprocessor | ("Claus'", True): English+apostrophe → allow MWT | Same (_is_contraction returns True) |
| %mor result | propn|Claus~punct|' | propn|Claus~punct|' |
| CHAT validity | Valid | Valid |
clean_lemma defensive fix: When ' is isolated as a PUNCT MWT component,
Stanza’s lemma is also '. The old clean_lemma stripped the apostrophe,
producing an empty string → punct| (empty stem) → E342 parse failure.
crates/batchalign-transform/src/morphosyntax/mor_word.rs:81::clean_lemma
now falls back to the surface text when stripping produces empty:
clean_lemma("'", "'") returns ("'", false), producing punct|'
(valid). A debug_assert! at MorStem construction time catches any
future regressions (regression test
clean_lemma_falls_back_from_empty_to_text at mor_word.rs:221).
Accent Normalization: “café” -> “cafe”
| Stage | Python Master | Our Approach |
|---|---|---|
| Stanza output | “cafe” (accent stripped) | Same |
| Postprocessor | DP accepts mismatch as edit operation | Bail out: chars don’t match |
| %mor result | Based on “cafe” (wrong lemma) | Based on “cafe” (Stanza’s analysis, not merged) |
| Main tier | Retokenize: “cafe” leaks in | Always original “café” |
Unicode Decomposition: “naïve” (NFC) vs “nai\u0308ve” (NFD)
| Stage | Python Master | Our Approach |
|---|---|---|
| Stanza output | Possibly NFD-decomposed (6 chars vs 5) | Same |
| Postprocessor | DP: char count mismatch, Extra result | Bail out: char sequences differ |
| Main tier | Undefined behavior (breakpoint in dev) | Always original NFC form |
Architecture: Two-Layer Design
Morphosyntax processing has two distinct layers, each handling a different problem. They use different languages because they interface with different systems.
Layer 1: Python: Stanza Tokenizer Callback
File: inference/_tokenizer_realign.py
Runs: Inside stanza.Pipeline.__call__(), between the neural tokenizer and
the MWT/POS/depparse models.
Language: Python, Stanza’s tokenize_postprocessor API requires a Python
callable. This cannot be implemented in Rust because Stanza is a Python/PyTorch
library; it doesn’t expose C FFI or any other non-Python hook.
Responsibility: Tell Stanza’s MWT model whether a merged token should be treated as a contraction (expand it) or as an accidental split (suppress expansion).
This layer has no knowledge of CHAT, %mor, POS mapping, or language grammar. It only answers one question per merged token: “is this an MWT?”
The _is_contraction() function replicates Python master’s rule exactly:
# English tokens containing ' (except o' forms like o'clock) → True (allow MWT)
# Everything else → False (suppress MWT re-expansion)
def _is_contraction(text: str, alpha2: str) -> bool:
if "'" not in text or alpha2 != "en":
return False
parts = text.split("'")
if len(parts) >= 2 and parts[0].strip().lower() == "o":
return False
return True
The rule is tiny (4 lines) because the logic is simple, it’s just a knob on the neural MWT model, not a grammar.
Layer 2: Rust: UD → %mor/%gra Conversion
Primary module: crates/batchalign-transform/src/morphosyntax/: orchestrates the
full UD-to-CHAT mapping pipeline. Core components:
sentence_mapping.rs: maps UD sentences to CHAT structureinjection.rs: injects mapped results into transcriptssynthesis/: synthesizes final%morand%graoutputlang_en.rs,lang_fr.rs,lang_ja.rs: language-specific mapping rulesmapping_helpers.rs: common mapping utilities
Runs: After Stanza has produced POS tags, lemmas, and dependency relations.
Language: Rust, this layer has no Python dependency. It reads Stanza’s JSON
output (a Vec<UdWord>) and produces %mor/%gra strings.
Responsibility: All the substantive language-specific work:
- Map UD POS tags (VERB, NOUN, PRON…) to CHAT POS categories (verb|, noun|, pron|…)
- Apply POS-specific suffix rules (tense, number, case, degree…)
- Handle 200+ English irregular verbs (go → went, be → was/were/been…)
- Handle French pronominal clitics and APM markers
- Handle Japanese verb conjugation (140+ patterns)
- Assemble MWT components into clitic chains (
aux|do~part|not) - Build
%gradependency relations
Why the Split
CHAT words
│
│ extract_nlp_words() [Rust]
▼
"I don't know" (raw strings sent to Python)
│
│ stanza.Pipeline.__call__() [Python/PyTorch neural models]
│ ├── neural tokenizer: splits "don't" → [do, n't]
│ ├── tokenize_postprocessor [Python callback, Layer 1]
│ │ merges splits, annotates contractions with True/False
│ ├── MWT model: expands (don't, True) → do + n't with Range IDs
│ ├── POS model: PRON, AUX, PART, VERB
│ ├── lemma model: I, do, not, know
│ └── depparse model: subj, aux, advmod, root
▼
UdWord JSON (Stanza's output)
│
│ map_ud_sentence() [Rust: Layer 2]
▼
%mor: pron|I-Prs-Nom-S1 aux|do-Fin-Ind-Pres-S2~part|not verb|know-Inf
%gra: 1|4|SUBJ 2|4|AUX 3|2|NEG 4|0|ROOT
The Python callback (Layer 1) sits inside the Stanza call because that is the only point where we can influence tokenization. Once Stanza has produced its UD output, the Python layer is done, Rust takes over for all language-specific morphosyntax generation.
Rule of thumb: If the decision affects what tokens Stanza sees, it belongs in the Python callback (Layer 1). If the decision affects how Stanza’s UD output maps to CHAT %mor, it belongs in Rust (Layer 2).
Validation Results
Side-by-side on Brown/Eve 010600a.cha after implementation:
| Metric | Python master | Rust (before) | Rust (after) |
|---|---|---|---|
MWT ~ joins on %mor | 644 | 1 | 298* |
* The count difference (644 vs 298) is because Rust counts unique
%mor lines with ~, while Python master’s count includes duplicates
from repeated contractions. The actual MWT expansion coverage matches.
Example output:
Input: *CHI: I don't know .
%mor: pron|I-Prs-Nom-S1 aux|do-Fin-Ind-Pres-S2~part|not verb|know-Inf .
Code References
Layer 1: Python (Stanza Tokenizer Callback)
| Component | File | Description |
|---|---|---|
| MWT eligibility | batchalign/worker/_stanza_loading.py:40 | should_request_mwt(alpha2, capability_table): capability-driven, replaces the deleted MWT_LANGS static |
| Stanza capability table | batchalign/worker/_stanza_capabilities.py | Cached snapshot of stanza.resources.common.load_resources_json(); _ISO3_OVERRIDES at :50 handles Stanza-specific iso3 cases |
| Stanza config builder | batchalign/worker/_stanza_loading.py:126 | load_stanza_models(): chooses tokenizer mode, wires postprocessor |
| MWT contraction rule | batchalign/inference/_tokenizer_realign.py:120 | _is_contraction(): English+apostrophe → True (replicates BA2 ud.py:680-685) |
| Tokenizer realignment | batchalign/inference/_tokenizer_realign.py:148 | _realign_sentence(): character-position merging; merged tokens get (text, bool) tuples |
| Postprocessor factory | batchalign/inference/_tokenizer_realign.py:67 | make_tokenizer_postprocessor(): creates the Stanza callback; captures alpha2 in closure |
| Batch callback | batchalign/inference/morphosyntax.py:201 | batch_infer_morphosyntax(): sets/clears TokenizerContext.original_words |
Layer 2: Rust (UD → %mor/%gra)
All paths below are under crates/batchalign-transform/src/morphosyntax/.
| Component | File | Description |
|---|---|---|
| MWT grouping (merge mode) | sentence_mapping.rs:81::map_ud_sentence | UdId::Range groups MWT components under one CHAT word index |
| MWT grouping (expand mode) | sentence_mapping.rs:24::map_ud_sentence_expanded | Per-component MOR for --retokenize |
| Clitic assembly | mapping_helpers.rs:60::assemble_mors | Joins MWT components with ~ (post-clitic) or $ (pre-clitic) |
| POS mapping | mor_word.rs:13::map_ud_word_to_mor | UD UPOS → CHAT category; clean_lemma at mor_word.rs:81 with empty-string fallback |
| English rules | lang_en.rs | Irregular verbs (200+), suffix patterns per POS |
| French rules | lang_fr.rs | Pronominal clitics, APM, case agreement |
| Japanese rules | lang_ja.rs | Verb conjugation (140+ patterns) |
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
Retokenization: Overview
Status: Current Last updated: 2026-05-19 14:18 EDT
Retokenization in BA3 refers to two distinct transformations that happen to share the name. A reader encountering “retokenization” in BA3’s codebase or in this book may be looking at either. This chapter names them, routes the reader to the right reference chapter for each, and then documents the per-language coverage gap.
The two meanings
ASR-stream retokenization
- When it runs: at transcribe time, as Stage 6 of the ASR post-processing pipeline.
- What it does: splits the raw ASR token stream into utterances
by punctuation boundaries. Consumes
Vec<AsrWord>, producesVec<Utterance>. - Input shape: ordered ASR words from a provider (Rev.AI, Whisper, etc.) with timestamps.
- Output shape: speaker-attributed utterances ready for CHAT assembly.
- Source:
crates/batchalign-transform/src/asr_postprocess/mod.rsfunctionretokenize()(around line 872). - Always on: every transcribe run goes through this stage.
- Reference:
architecture/asr-token-pipeline.md§Stage 6.
Morphosyntax retokenization
- When it runs: at morphotag time, only when the user passes
--retokenize(vs the default--keeptokens). - What it does: reshapes CHAT utterance words to match Stanza’s
tokenization. Splits contractions, merges compounds, updates
%mor/%graalongside. - Input shape: CHAT AST
Utterance+ Stanza output (tokens + MWT + UD annotations). - Output shape: mutated
Utterancewith new word boundaries and injected morphosyntax tiers. - Source:
crates/batchalign-transform/src/retokenize/module (entry pointretokenize_utterance()inmod.rs). - Gated: opt-in via
--retokenizeCLI flag. - Reference:
reference/morphotag-retokenization.md(primary),reference/mwt-handling.md(MWT mechanics foundational to both Preserve and Retokenize modes).
Visualizing the distinction
flowchart LR
subgraph TranscribeTime["transcribe command (always)"]
ASR["Rev.AI / Whisper tokens"] --> Clean["talkbank-transform<br/>asr_postprocess/mod.rs<br/>stages 1-5"]
Clean --> ASRRetok["Stage 6: retokenize()<br/>split by punctuation"]
ASRRetok --> Disfl["stages 7-8<br/>disfluency, retrace"]
Disfl --> CHAT["CHAT file on disk"]
end
subgraph MorphotagTime["morphotag --retokenize (opt-in)"]
CHAT2["CHAT file on disk"] --> Parse["parse_lenient()"]
Parse --> Worker["Stanza worker:<br/>tokenize, pos, lemma, mwt"]
Worker --> MorphRetok["talkbank-transform<br/>retokenize_utterance()<br/>reshape CHAT to match Stanza"]
MorphRetok --> Inject["inject %mor / %gra"]
Inject --> CHATOut["CHAT file on disk"]
end
CHAT -.->|"days or years later"| CHAT2
The two are composable but unrelated. A file transcribed today
(running ASR-stream retokenization) can be morphotagged months
later with or without --retokenize; they share no code path.
Per-language coverage
The rest of this chapter focuses on morphosyntax retokenization, because that’s where the per-language complexity lives. ASR-stream retokenization is language-agnostic (it operates on punctuation boundaries).
BA3’s morphosyntax retokenization inherits its per-language competence from three sources, in order of leverage:
- Stanza’s native models and MWT processor: per-language neural tokenizers ship with each Stanza model. ~45 of the languages Stanza supports have an MWT processor.
- BA3’s Rust per-language overrides: morphology-level patches
on Stanza’s UD output (POS/lemma corrections, case features).
Live in
crates/batchalign-transform/src/morphosyntax/lang_{en,fr,it,ja}.rs. - BA3’s Python tokenizer realignment postprocessor,
batchalign/inference/_tokenizer_realign.py, a character-DP realigner that forces Stanza to respect BA3’s pre-tokenized input. Mostly language-agnostic; one English-specific contraction hint rule.
A fourth layer existed in batchalign2 at the jan9 snapshot but
was dropped in the rewrite:
- BA2’s
tokenizer_processor()+auxiliariesjoining pass, a hand-coded per-language tokenization override layer that expanded polyapostrophic contractions, joined clitics to hosts, and patched Stanza’s mis-splits for Italian, French, Portuguese, and English. Absent in BA3.
What moved where
Morphology-patching tables were fully ported from BA2 to BA3:
| BA2 file | Role | BA3 location |
|---|---|---|
morphosyntax/en/irr.py (170 verbs) | Past-tense irregularity flag | talkbank-transform/morphosyntax/lang_en.rs::IRREGULAR_VERBS |
morphosyntax/fr/case.py | Nominative/accusative pronoun lists | talkbank-transform/morphosyntax/lang_fr.rs::{PRON_NOM, PRON_ACC} |
morphosyntax/fr/apm.py + fr/apmn.py (158) | Auditory plural marking | talkbank-transform/morphosyntax/lang_fr.rs::APM_NOUNS |
morphosyntax/ja/verbforms.py (50+ patterns) | Verb-form POS/lemma patches | talkbank-transform/morphosyntax/lang_ja.rs |
| Cantonese normalization (Python OpenCC) | OpenCC s2hk + 31 domain replacements | talkbank-transform/asr_postprocess/cantonese.rs (Rust ferrous-opencc + domain table) |
These ports were faithful; the underlying semantics carry over.
What was dropped
BA2’s tokenization-override layer was not ported. Specifically, the following BA2 rules have no BA3 equivalent:
| BA2 rule | Phenomenon | BA2 location |
|---|---|---|
French jusqu' / puisqu' / quelqu' / aujourd'hui | Elision-prefix clitic handling | ud.py:422-431, 684-693 |
French polyapostrophic splitter (d'l'attraper) | Multi-level apostrophe expansion | ud.py:684-693 |
French c'est / l'ami / qu'il preservation | Basic clitic elision | via auxiliaries pass |
Italian ll' / gliel' / d' / c' / qual' / l' | Clitic + host joining | ud.py:410-419 |
Italian le + i → lei repair | Stanza mis-split fix | ud.py:667-672 |
Portuguese d'água | Idiomatic MWT force | ud.py:673-674 |
English apostrophe join (except o'clock) | Contraction handling | ud.py:694-697 |
BA3’s working assumption was that Stanza’s native MWT processor
would handle these. In practice Stanza handles some correctly
(French au, Italian dello) but not others.
The Italian xfails: where we know BA3 is worse than BA2
batchalign/tests/investigations/_cases/italian.py carries 8 xfail
cases pinned with defect slugs stanza-it-verb-clitic-pos-split
and stanza-it-la-sentence-initial-split:
| Case | Stanza behavior | BA2 would have |
|---|---|---|
dell_opera_in_context | parla → par + la (fake lemma), 4 UD for 3 CHAT | Joined via auxiliaries |
parla_3sg_storia_context | la → il + i, 7 UD for 6 CHAT | Handled via le+i → lei repair family |
parla_imperative_forte | Sentence-initial parla splits even with no clitic | Rejected by auxiliaries rules |
arancione_noun_bogus_verb | Noun arancione → arancio + ne (fake verb+clitic) | Wouldn’t apply, arancione not in clitic list |
piccolo_adj_bogus_verb | Adjective piccolo → picco + lo | Same |
gomitolo_noun_bogus_verb | Noun gomitolo → gomito + lo | Same |
divano_noun_bogus_verb | Noun divano → diva + no (invalid clitic ending) | Same |
These are pinned as “known Stanza limitations” but the BA2 perspective is that we had a working defense and gave it up.
The test-coverage compound problem
Beyond the logic gap, default CI doesn’t probe per-language
tokenization at all. The investigation probe matrix
(batchalign/tests/investigations/_cases/) covers French, Italian,
Portuguese, Dutch, Spanish, German for clitic / MWT phenomena,
but every case is @pytest.mark.golden, so make test and
default uv run pytest skip them.
A contributor can break BA3’s handling of c'est or d'água
tomorrow, push the change, and nothing in default CI will notice.
The probes exist but are on-demand.
Current work: gap investigation
A gap investigation is underway. Summary of the phased approach:
- Phase 0, Investigation (complete): cataloged BA2 overrides, BA3 ported vs absent tables, test-coverage state.
- Phase 1, Book docs (this chapter): distinguish the two retokenizations, document the per-language gap.
- Phase 2, Extend the Stanza decision-probe harness (v2):
add a token-count
Goldand newCandidateClassvariants for the absent rule families. - Phase 3, Seed probes for every BA2 rule family across every language BA3 supports (not only BA2-covered ones).
- Phase 4, Adjudicate per rule: decide port / replace / drop with evidence. Use the Q-B ruling (Stanza POS > hand-judgment gold) as the calibration standard.
- Phase 5, Implement only the rules adjudication demands, in the most principled home available (Stanza postprocessor extension, per-language Rust reconciler, or user lexicon).
- Phase 6, Graduate locked regression probes out of the
@pytest.mark.goldentier into default CI.
The program covers all languages BA3 supports, tiered by evidence strength:
- Tier A: English, French, Italian, Portuguese, Japanese, where BA2 had explicit rules.
- Tier B: Spanish, German, Dutch, Catalan, Polish, Romanian, Greek, Russian, Turkish, Arabic, Hebrew, Chinese, etc., Stanza-MWT-supported, no BA2 precedent.
- Tier C: remaining languages with minimal Stanza support, probe for minimal tokenization correctness only.
CHAT provenance emission
Two questions about whether retokenization should emit CHAT provenance annotations:
-
[: replacement]annotations on retokenization-induced word changes: neither BA2-jan9 nor BA3 emitted these; silent mutation is the status quo. The partial exception is the(elided)parens convention (e.g.'cause→(be)cause) which is already shipping as a lightweight form of provenance for contraction cases. No change planned. -
[= explanation]annotations for expanded acronyms, numbers, etc.: deferred indefinitely. Reviewers have flagged visual clutter when these annotations are dense. Silent mutation remains the norm; BA3 architecture should keep a “provenance-emit” mode possible as a future opt-in, but not bake it in now.
Both decisions prefer reader-friendly output over machine-auditable provenance. Implementations of new normalization rules (period stripping, I-cap, etc.) mutate word forms directly without emitting annotations.
Transcribe-time English orthographic rules
Three English orthographic corrections live in the transcribe
pipeline as narrow hooks inside the ASR post-processing stages.
They are not part of morphosyntax retokenization, they run at
transcribe time, before Stage 6, and are gated on lang == "eng".
Full spec, allowlists, and probe-verdict citations live in
English Transcribe Corrections.
flowchart TD
Raw["AsrOutput"]
TP["strip_english_title_periods_on_elements\n⚠ before stage 3"]
S3["stage 3 split → expansion → merges"]
IC["apply_english_transcribe_rules_pre_retokenize\n(I-cap)"]
S6["stage 6: retokenize by punctuation"]
UC["apply_english_transcribe_rules_post_retokenize\n(utterance-initial cap)"]
OK["Vec<Utterance>"]
Raw --> TP --> S3 --> IC --> S6 --> UC --> OK
The position of each hook is load-bearing: title-period MUST
precede stage 3’s .-separator split (otherwise Dr. fragments
before the allowlist sees it); I-cap sits on per-word chunks; the
utterance-initial cap runs after retrace detection so it can
skip WordKind::Retrace copies and land on the “real” first word.
Open questions
-
ReplacedWord / AnnotatedWord splits: today retokenize cannot split inside a replacement (1:1 text replacement only; see
crates/batchalign-transform/src/retokenize/rebuild.rs). A future per-language fix that needs to split inside an annotation must first lift this constraint. -
Stale
%worafter retokenize: perarchitecture/asr-token-pipeline.md,%woris a timing- annotation tier; re-tokenizing the main tier invalidates its bullet alignment. Today this is documented and accepted. A stricter retokenization program might need to regenerate%woror surface a warning. -
Hebrew 1-to-n mismatch: Stanza’s Hebrew MWT fires under our postprocessor, producing 1-to-2 or 1-to-3 UD-word splits for prefixed forms. No other probed language shows this. Worth a Hebrew-corpus investigation to determine whether downstream code actually breaks on these counts; if so, Hebrew may need a per-language postprocessor override.
-
Italian Defect 6/7/8 family: partially resolved. A Rust-side reconciler ships in
crates/batchalign-transform/src/morphosyntax/lang_it.rscovering two allowlist families:IT_MIS_SPLIT_OVERRIDES(parla,arancione,piccolo,gomitolo,divano,la) collapses Stanza’s bogusUdId::Rangeexpansions back onto the single CHAT word.IT_COMPOUND_IMPERATIVES(dammela,dammelo,prendilo,prendila,prendili,prendile) keeps genuine compound imperatives as one word in CHAT while preserving Stanza’s component POS/lemma on the collapsed MOR. Remaining follow-up:mettere-family lemma quality, dative-glie-stacks, 2pl imperatives, multi-chunk Defect 8 decomposition. See Italian language reference and Stanza limitations for the full shipped state and deferred work.
Cross-references
reference/morphotag-retokenization.md: primary morphosyntax retokenization chapterreference/mwt-handling.md: Multi-word token mechanics (foundation for both Preserve and Retokenize modes)reference/stanza-limitations.md: the xfailed Italian cases and other pinned Stanza defectsarchitecture/asr-token-pipeline.md: ASR-stream retokenization (Stage 6)architecture/preprocessing-postprocessing.md: pipeline orchestration- Dynamic Programming , why morphosyntax retokenize uses deterministic span-join rather than character DP (intentional simplification)
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
English Transcribe Corrections
Status: Current Last updated: 2026-05-01 09:47 EDT
BA3 applies three orthographic corrections to English ASR output
before CHAT assembly. Each rule ships silent-mutation (no
[: replacement] annotation, per the provenance-policy resolution)
and is gated to lang == "eng" so other languages are untouched.
The three rules
1. I-cap
Bare English pronoun i and its contractions are rewritten to
uppercase I:
| Input | Output |
|---|---|
i | I |
i'll | I'll |
i'm | I'm |
i've | I've |
i'd | I'd |
Implementation: crates/batchalign-transform/src/asr_postprocess/cleanup.rs::EN_I_CAP_REWRITES.
Idempotent (already-capitalized I passes through unchanged).
Probe-verdict lock: batchalign/tests/investigations/_decision_cases/english.py::_PRONOUN_I_CASES
(POST_NEUTRAL × 2) and _I_CONTRACTION_CASES (POST_NEUTRAL × 3).
Stanza’s POS tagging is case-invariant for these surfaces, the
rewrite is orthographic policy, not morphotag improvement.
2. Title-period strip
Trailing period(s) on a closed allowlist of English abbreviation surfaces are stripped:
| Family | Surfaces |
|---|---|
| Title | Dr., Mr., Mrs., Prof. |
| Place | St., Mt., Ave. |
| Time | a.m., p.m. |
| Initialism | U.S., J.F.K. |
| Degree | Ph.D., M.D. |
| Technical | etc., e.g., i.e. |
Implementation: crates/batchalign-transform/src/asr_postprocess/cleanup.rs::EN_TITLE_PERIOD_SURFACES.
Matching is case-insensitive; the non-period characters preserve
their original casing (DR. → DR, dr. → dr, Mr. → Mr).
Probe-verdict lock: six CandidateClass families in
_decision_cases/english.py: _TITLE_CASES, _PLACE_CASES,
_TIME_CASES, _INITIALISM_CASES, _DEGREE_CASES,
_TECHNICAL_CASES. All locked POST_NEUTRAL except Q-B-adjudicated
etc./eg/ie/M.D. which are POST_NEUTRAL per the Q-B
Stanza-POS-over-UD-EWT adjudication.
Closed-set design: the allowlist explicitly excludes
DECIMAL_CONTROL (3.14, 2.50) and SENTENCE_PERIOD (utterance-
final .) cases, which are locked POST_STRICTLY_WORSE in the
probe matrix, those would corrupt the output if the rule fired.
Pipeline hook position (critical): the period-strip fires
early in prepare_words_pre_expansion, before
split_multiword_tokens (stage 3). Stage 3’s
normalized_split_separator treats . as a word separator and
would slice Dr. into Dr + . before the allowlist could
match. Stripping on the raw AsrElement text keeps Dr as a
single token through every subsequent stage.
3. Utterance-initial cap
The first non-retrace, non-marker, non-empty word of every English utterance has its initial letter uppercased:
| Input utterance | Output utterance |
|---|---|
hello world . | Hello world . |
xxx said something . | xxx Said something . |
&-um &+go &~uh hello . | &-um &+go &~uh Hello . |
a [/] a [/] a . | a [/] a [/] A . (retrace chain) |
Implementation: crates/batchalign-transform/src/asr_postprocess/cleanup.rs::apply_utterance_initial_capitalization.
Exclusions (walked past to find the first “real” word):
- Untranscribed markers:
xxx,yyy,www. &-prefixed tokens: fillers (&-um), fragments (&+go), nonwords (&~uh).- Retrace copies (
WordKind::Retrace), the “real” word is at the end of the retrace chain, so the initial cap lands on it rather than on a false-start repetition. - Empty strings and pure-punctuation tokens.
Idempotent (already-capitalized first words pass through).
Probe-verdict lock: _UTTERANCE_INITIAL_CASES (POST_NEUTRAL × 3)
, hello, the, what all show case-invariant Stanza POS.
Pipeline integration
flowchart TD
A["AsrOutput\n(raw provider tokens)"]
B["prepare_words_pre_expansion:\nstages 1-3"]
C["strip_english_title_periods_on_elements\n⚠ BEFORE stage 3"]
D["finalize_words_to_chunks:\nstages 5-5b"]
E["apply_english_transcribe_rules_pre_retokenize\n(I-cap + title-period safety net)"]
F["utterances_from_prepared_chunks:\nstage 6 (retokenize by punct)"]
G["finalize_utterances:\nstages 7-8 + apply_english_transcribe_rules_post_retokenize\n(utterance-initial cap)"]
H["Vec of Utterance\nready for CHAT assembly"]
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
G --> H
The position of each hook is deliberate:
- Title-period strip on elements (before stage 3), stage 3
splits on
.and would fragmentDr.. Must run first. - I-cap on words (in
finalize_words_to_chunks), runs after number expansion and before retokenize. Per-word rewrite. - Utterance-initial cap (in
finalize_utterances), runs after utterances are formed and after retrace detection, so it can skip retrace copies to find the “real” first word.
Non-English languages
All three rules return immediately for lang != "eng". The
language gate is tested explicitly in
apply_english_transcribe_rules_skip_other_languages: an Italian
fixture ho visto i bambini . passes through untouched (Italian
i is the plural masculine article, which must NOT be uppercased
by the English rule).
Tests
- Unit tests in
crates/batchalign-transform/src/asr_postprocess/cleanup.rs::testscover each rule’s contract and combined-rule interactions. - Pipeline integration tests in the same module
(
period_strip_prevents_retokenize_mid_utterance_split,combined_rules_fire_per_utterance) verify the per-stage hook positions. - End-to-end transcribe tests in
crates/batchalign-transform/src/build_chat/tests.rs(english_transcribe_rules_fire_end_to_end,english_transcribe_rules_skip_other_languages) exercise the full in-process pipeline fromAsrOutputthroughbuild_chat.
Cross-references
_decision_cases/english.py: probe-verdict locks authorizing each rule.reference/retokenization-overview.md: how the three rules interact with the two “retokenization” meanings (ASR-stream retokenize at stage 6 vs morphosyntax retokenize at morphotag time).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Morphotag Retokenization
Status: Current Last updated: 2026-05-20 20:25 EDT
Purpose and audience
This page is the single entry point for understanding the retokenization
subsystem of batchalign3 morphotag. By the end you should know which mode
does what, how CHAT tokens flow through Stanza and back, where MWT hints
survive the round-trip, and how the BA3 pipeline differs from BA2. It is
aimed at a newcomer who has never read the Python or Rust morphosyntax code
before, and it links out to the deeper companion pages rather than repeating
them.
For deeper detail:
reference/mwt-handling.md, MWT mechanics in full.reference/stanza-limitations.md, Defect 2 (MWT hint preservation) is the root cause of the Preserve-mode MWT regression discussed below.reference/l2-morphotag-status.md, L2 usesretokenize=truefor secondary Stanza dispatch.developer/commands/morphotag.md, CLI wiring and dispatch sketch.
The problem: CHAT tokenization vs UD tokenization don’t match
CHAT transcripts and Universal Dependencies treebanks disagree on what a “word” is, and neither disagreement is wrong in its own domain.
CHAT tokenization is defined by the CLAN manual and is conservative about word boundaries. A transcriber writes one token per word they heard:
- Underscore-joined fillers.
&-you_know,&-sort_of,&-I_mean. - Hyphenated compounds.
daddy-o,ice-cream. Still one token. - Contractions kept whole.
don't,gonna,it's: one CHAT word each. @swords.hola@s:spatags an utterance-internal language switch without splitting the token.- Special forms.
xxx,yyy,www,&+,&~all carry semantics that are invisible to a UD tokenizer.
Stanza’s UD tokenizer, by contrast, aims at treebank conventions:
don't→do+n't(two UD words inside one Multi-Word Token, “MWT”).gonna→gon+na.- French
au→à+le. - Italian
lei, when mis-split by the model, must be re-merged to match treebank convention.
The tension is structural, not a bug: CHAT is a transcript-preserving
convention owned by the TalkBank manual; UD is a syntactic-annotation
convention owned by the treebank community. morphotag exists to marry
them, and retokenization is where we decide which side wins when they
disagree.
The two modes
morphotag exposes the tension as a user choice, controlled by the
--retokenize CLI flag and the TokenizationMode enum defined in
crates/batchalign-transform/src/morphosyntax/types.rs:
#![allow(unused)]
fn main() {
pub enum TokenizationMode {
Preserve, // default: CHAT main tier wins
StanzaRetokenize, // --retokenize: Stanza main tier wins
}
}
Preserve mode (default, --keeptokens)
The CHAT main tier is the source of truth. Stanza still analyses the text
and may internally produce MWTs; when it does, BA3 merges the MWT components
back into one MOR item with clitic syntax (~) so that the CHAT main tier
and %mor stay 1:1.
- Main tier stays untouched:
*PAR: I don't know . %moremits a single clitic-joined item:aux|do-Fin-Ind-Pres-S3~part|not- Mapped by
map_ud_sentence()incrates/batchalign-transform/src/morphosyntax/sentence_mapping.rs(line 77).
Retokenize mode (--retokenize)
Stanza’s tokenization is authoritative. The CHAT main tier is rewritten so each UD word is its own CHAT word, and each UD word gets its own MOR item.
- Main tier rewritten:
*PAR: I do n't know . %moremits two items:pro:sub|I aux|do-Fin-Ind-Pres-S3 part|not v|know .- Driven by
map_ud_sentence_expanded()(same file, line 24) for the MOR side, and byretokenize_utterance()incrates/batchalign-transform/src/retokenize.rs:195for the main tier rewrite (withparse_helpers.rsandrebuild.rsas siblings undercrates/batchalign-transform/src/retokenize/).
We chose this split because the two goals, “preserve the transcript” and “produce UD-shaped morphology”, are legitimate for different downstream tasks. CLAN-style workflows want Preserve; UD-trained parsers and treebank comparisons want Retokenize. Neither is a default “for everyone.”
Pipeline diagram (end-to-end, both modes)
The flowchart below traces a single CHAT utterance from Rust parse to CHAT write for both modes on the same page, so the divergence is visible at a glance. Every node labels its source file.
flowchart TD
Cha["CHAT file on disk"] --> Parse["parse_lenient()\n(talkbank-transform/src/parse.rs)"]
Parse --> Extract["extract_words()\n(talkbank-transform/src/extract.rs)"]
Extract --> Collect["collect_payloads()\n(talkbank-transform/src/morphosyntax/payload.rs)"]
Collect --> Worker["Python worker\n(batchalign/worker/_protocol.py)"]
Worker --> Load["load_stanza_model(lang)\n(batchalign/worker/_stanza_loading.py)"]
Load --> Infer["batch_infer_morphosyntax()\n(batchalign/inference/morphosyntax.py)"]
Infer --> Postproc["tokenize_postprocessor\n(batchalign/inference/_tokenizer_realign.py)"]
Postproc --> Stanza["Stanza Pipeline\n(tokenize + mwt + pos + lemma + depparse)"]
Stanza --> Raw["UdSentence JSON\n(ipc-schema/generated)"]
Raw --> Mode{"TokenizationMode\n(talkbank-transform/src/morphosyntax/types.rs:106)"}
Mode -->|"Preserve"| MapMerge["map_ud_sentence()\n(talkbank-transform/src/morphosyntax/sentence_mapping.rs:77)"]
MapMerge --> InjectP["inject_morphosyntax()\n(talkbank-transform/src/inject.rs)"]
InjectP --> Serial["to_chat_string()\n(talkbank-transform/src/serialize.rs)"]
Mode -->|"StanzaRetokenize"| MapExp["map_ud_sentence_expanded()\n(talkbank-transform/src/morphosyntax/sentence_mapping.rs:24)"]
MapExp --> Retok["retokenize_utterance()\n(talkbank-transform/src/retokenize.rs:195)"]
Retok --> Rebuild["rebuild_content()\n(talkbank-transform/src/retokenize/rebuild.rs)"]
Rebuild --> InjectR["(no separate inject step)"]
InjectR --> Serial
Serial --> Out["CHAT file with %mor / %gra"]
Retok -.->|"on mismatch"| Taint["mark_parse_taint(Main)\n(talkbank-transform/src/retokenize.rs)"]
Both branches end with inject_morphosyntax(): the tier-insertion machinery is mode-agnostic.
The difference is upstream:
- Preserve:
map_ud_sentence()merges MWT components into clitics - StanzaRetokenize:
map_ud_sentence_expanded()expands each component into a separate MOR item, thenretokenize_utterance()rewrites the main tier to match Stanza’s tokenization before callinginject_morphosyntax()
Character-DP realignment (the Python layer)
Stanza’s output does not line up cleanly with CHAT words, because Stanza’s
tokenizer may split, merge, or drop characters in ways that a naive 1:1
index cannot recover. batchalign/inference/_tokenizer_realign.py runs as
Stanza’s tokenize_postprocessor callback and reconciles the two.
We delegate the character-level alignment itself to
batchalign_core.align_tokens() (a Rust function, called from the Python
postprocessor). The Python wrapper supplies two extra things:
- A thread-local
TokenizerContextholdingoriginal_wordsfor the current batch. This is populated inbatch_infer_morphosyntax()inbatchalign/inference/morphosyntax.py(around line 346) and cleared immediately afternlp()returns. - MWT hint preservation. Stanza’s tokenizer natively emits
(text, True)tuples to ask its MWT processor to expand a token (English contractions, Frenchau, Italiandal, etc.). When we flatten tuples into plain strings to hand them to the Rust char-DP aligner, that hint is temporarily erased._realign_sentence()re-overlays the original tuples onto the aligner’s output when lengths match and no merging happened, so the downstream Stanza MWT processor can still see the hint and fire. See Defect 2 instanza-limitations.mdfor the full story.
The sequence below shows one nlp() call start-to-finish. It spans a
single Python process; “Rust server” and “Stanza MWT” are in-process
components, not remote services.
sequenceDiagram
participant R as "Rust server\n(batchalign)"
participant W as "Python worker\n(batchalign/worker)"
participant PP as "tokenize_postprocessor\n(_tokenizer_realign.py)"
participant Align as "align_tokens (Rust)\n(batchalign_core)"
participant MWT as "Stanza MWT processor"
participant Tail as "pos/lemma/depparse"
R->>W: infer_morphosyntax(words, lang)
W->>W: set ctx.original_words = word_lists
W->>PP: Stanza tokenize emits\n[[(don't, True), ...]]
PP->>PP: _conform() flattens tuples to strings
PP->>Align: align_tokens(original_words, flat_tokens, alpha2)
Align-->>PP: merged tokens (may add MWT patches)
PP->>PP: overlay stanza's (text,True) tuples\nwhere len(merged)==len(stanza) and text matches
PP-->>MWT: [(don't, True), know, ...]
MWT->>MWT: expand (don't, True) into Range(id=2-3)\nwith component words do + n't
MWT->>Tail: UD words with Range parents
Tail-->>W: doc.to_dict()
W-->>R: UdSentence with Range + components
Note over PP,MWT: If the realign step strips the<br/>tuple, MWT never fires and all<br/>contractions stay as single tokens.<br/>The overlay below prevents this.
The “overlay” step in _realign_sentence() is what keeps the hint
intact. When len(merged) == len(stanza_tokens) and the aligner
returned a plain string for a token whose original was a tuple with
the same text, the tuple is put back. This is not a cosmetic change:
without it Stanza’s MWT processor sees a plain string and silently
skips expansion.
MWT handling across the two modes
Both modes see the same UD output. They differ only in how they turn
UdSentence.words (which contains Range(start, end) parent tokens
followed by their components) into Vec<Mor>.
flowchart LR
UD["UdSentence\nwith Range + components"] --> Branch{"TokenizationMode?"}
Branch -->|"Preserve"| Merge["map_ud_sentence()\n(crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs:77)"]
Merge --> Assemble["assemble_mors()\n(crates/batchalign-transform/src/morphosyntax/mapping_helpers.rs)"]
Assemble --> OneMor["One Mor per CHAT word\naux|do~part|not"]
Branch -->|"StanzaRetokenize"| Expand["map_ud_sentence_expanded()\n(crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs:24)"]
Expand --> PerComp["One Mor per UD component\n(skip Range parent)\naux|do + part|not"]
PerComp --> Rewrite["retokenize_utterance()\n(crates/batchalign-transform/src/retokenize.rs:195)\nrewrites main tier"]
OneMor --> Inject["inject_morphosyntax()\n(crates/batchalign-transform/src/inject.rs:119)"]
Rewrite --> Inject
map_ud_sentence_expanded() skips the Range parent entirely and returns
one Mor per component word, in order. The length of the returned Mor vector
equals the number of Stanza tokens in the main-tier rewrite, so the
retokenize path can consume them with a simple cursor
(ctx.mor_cursor in retokenize/rebuild.rs).
map_ud_sentence() assembles the MWT components into one clitic-joined
Mor via assemble_mors(), so the returned vector length matches the
original CHAT word count.
Contrast: BA2 vs BA3
BA2 is a useful oracle for semantic correctness of the retokenize
output, but its architecture is not an oracle we want to reproduce. The
table and diagram below report what BA2 actually does, confirmed by
reading ~/batchalign2-master/batchalign/pipelines/morphosyntax/ud.py.
| Concern | BA2 (ud.py) | BA3 |
|---|---|---|
| Stanza mode | tokenize_no_ssplit=True, free tokenizer (NOT pretokenized). Config built in _build_nlp() at line 1004. | Mixed: tokenize_pretokenized=True for non-MWT languages and Japanese; free tokenizer with tokenize_postprocessor for English and other MWT languages. Config in batchalign/worker/_stanza_loading.py line 102-140. |
| Preserve vs retokenize branch | Single morphoanalyze() function at line 713 branches on the retokenize boolean parameter at line 833. | Typed TokenizationMode enum in morphosyntax/mod.rs; dispatch branch in morphosyntax/inject.rs at line 158. |
| MWT contraction fix | Inline regex on emitted %mor string at line 826: `re.sub(r“~part|s verb|(\w+)-Ger-S“, r“~aux | is verb |
| Character-DP aligner | Internal align() function from batchalign.utils.dp operating on PayloadTarget/ReferenceTarget lists, called at line 872. | Rust align_tokens() exposed via batchalign_core, called from the Python _tokenizer_realign.py postprocessor. Hirschberg divide-and-conquer (crates/batchalign-transform/src/dp_align/). |
| Main-tier rewrite | Text surgery: 14+ chained .replace() and re.sub() calls at lines 925-942, then a sanity-check reparse of the result. | AST rewrite: rebuild_content() walks the parsed UtteranceContent and splices Stanza tokens in place (retokenize/rebuild.rs), re-using the tree-sitter fragment parser. No string surgery. |
%wor preservation | Not handled, BA2 had no %wor tier concept at this layer. | Retokenize-mode invalidates %wor bullets; FA must be re-run. See the Known Limitations section. |
| Cross-language routing | MultilingualPipeline with all lang alpha-2 codes (line 1058). | Per-language pipelines, with per-utterance lang_code dispatch in batch_infer_morphosyntax(). |
flowchart TD
subgraph BA2 ["BA2 (Python-only, ud.py)"]
B_In["Utterance text\n(str)"] --> B_Nlp["stanza.Pipeline\ntokenize_no_ssplit=True\nfree tokenizer"]
B_Nlp --> B_Parse["parse_sentence()\nline 825"]
B_Parse --> B_Regex["re.sub %mor patch\nline 826"]
B_Regex --> B_Branch{"retokenize?"}
B_Branch -->|"no"| B_Merge["chat_parse_utterance(line, mor, gra)\nline 956"]
B_Branch -->|"yes"| B_Align["align() DP on chars\nline 872"]
B_Align --> B_Surgery["14+ string replace / re.sub\nlines 925-942"]
B_Surgery --> B_Reparse["chat_parse_utterance sanity check\nline 947"]
B_Merge --> B_Doc["Document.content[idx] = ..."]
B_Reparse --> B_Doc
end
subgraph BA3 ["BA3 (Rust + thin Python)"]
R_In["Utterance AST\n(talkbank-model::Utterance)"] --> R_Extract["extract_words()\n(../chatter/crates/talkbank-transform/src/extract.rs)"]
R_Extract --> R_Worker["Python worker\n(infer_morphosyntax)"]
R_Worker --> R_Stanza["stanza.Pipeline\npretokenized or postprocessor"]
R_Stanza --> R_Map{"TokenizationMode"}
R_Map -->|"Preserve"| R_Merge["map_ud_sentence()\n(crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs:77)"]
R_Map -->|"StanzaRetokenize"| R_Exp["map_ud_sentence_expanded()\n(crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs:24)"]
R_Exp --> R_Retok["retokenize_utterance()\n(crates/batchalign-transform/src/retokenize.rs)"]
R_Merge --> R_Inject["inject_morphosyntax()\n(crates/batchalign-transform/src/inject.rs)"]
R_Retok --> R_Inject
R_Inject --> R_Ser["to_chat_string()\n(../chatter/crates/talkbank-transform/src/serialize.rs)"]
end
What BA2 got wrong that BA3 got right:
- BA2 does 14 text-surgery passes on the already-serialized utterance
string. Each was a band-aid for a specific bug, and several interact
with CHAT annotations (brackets,
⁎creaky markers,@wpprefixes). BA3 never serializes in the middle of the pipeline; the main tier is rewritten at the AST layer. - BA2’s
re.subon the%morstring (line 826) is an ad-hoc patch for a specific Stanza wrong-analysis of's Ger-S. BA3 addresses this at the UD layer inmap_ud_sentenceandassemble_mors.
What BA3 got wrong that BA2 got right (or at least, did simply):
- BA2 always runs the free tokenizer + postprocessor path. BA3’s mix of
tokenize_pretokenized=Truefor some languages and the postprocessor for others means the MWT hint preservation described above is load-bearing for English and other MWT languages, and a regression there silently disables MWT expansion without crashing. Defect 2 instanza-limitations.mdcovers the failure mode.
Code map
| File | Role |
|---|---|
crates/batchalign-transform/src/morphosyntax/types.rs | TokenizationMode enum (:106); top-level morphosyntax types |
crates/batchalign-transform/src/inject.rs | Top-level inject_morphosyntax(): tier insertion, shared by both modes |
crates/batchalign-transform/src/morphosyntax/payload.rs | Per-utterance payload collection sent to Python |
crates/batchalign-transform/src/retokenize.rs | retokenize_utterance() entry point (:195); build_word_token_mapping() (:67); inline #[cfg(test)] tests |
crates/batchalign-transform/src/retokenize/rebuild.rs | rebuild_content() (:47): AST walk that splices Stanza tokens into the UtteranceContent |
crates/batchalign-transform/src/retokenize/parse_helpers.rs | Fragment-parser helpers (re-uses tree-sitter fragment parsing) |
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs | map_ud_sentence() (merge, :81) and map_ud_sentence_expanded() (per-component, :24) |
crates/batchalign-transform/src/morphosyntax/mapping_helpers.rs | assemble_mors() (:60): clitic-join MOR construction |
batchalign/inference/_tokenizer_realign.py | TokenizerContext, make_tokenizer_postprocessor, _realign_sentence, _conform |
batchalign/inference/morphosyntax.py | batch_infer_morphosyntax(): dispatches Stanza per language, sets original_words |
batchalign/worker/_stanza_loading.py | Per-language Stanza pipeline construction (pretokenized vs postprocessor) |
Known limitations
%worbullets become invalid after retokenize.%wortiming is per-CHAT-word; if a CHAT word is split, per-word timing does not survive cleanly. Retokenize-mode output should be re-aligned withalignbefore%woris trusted. Seereference/wor-tier.md.%worvalidation breaks on re-transcription. Files re-transcribed after a main-tier rewrite will fail downstream%worcount checks. This is a deliberate invariant: the old%woris stale.- Phrasal-verb MWTs. Cases like
wake@s up@sproduceverb|wake part|upwithCOMPOUND-PRTGRA deprel via the L2 merge’s Priority 0 check (crates/batchalign-transform/src/morphosyntax/l2/merge.rs::resolve_merged_pos_with_context). See L2 Morphotag: Phrasal-verb recognition for the mechanism. - Pretokenized-mode languages never run the postprocessor. For
non-MWT languages and Japanese,
tokenize_pretokenized=Trueis used and_tokenizer_realign.pyis not wired in. Any future MWT additions for those languages must either switch to the postprocessor path or add equivalent Rust-side handling. - Mandarin retokenize uses a separate pipeline. When
req.retokenizeis true and the job language iszho/cmn,batch_infer_morphosyntaxlazy-loads a second pipeline withtokenize_pretokenized=Falseso Stanza’s neural segmenter can resegment Latin+CJK mixed text. Seereference/chinese-word-segmentation.md.
Testing
Rust unit tests (inline #[cfg(test)] blocks):
crates/batchalign-transform/src/retokenize.rs: mapping deterministic / fallback / mixed cases (e.g.deterministic_mapping_succeeds_for_split_and_mergeat:287), rebuild walk, diagnostics, and taint marking on mismatch.
Rust ML golden tests:
crates/batchalign/tests/ml_golden/morphotag/golden.rs:158::golden_morphotag_retokenize_eng, end-to-end Stanza call + retokenize on an English fixture; asserts specific MOR items.crates/batchalign/tests/ml_golden/morphotag/golden_l2.rs:121::golden_l2_morphotag_eng_contractions, L2 secondary dispatch with contractions; load-bearing for the MWT-hint preservation fix.
Python tests:
batchalign/tests/pipelines/morphosyntax/test_tokenizer_realign.py: 25 tests covering_conform,_is_contraction,_realign_sentence, and the MWT-hint overlay.batchalign/tests/pipelines/morphosyntax/test_preserve_mwt.py: 3 tests pinning the Preserve-mode MWT contract end to end.- Adjacent files
test_preserve_mwt_end_to_end.py,test_retokenize_mwt.py,test_retokenize_retrace_e2e.py,test_retokenize_retrace_regression.py,test_retokenize_vs_engines.pycover longer-running regressions.
To run the Rust side locally:
cargo test -p batchalign retokenize::tests
The ML goldens require real Stanza models and only run on a
Large/Fleet-tier host (≥ 256 GB RAM). See
developer/testing.md for the safety rules.
Sources verified
The diagrams and function references on this page were verified against the following source files, read during authoring:
crates/batchalign-transform/src/morphosyntax/types.rs:106:TokenizationModeenum definition.crates/batchalign-transform/src/inject.rs: top-levelinject_morphosyntax()shared by both modes.crates/batchalign-transform/src/retokenize.rs:195:retokenize_utteranceentry point; sibling helpers undercrates/batchalign-transform/src/retokenize/{parse_helpers,rebuild}.rs.crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs:map_ud_sentence(:81) andmap_ud_sentence_expanded(:24).batchalign/inference/_tokenizer_realign.py:_conform,_is_contraction(:120),_realign_sentence(:148), and the MWT-hint overlay block.batchalign/inference/morphosyntax.py:batch_infer_morphosyntax(:201) and thectx.original_wordsdispatcher around it.batchalign/worker/_stanza_loading.py: per-language Stanza pipeline construction;should_request_mwtat:40;load_stanza_modelsat:126.crates/batchalign/tests/ml_golden/morphotag/golden.rs:158andcrates/batchalign/tests/ml_golden/morphotag/golden_l2.rs:121, retokenize and L2 contractions golden tests respectively.- BA2 comparison points (
morphoanalyze, retokenize branch, text-surgery block,%morregex patch,_build_nlp, tokenizer_processor rules) were read against a private archive copy ofbatchalign2-master/batchalign/pipelines/morphosyntax/ud.py; the archive is not part of this public repo.
This page last changed: 2026-07-30 (commit 5157a549). The whole book last changed: 2026-09-16 (commit 34d249d8).
Stanza Limitations: Observed Defects with Version Pinning
Status: Reference (living document, update when Stanza behavior changes)
Last updated: 2026-09-10 13:54 EDT
Current Stanza pin: stanza[transformers]>=1.14.0,<1.15 (see pyproject.toml)
Current English MWT package: gum
2026-05-14, Stanza 1.12.0 upgrade: every defect below was re-evaluated against Stanza 1.12.0. Verdicts: defect 4 (Finnish
<SOS>leak) is Fixed upstream and its mitigation was removed; defects 1, 2, 5, 6, 7, 8 are still confirmed and have1.12.0added to their version lists; defect 3 (CJK reference-only) was not re-evaluated this pass.
2026-05-28, Stanza 1.12.1 patch upgrade: every defect below was re-evaluated against Stanza 1.12.1 via the same reproducer used for 1.12.0. Verdicts: defect 4 still Fixed upstream (sentinel still GREEN); defects 1, 2, 5, 6, 7, 8 still confirmed and have
1.12.1added to their version lists; defect 3 not re-evaluated. The newen_combined's/herlemma classifier in 1.12.1 does NOT fix defect 1, Stanza still mis-MWTs “the stool’s going over”-style sentences at the MWT layer, before the lemma classifier runs.
2026-06-19, Stanza 1.13.0 upgrade: every defect below was re-evaluated against Stanza 1.13.0 via the same reproducer. Verdicts: defect 4 still Fixed upstream (Finnish
<SOS>leak sentinel still GREEN, no regression); defects 1, 2, 5, 6, 7, 8 still confirmed and have1.13.0added to their version lists (zero XPASS on the Italian MWT-probexfailmarkers); defect 3 not re-evaluated. English pretokenized POS/lemma/deprel is byte- identical to 1.12.x, and the constituency/utseg suite is unchanged by the 1.13.0 constituency-parser model condensation, so no%morregeneration is warranted. Separately, the Spanishdel_aloneMWT drift sentinel was re-locked from 2 to 1 UD words: isolateddelstopped MWT-expanding as of the 1.12.1 Spanish updates (verified identical on 1.12.2 and 1.13.0), with zero production impact since downstream Range reassembly is 1-to-1 either way.
2026-07-31, Stanza 1.14.0 upgrade: the 1.13.0 and 1.14.0 golden suites have identical outcomes, including the same two pre-existing failures. The Italian lexicon adaptation is compatible with the new lemmatizer layout, and the confirmed-version map records each individually rechecked defect. Dependency-parse repair is now enabled by default; a typed-AST corpus review remains separate from the golden suite and must not be replaced by a serialized-tier survey.
See also: Stanza Defect Mitigation Map , pipeline-stage view of where each defect below is patched.
Purpose
Stanza is a third-party dependency whose analyses drive much of BA3’s NLP output (morphosyntax, utterance segmentation, dependency parsing). Stanza is imperfect. When Stanza’s output is wrong, BA3 has to either override it with principled rules or accept degraded output. Either choice creates technical debt: overrides can go stale if Stanza improves, and accepted-wrong output misleads users.
This document records Stanza defects we have observed, with the Stanza version in which each defect was confirmed. When Stanza is upgraded, this document is the re-evaluation checklist: re-run the permanent tests associated with each defect and, if Stanza has fixed it, remove the BA3 override.
Core principle: BA3 targets linguistic correctness, not Stanza parity. When Stanza is wrong, BA3 overrides. When Stanza improves, BA3 un-overrides. Both directions are driven by tests that pin the observed behavior.
Format for each entry
- Defect: brief name + construction class affected
- Stanza version: where confirmed
- Input example: minimal reproducible sentence
- Stanza output: what it currently produces (POS, lemma, deprel)
- Correct output: what it should produce, grounded in linguistic/CHAT conventions
- BA3 mitigation: the principled override we apply, with a pointer to the implementation
- Tests: the permanent tests that lock this observation
- Re-evaluation criteria: what to check when Stanza upgrades
Defect 1: Copula 's vs possessive 's disambiguation fails before nominal gerunds
- Stanza version: 1.10.1, 1.11.1, 1.12.0, 1.12.1, and 1.13.0 (all confirmed; re-verified by
test_stanza_mwt_copula_observations.pyagainst 1.12.1 on 2026-05-28 and against 1.13.0 on 2026-06-19) - MWT package:
gum - Construction:
<noun>'s <word-ending-in-ing>in a main clause.
Input examples
and the sink's overflowing .
the lady's washing dishes .
Stanza’s output
Stanza commits to a possessive reading end-to-end:
| Word | upos | lemma | deprel | Head |
|---|---|---|---|---|
| sink | NOUN | sink | nmod:poss | overflowing |
| ’s | PART | ’s | case | sink |
| overflowing | NOUN | overflow | root | 0 |
The whole sentence is parsed as the noun phrase “the sink’s overflowing” with “overflowing” as the nominal head. This leaves the main clause with no finite verb: ungrammatical English.
Correct output
The sentences are contracted copula is + progressive. The 's
should be AUX with lemma be, and the -ing word should be a verbal
present participle:
| Word | upos | lemma | deprel | Head |
|---|---|---|---|---|
| sink | NOUN | sink | nsubj | overflowing |
| ’s | AUX | be | aux | overflowing |
| overflowing | VERB | overflow | root | 0 |
CHAT %mor target: noun|sink~aux|be-Fin-Ind-Pres-S3 verb|overflow-Part-Pres-S.
Why Stanza gets this wrong
The construction <noun>'s <-ing> is ambiguous in isolation:
- Copula reading (correct in conversational CHAT): “the sink is
overflowing”,
<noun>is the subject,isis the finite verb,-ingis present participle in progressive aspect. - Possessive reading (rare, requires broader clausal context):
“the sink’s overflowing
[is problematic]”,<noun>'sis possessor,-ingis a deverbal gerund noun.
In natural English conversation, the copula reading is overwhelmingly more common. Human speakers rely on prosody, context, and the grammatical requirement that main clauses have a finite verb to resolve the ambiguity. Stanza’s POS tagger does not reliably use sentence-level well-formedness as a tiebreaker, and in the failure cases its tagger commits to the possessive reading even when the result is an ungrammatical fragment.
Counter-examples (Stanza handles correctly, for reference):
he's falling over: Stanza correctly tags'sas AUX. The pronounheprobably helps,hiswould be the possessive form.the stool's going over: Stanza correctly tags'sas AUX.goinggets tagged as VERB (VerbForm=Part) here, perhaps because “going” is more verb-like in its training than “overflowing” / “washing”.
The failing cases (sink, lady) share the pattern: a NOUN head and
an -ing form that Stanza’s lexical semantics considers
noun-compatible (overflowing as a noun-like event; washing as in
“washing machine”). Whatever Stanza’s internal signal is, it fails
for these.
BA3 mitigation (ACTIVE)
Grammatical-invariant rewrite on typed UD data. The “main clauses
require a finite verb” invariant is checked on each UdSentence before
map_ud_sentence runs; when violated AND an MWT-bound 's tagged as
PART/case is present AND exactly one NOUN-tagged -ing word exists,
the rule rewrites the sentence into its coherent copula-progressive
analysis (flipping 's to AUX/be/Fin, promoting the -ing word to
root VERB/VerbForm=Part, reattaching subject and object dependencies).
Handles two sub-patterns:
- Pattern A (root == target): the
-ingword is already Stanza’s root (e.g.,and the sink's overflowing). In-place POS/feat flip plus subject reattachment. - Pattern B (root != target): the
-ingword is a compound modifier; a different noun holds the root position (e.g.,the lady's washing dishes: Stanza makesdishesroot). The-ingword is promoted to root, the former root is demoted toobj, subject and punctuation are reattached.
Implementation: crates/batchalign-transform/src/morphosyntax/invariants/finite_verb_main_clause.rs::rescue_english_copula_progressive.
Dispatcher: crates/batchalign-transform/src/morphosyntax/invariants.rs::apply_grammatical_invariants.
Hook point: crates/batchalign-transform/src/morphosyntax/injection.rs:276
(apply_grammatical_invariants(ud_sentence, &ctx); the subsequent
map_ud_sentence/map_ud_sentence_expanded calls land at :287 and
:289 respectively).
Tests
Rust unit tests (14 tests, all GREEN):
crates/batchalign-transform/src/morphosyntax/invariants/finite_verb_main_clause.rs
#[cfg(test)] block, 2 positive rewrite tests (sink pattern A, lady
pattern B), 10 negative no-op tests covering distinct precondition
failure modes.
Python end-to-end tests (new file, 2 tests, all GREEN):
batchalign/tests/pipelines/morphosyntax/test_preserve_mwt_end_to_end.py
, runs batchalign3 morphotag on a CHAT fixture with all four
copula-contraction sentences, asserts final %mor contains
~aux|be-Fin-Ind-Pres-S3 for every one and %gra contains the
corresponding AUX/NSUBJ/ROOT structure.
Python observation tests (retained as versioned documentation of
Stanza’s current output): test_stanza_mwt_copula_observations.py,
test_preserve_mwt.py: assert what Stanza emits at the
intermediate layer (still PART/’s for sink/lady; BA3 corrects
downstream).
Re-evaluation criteria (when Stanza upgrades)
On Stanza upgrade:
- Temporarily disable the rewrite (return
sentence.clone()unconditionally inrescue_english_copula_progressive). - Re-run the Python observation tests. If Stanza now emits AUX/be for sink/lady, the underlying Stanza defect is fixed; the mitigation can be removed. Update this document with the Stanza version and remove the rule and its tests.
- If the Python observation tests still show PART/’s, re-enable the mitigation and update the Stanza version header at the top of this document.
Long-term successor (Option D: fine-tune Stanza)
The rewrite is a principled compromise, not the ideal solution. The
correct long-term fix is to retrain Stanza’s English POS and
depparse models on data where contracted copula 's before a
present participle is labeled correctly. Work items:
- Build a CHAT-to-UD conversion pipeline that labels MWT Range
'sin copula-progressive contexts as AUX/be/Fin (and the following-ingword as VERB/VerbForm=Part). - Curate a training set from BA3’s ~199 MB of CHAT transcripts, English Clinical and English CHILDES-NA corpora have the target construction in abundance.
- Set up a Stanza continued-training job from the published checkpoint.
- Evaluate against both a held-out CHAT test set (must improve) and Stanza’s default English test set (must not regress).
- Distribute: either bundle a custom Stanza model with BA3 or publish to the Stanza model hub.
- Once deployed, follow the re-evaluation procedure above to retire the invariant rewrite.
Estimated effort: weeks to months. Tracked here as a future work item; not scheduled.
Defect 2: MWT hint tuples must be preserved through postprocessors (Stanza/Python interop gotcha)
- Stanza version: 1.10.1, 1.11.1, 1.12.0, 1.12.1, and 1.13.0 (all confirmed; re-verified by
test_stanza_mwt_copula_observations.pyagainst 1.12.1 on 2026-05-28 and against 1.13.0 on 2026-06-19) - Nature: Not strictly a Stanza bug, a contract that the
tokenize_postprocessorAPI places on callers but does not document prominently. Easy to violate in a wrapper that flattens tuples to strings.
Summary
Stanza’s tokenizer natively emits (text, True) two-element tuples for
English contractions (and other MWT-capable languages). Its MWT processor
honors those hints to expand the token into Range components (don't →
do + n't). Any tokenize_postprocessor callback that discards the
boolean, for example by extracting only tok.text before a downstream
aligner runs, silently disables MWT expansion for the whole document.
The symptom is subtle: no error, no warning, just missing Range tokens
and therefore no ~-joined %mor output for English contractions.
Why this matters in BA3
If batchalign/inference/_tokenizer_realign.py::_realign_sentence
flattens tokens to plain strings before passing them to the Rust
char-DP aligner, the aligner’s 1:1 mapping (the common case where no
compound-merging is needed) loses the hint tuple, and Stanza’s MWT
processor sees only bare strings. MWT never fires, all English
contractions regress to single-token morphology, and the symptom is
silent (no error, no warning).
BA3 mitigation (ACTIVE)
_realign_sentence now overlays Stanza’s original (text, True) tuples
onto the aligner output for positions where lengths match and no merging
happened. The hint survives the realignment and reaches Stanza’s MWT
processor intact. Applies to every language for which the runtime
capability table reports has_mwt=True (see Defect 5 for how MWT
availability is decided per language).
Tests
- L2 ML-golden tests:
golden_l2_morphotag_eng_contractions,golden_l2_morphotag_eng_spa,golden_l2_morphotag_deu_eng,golden_l2_morphotag_off_produces_l2_xxx. - Python observation tests:
test_stanza_mwt_copula_observations.pypins Stanza’s native tuple emission for the four copula-contraction fixtures.
Re-evaluation criteria (when Stanza upgrades)
Stanza is unlikely to move away from the tuple convention, it is a documented API contract. The re-check on upgrade is:
- Re-run
test_stanza_mwt_copula_observations.py. It still requires Stanza to emit(text, True)for English contractions. - If a future Stanza version emits a different hint shape (e.g., a
dedicated class instead of a tuple), update
_realign_sentence’s overlay logic to recognize the new shape. The principle, preserve Stanza’s hints through our wrapper, does not change.
Defect 3: CJK tokenization and POS quality (reference only: existing workarounds)
- Stanza version: 1.10.x and 1.11.x (both)
- Construction: Word segmentation and POS tagging for Chinese (Mandarin, Cantonese) and Japanese.
- Symptom: Stanza’s accuracy on conversational CJK text is below what BA3 needs for CHAT-quality morphotag output, especially for Cantonese.
- BA3 mitigation: BA3 uses dedicated engines for CJK, PyCantonese for Cantonese POS, unified Stanza training on HKCanCor+UD for Cantonese tokenize+POS+depparse, and pretokenize+CHAT-gold segmentation for Japanese.
- Tests:
batchalign/tests/pipelines/morphosyntax/test_cantonese_*,test_stanza_cantonese_*,test_mandarin_*. - Re-evaluation criteria: When Stanza ships a CJK model upgrade, re-run the baseline accuracy tests and compare against the per-engine quality benchmarks.
This entry is listed for completeness; it belongs in the same registry.
Defect 4: Neural-LM control tokens leak into Document output (Finnish MWT)
- Stable slug (use this in code references, defect numbers can
renumber as entries are retired):
stanza-fi-mwt-sos-leak - Stanza version: 1.11.1 (confirmed); older versions not tested. Fixed in Stanza 1.12.0, 1.12.1, and 1.13.0: verified by
test_stanza_fi_mwt_sos_leak.pyGREEN on 2026-05-14, re-verified GREEN against 1.12.1 on 2026-05-28, and re-verified GREEN against 1.13.0 on 2026-06-19. The strip+warn mitigation was removed in the 1.12.0 upgrade commit (the associatedtest_control_token_leak_propagation.pymitigation-removal sentinel was retired alongside it); the standalone reproducertest_stanza_fi_mwt_sos_leak.pyis retained as the regression sentinel against any future re-introduction of the leak. - Nature: Character-level language-model internal tokens (
<SOS>, start-of-sequence) leak into Stanza’s publicDocumentAPI, appearing as literal substrings onword.textandword.lemma. Observed on Finnish when the MWT processor splits the wordtolleiin a 3+-word context.
Input examples
Minimum trigger (no domain knowledge of Finnish needed):
a tollei b
Larger real-corpus example (from
childes-other-data/Finno-Ugric/Finnish/Kirjavainen-MPI/1-08-01.cha
line 2222):
*MOT: kato se on tommonen (.) tollei se menee xxx .
Stanza’s output
token.text='a' word.text='a' lemma='a' upos=NOUN
token.text='tollei' word.text='<SOS>tos' lemma='<SOS>tos' upos=SYM ← LEAK
word.text='ei' lemma='ei' upos=VERB
token.text='b' word.text='b' lemma='b' upos=NOUN
The MWT expansion is correct in shape (tollei → tos + ei),
but the first expansion word has <SOS> prepended on both its
text and lemma fields. The second expansion word is clean.
Correct output
word.text='tos' lemma='tos' upos=SCONJ
word.text='ei' lemma='ei' upos=VERB
Why the leak matters
Batchalign writes Stanza’s output to CHAT %mor tiers. The CHAT
manual’s %mor grammar does not permit angle-bracket content
inside stems, so an unguarded leak produces invalid CHAT like
sconj|<sos>tos~aux|ei-Fin-Neg-S3 that chatter validate
correctly rejects with E316. The rejection is a reliable final
safety gate but inconvenient, the corruption ships to disk before
validation sees it.
Observed impact: files in CHILDES Finnish Kirjavainen-MPI have carried this pattern in committed state, traceable to morphotag runs.
Trigger conditions
Empirically narrowed down from the original 8-word sentence:
- Language: Finnish (
lang="fi") - Processors include MWT:
tokenize,pos,lemma,depparse,mwt tolleiappears as a non-boundary token (3+ whitespace-separated tokens total;se tolleiandtollei sedo NOT leak)
The leak is not observed for arbitrary Finnish MWT splits, it
reproduces reliably on tollei but was not seen on the other
Finnish MWTs we sampled. Likely a corner case in the character LM’s
interaction with the MWT processor on this specific surface form.
BA3 mitigation (RETIRED in Stanza 1.12.0)
The earlier mitigation, a control-token stripper at
batchalign/inference/_control_token_filter.py plus its
integration test and unit-test suite, was removed as part of the
2026-05-14 Stanza 1.12.0 upgrade once
test_stanza_fi_mwt_sos_leak.py flipped GREEN against the new
release. The standalone reproducer is kept as a regression sentinel;
no active strip-in-place code remains in the pipeline.
Tests
- Standalone upstream reproducer (kept):
batchalign/tests/pipelines/morphosyntax/test_stanza_fi_mwt_sos_leak.py, no batchalign imports; remains GREEN on Stanza 1.12.0 and is the fail-loud signal if a future Stanza version reintroduces the leak. - The earlier
batchalign/tests/inference/test_control_token_filter.pyandbatchalign/tests/pipelines/morphosyntax/test_control_token_leak_propagation.pywere deleted alongside the mitigation.
Re-evaluation criteria (when Stanza upgrades)
- Re-run the standalone reproducer
(
test_stanza_fi_mwt_sos_leak.py). If Stanza’s Finnish MWT no longer leaks<SOS>on"a tollei b", the upstream bug is fixed. - If the standalone reproducer is GREEN on the new Stanza, the integration test will still pass (no leak to strip = no warning). At that point the strip + warning code becomes dead for this defect; retire it only after confirming no other language exhibits a similar leak on Stanza’s new version.
- If the standalone reproducer is still RED but on a different surface form, extend the regex vocabulary if the new token type falls outside the current list; otherwise no code change needed.
Upstream reporting
An issue has not yet been filed. The standalone reproducer is prepared and ready to submit. Until it is filed and resolved, the BA3 workaround stays in place.
Defect 5: MWT processor selection must come from the live capability table, not a hardcoded mirror
- Stable slug:
stanza-mwt-capability-driven-selection - Stanza version: every 1.x release through 1.11.1; the issue is the BA3 loader, not Stanza.
- Nature: A loader-side bug, not a Stanza bug. BA3 maintained a
hardcoded
MWT_LANGSinclude set inbatchalign/worker/_stanza_loading.pythat drifted from Stanza’s installed catalog. For any language on the include set that Stanza did not actually ship MWT for, the worker requested an unavailable processor and crashed at bootstrap withUnsupportedProcessorError. Swedish was the case that surfaced the bug; the underlying class affects every language.
Symptom (Swedish)
When the loader requests processors="tokenize,pos,lemma,depparse,mwt"
for Swedish, the Stanza pipeline never finishes loading. The Python
worker subprocess prints the bootstrap traceback and exits before
emitting a ready signal, the Rust dispatcher reports
Batch infer failed for language group lang=swe, and every Swedish
file in the batch is failed cleanly by the language-group failure
aggregator. No silent corruption, but no %mor either.
History: and why this was a regression, not a new bug
BA2-jan9 (the 84ad500 baseline this team uses as the migration
oracle) already handled this correctly. The relevant excerpt from
batchalign/pipelines/morphosyntax/ud.py:760-772 in BA2-jan9:
mwt_exclusion = ["hr", "zh", "zh-hans", "zh-hant", "ja", "ko",
"sl", "sr", "bg", "ru", "et", "hu",
"eu", "el", "he", "af", "ga", "da", "ro"]
elif not any(i in mwt_exclusion
or "mwt" not in get_language_resources(resources, i)
for i in lang):
if "en" in lang:
config["processors"]["mwt"] = "gum"
else:
config["processors"]["mwt"] = "default"
The right-hand side of the OR is the principled check: it asks
Stanza’s installed catalog whether the language has an mwt
processor before requesting it.
The left-hand mwt_exclusion list predates Ignas’s commit and
carries no documented rationale per language. It is a snapshot of
“what Stanza didn’t ship at some earlier point,” frozen in the
source. As Stanza added MWT models for more languages (including
Hebrew, Greek, and Estonian), the list became progressively stale
but kept vetoing. By 2026-01, three of its entries (el, et,
he) had been overruled in upstream Stanza but were still being
excluded by BA2’s hardcoded list; the remaining entries either
agreed with the catalog or were deliberate-CJK exclusions (handled
elsewhere in BA3).
When BA3 was built, BA2’s two-armed check was flattened into a
single hardcoded include set MWT_LANGS. The runtime catalog arm
was lost. The hardcoded include set then drifted in the opposite
direction, listing Swedish (sv) for MWT even though Stanza had
never shipped a Swedish MWT model.
BA3 fix (ACTIVE)
MWT_LANGS is deleted. The loader consults the capability table
at every Stanza pipeline construction:
has_mwt = should_request_mwt(alpha2, get_cached_capability_table())
The capability table (batchalign/worker/_stanza_capabilities.py)
is built once at worker startup from
stanza.resources.common.load_resources_json() and reports per
language whether each processor is available. CLAUDE.md mandates
this pattern: “Per-language processor availability is determined
by reading Stanza’s resources.json at worker startup, NOT by
hardcoded tables. Never hardcode processor assumptions.”
Per-language behavior with this fix:
| Language | Old hardcoded list | Capability table | Net change |
|---|---|---|---|
Swedish (sv) | True (wrong) | False | MWT no longer requested → bootstrap succeeds (was crashing) |
Hebrew (he) | False | True | MWT requested where it produces real splits |
Greek (el) | False | True | MWT requested where it produces real splits |
Estonian (et) | False | True | MWT requested but does not fire on conversational input (effective no-op; see “Estonian no-op” below) |
Russian (ru) | False | False | unchanged |
Japanese (ja) | False | False | unchanged |
| English / French / German / Italian / Spanish / Dutch / Finnish / etc. | True | True | unchanged |
Practical impact on linguistic output
Swedish (was crashing, now runs without MWT). Stanza never
shipped a Swedish MWT model. Swedish orthography keeps most function
words separate, so the loss of MWT mostly does not affect %mor
granularity. A few historical contractions (e.g. i+det → it) pass
through as single tokens; this is acceptable for CHAT corpora and
matches what BA2-jan9’s runtime check would also have produced
(BA2-jan9 also did not request Swedish MWT, via the right-hand arm
of its OR).
Hebrew (now gets MWT, was being suppressed). Stanza splits Hebrew prepositional+definite contractions and definite-article fusion correctly:
בבית → ב + בית "in the house", prep ב + noun (definite ה absorbed)
מהילד → מ + ה + ילד "from the boy", prep מ + def ה + noun
לאישה → ל + אישה "to the woman", prep ל + noun
הזה → ה + זה "this", def ה + demonstrative
These are linguistically real morpheme boundaries; producing them
in %mor is the correct CHAT-format output. BA2-jan9’s hardcoded
exclusion of Hebrew predates Ignas’s 2024 commit and has no recorded
justification.
Greek (now gets MWT, was being suppressed). Stanza splits the preposition+article contractions correctly:
στο → σ + το "in the (n.acc)", prep σε + def το
στον → σ + τον "in the (m.acc)", prep σε + def τον
στις → σ + τις "at the (f.pl.acc)", prep σε + def τις
Same justification as Hebrew. The split components are the underlying lexical items; merging them into a single token loses real morphosyntactic structure.
Estonian no-op. Stanza ships et MWT in
resources.json, so the capability table reports
has_mwt=True and BA3 requests it. Empirically, Stanza’s Estonian
MWT model does not split anything on conversational input, including
the contracted-negation forms (pole = ei+ole, polnud =
ei+olnud, polegi = ei+ole+gi) that UD Estonian-EDT does mark as
MWT in the treebank. Probed inputs and observed Stanza output:
| Input | Expected (UD-EDT) | Stanza-1.11.1 output |
|---|---|---|
pole tähtis | ei+ole tähtis | pole tähtis (no split) |
polnud aega | ei+olnud aega | polnud aega (no split) |
ma pole näinud | ma ei+ole näinud | ma pole näinud (no split) |
ta polegi tulnud | ta ei+ole+gi tulnud | ta polegi tulnud (no split) |
The Estonian MWT model likely trained on a treebank where MWT was
rarely or never marked on conversational pole-class forms;
either way, requesting it has no observable effect on real CHAT
input. We request it for consistency with the capability table; if
the upstream model later starts splitting these forms, the change
will surface through a test rather than as silent output drift.
Tests
- Pure-function unit tests (4 tests, GREEN, no Stanza required):
batchalign/tests/pipelines/morphosyntax/test_stanza_loading.py::TestShouldRequestMwtpinsshould_request_mwtagainst synthetic capability tables (Swedish-not-supported, English-supported, unknown-language, table-is-None). - Live-catalog regression tests (3 tests, GREEN):
batchalign/tests/pipelines/morphosyntax/test_stanza_config_parity.py::TestMwtCapabilityDrivenasserts the runtime decision against the actual installed Stanza catalog, Swedish False, English True, and uses an AST scan to forbid re-introduction of a hardcodedMWT_LANGSset. - Hebrew/Greek/Estonian MWT split observation tests (golden, real Stanza):
batchalign/tests/pipelines/morphosyntax/test_stanza_he_el_et_mwt_splits.pypins the linguistically-correct splits for the canonical Hebrew and Greek constructions listed above plus the Estonian no-op probes. Standalone, no batchalign imports, safe to share with upstream if Stanza output drifts. - CHAT end-to-end tests (golden, integration):
batchalign/tests/pipelines/morphosyntax/test_he_el_mwt_end_to_end.pyrunsbatchalign3 morphotag --sequentialon minimal Hebrew and Greek CHAT fixtures, asserts%morcontains tilde-joined splits for the contraction forms, and verifies the output passeschatter validate.
Re-evaluation criteria (when Stanza upgrades)
- Re-run the live-catalog tests. If
should_request_mwtflips for any language, the capability table picked up an upstream change automatically; no BA3 code change needed. - Re-run the Hebrew/Greek split tests. If Stanza’s splits change shape, decide whether the new shape is linguistically defensible and update the assertions, or file an upstream issue.
- Re-probe Estonian. If Stanza’s Estonian MWT begins splitting
pole-class forms, update the table above and add positive assertions.
Why we did not re-introduce a deliberate-exclude list
BA2-jan9’s hardcoded mwt_exclusion list was inherited without
documented rationale (only Ignas’s runtime-check arm had a clear
purpose). Recreating it in BA3 would re-create the same drift bug
in a different shape, over time, the upstream catalog moves and
the hardcoded list goes stale. Per the project’s standing policy
(“BA2 is known-buggy; never duplicate BA2’s wrong output”), the
correct stance is to trust the capability table and let any
linguistically-bad MWT splits surface through tests.
Long-term: Swedish MWT
If Swedish corpus throughput justifies it, train a Stanza MWT model on UD Swedish-Talbanken or UD Swedish-LinES and contribute it upstream. The capability table would then flip automatically on the next Stanza release and Swedish would gain MWT support with no BA3 code change. Estimated effort: weeks; not currently scheduled.
Routing Swedish to a sibling model (Norwegian-Bokmål) is not a
viable workaround, Norwegian and Swedish are distinct languages,
per-word morphological features would be wrong, and the resulting
%mor would be misleading rather than merely incomplete.
Defect 6: Italian POS layer splits words with clitic-shaped endings into fake verb+clitic compounds
- Stable slug:
stanza-it-verb-clitic-pos-split - Stanza version: 1.11.1, 1.12.0, 1.12.1, and 1.13.0 (all confirmed; re-verified via MWT probe matrix
xfailmarkers held on 2026-05-28 and again on 2026-06-19, zero XPASS) - MWT package: Italian default
- Failure class: linguistic-content quality. Stage 3’s MWT Range
reassembly
(
crates/batchalign-transform/src/morphosyntax/mapping_helpers.rs::assemble_mors) collapses Stanza’s 2-word expansion into a single compound%morentry per CHAT word using~/+, so the count invariant holds. The content of that%morentry is what’s wrong: a fake verb lemma plus a spurious enclitic pronoun. - Construction: Italian words whose last one-to-two characters
match a clitic shape (
-la,-lo,-le,-li,-ne,-no,-ni,-mi,-ti,-ci,-vi,-si) are wrapped by Stanza in an MWT Token and analyzed asverb stem + enclitic pronoun, with a bogus stem lemma, regardless of actual part of speech. The defect fires not only on verb forms likeparla(imperative ofparlare) but also on common nouns likearancione(orange),seggiola(chair),gomitolo(ball of yarn),cavallone(big horse),cielo(sky),bottone(button), on adjectives likepiccolo/piccola(small), and on diminutive/augmentative baby-talk forms (coccole,babbolo,pettole). Most of the non-verb hits are tagged withPart Pastfeatures, Stanza confidently treats the whole surface as a past participle plus clitic.
Representative examples and observed end-to-end %mor output
Pulled from the ita-only corpus audit
(scripts/analysis/audit_italian_mor_content.py, pointed via
--root or $TB_DATA_JSON at a pre-parsed JSON snapshot of the
TalkBank CHAT corpora):
| Surface (actual meaning) | Shipped %mor | Features on stem |
|---|---|---|
parla (speak!, imp) | verb|par~pron|la | Fin Imp Pres S2 |
arancione (orange, noun) | verb|arancio~pron|ne | Part Past |
seggiola (little chair) | verb|seggio~pron|la | Part Past |
piccolo (small, adj) | verb|picco~pron|lo | Part Past |
piccola (small, fem adj) | verb|picco~pron|la | Part Past |
gomitolo (ball of yarn) | verb|gomito~pron|lo | Part Past |
divano (sofa) | verb|diva~pron|no | Part Past |
cielo (sky) | verb|cie~pron|lo (cie is not a word) | Inf Ind Imp S2 |
bottone (button) | verb|botto~pron|ne | Part Past |
cavallone (big horse) | verb|cavallo~pron|ne | Part Past |
Every row has ONE %mor item for ONE CHAT word, the Range
reassembly worked, the count invariant holds. Every row’s content
is wrong.
Correct output (illustrative)
parla → verb|parlare-Imp-S2 (2sg imperative of parlare)
arancione → adj|arancione OR noun|arancione
seggiola → noun|seggiola-Fem-Sing (little chair)
piccolo → adj|piccolo-Masc-Sing (small, m.sg)
gomitolo → noun|gomitolo-Masc-Sing (ball of yarn)
Italian UD analyses of these forms are well-defined; Stanza’s Italian POS/MWT model fails to produce them for a class of clitic-shaped endings.
Why no tokenizer hack rescues it
The split happens at the POS/depparse layer, not at tokenize.
batchalign’s tokenize_postprocessor hook runs during
Stanza’s tokenize stage and sees a single token parla. The split
is introduced later by Stanza’s POS tagger interpreting parl- as
the imperative stem and producing lemma=par because no real
Italian lemma exists for that fragment. No per-language MWT-override
rule, old or new, operates after POS tagging, so this class of
break is out of reach for the hook. The character-DP realigner in
align_tokens also runs at tokenize, before the POS split.
Fixing this properly would require one of:
- Lemma-driven content rejection: detect the signature
concat(inner_words) == token_text && head_word.lemma == head_word.text: the two-signal discriminator that separates Defect 6 pseudo-splits from legitimate clitic compounds likedammela → da/dare + me + la(where head lemmadare≠ head textda). When it fires, rewrite the merged%morwith a principled substitute (e.g., surface-form lemma plus a bareverb|…POS without the junk enclitic), OR refuse to emit a%morentry and mark the utterance so the user knows Stanza gave up. - Stanza retrain on conversational Italian where
parlaas 2sg imperative is marked with its correctparlarelemma. - Swap Stanza for CLAN’s Italian MOR as the morphosyntax engine for Italian, its lexicon handles these imperative forms correctly.
BA3 mitigation (ACTIVE)
BA3 carries a per-language reconciler
in crates/batchalign-transform/src/morphosyntax/lang_it.rs that collapses
the known Defect 6 mis-splits back to a single %mor entry with
overridden POS/lemma/features. The IT_MIS_SPLIT_OVERRIDES
allowlist covers parla → verb|parlare,
arancione → noun|arancione, piccolo → adj|piccolo,
gomitolo → noun|gomitolo, divano → noun|divano. The reconciler
fires inside map_ud_sentence’s UdId::Range branch and records
the collapsed range so build_gra_and_validate emits a single
%gra relation. See the
Italian chapter §“Reconciler for Defect
6 / 7” for the full allowlist, constraints, and the three-layer
test strategy (lang_it.rs unit tests + synthetic UD integration
tests + end-to-end morphotag golden).
The UD-level xfail probes below remain pinned, they measure
Stanza’s raw output, which the reconciler does not change. The
reconciler operates downstream of Stanza and only affects what
lands in the CHAT %mor tier.
Tests
-
Pinned UD-level observations (xfail): in
batchalign/tests/investigations/test_stanza_mwt_probe_matrix.py:test_stanza_mwt_probe_with_postprocessor[ita__dell_opera_in_context][ita__parla_imperative_forte][ita__parla_imperative_piu_forte]
Each asserts that Stanza produces as many UD words as CHAT words and xfails because Stanza produces one more (the spurious
par + lasplit at UD level). These pins are Stanza-behavior observations, NOT injection-gate failure indicators, the actual%mortier emits one item per CHAT word for each of these inputs because Stage 3 collapses the MWT Range. The pins exist so a Stanza upgrade that fixes the UD-level anomaly (or a BA3 content-rejection rule that intercepts the junk before Stage 3) surfaces as XPASS. -
Free-tokenize twin (no postprocessor): the matrix also runs each case through a plain Stanza pipeline without BA3’s tokenizer-postprocessor context, confirming the split is native Stanza behavior, not an artifact of our realignment hook.
-
Content-quality probes are NOT yet defined. There is no automated assertion today that
%morcontent forparla fortematches the correctverb|parlare-Imp-S2 adj|forte-S1. Adding such a probe requires either a manually-curated expected-%morfixture per case or an oracle (e.g., CLAN’s Italian MOR output). Flagged as a gap.
Re-evaluation criteria (when Stanza upgrades)
- Re-run the xfail test. If it starts passing unexpectedly, Stanza has fixed the POS-layer split, remove the xfail mark and update this entry to a resolved state.
- If the split shifts to a different surface form (e.g., stops on
parlabut starts onguarda → guard+a), extend the probe case list and re-document.
Related
Other Italian verbs that share the surface-ending risk (-la, -lo,
-le, -li, -mi, -ti, -ci, -vi, -si, -ne) have not yet
been systematically probed. See the
Italian chapter for the broader audit
context and the full list of examined constructions.
Scope evidence
An earlier corpus-wide audit of committed %mor content counted
65 Defect-6 hits across 417 Italian files and 15 distinct surface
forms. The top surfaces were parla (15), arancione (13),
piccolo (10), seggiola/piccola/divano/trottola (3-4 each),
and a long tail of single-file occurrences. The audit script itself
was a maintainer-side probe that did not survive into the current
public source tree; re-running it for delta measurement requires
rebuilding the probe against the current pre-parsed JSON snapshot.
A narrower probe against an ita-only main-tier scan found 73
occurrences of parla specifically across 43 files. Probes in
batchalign/tests/investigations/_cases/italian.py pin the verb
subclass (parla_imperative_forte, parla_imperative_piu_forte,
dell_opera_in_context) plus representative noun/adjective hits
(arancione_noun_bogus_verb, piccolo_adj_bogus_verb) as UD-level
Stanza-behavior observations. The minimal end-to-end
batchalign3 morphotag run characterizes what %mor actually ships
downstream:
| Input | Stanza UD structure (after MWT expansion) | Actual %mor emitted | Linguistic correctness |
|---|---|---|---|
parla forte | par/par/VERB + la/la/PRON + forte/forte/ADJ wrapped as Token(1,2)+Token(3,) | `verb | par-Inf-S~pron |
parla più forte | par/par/VERB + la/la/PRON + più/più/ADV + forte/forte/ADJ | `verb | par-Inf-S~pron |
parla dell'opera nuova | par/par + la/la + dell'/(di+l') + opera + nuova | compound `verb | par-…~pron |
la storia parla di un bambino | la/(il+i) + storia + parla/parlare/VERB + di + un + bambino | `det | il-Masc-Def-Art-Sing~det |
dammela | da/dare/VERB + me/me/PRON + la/la/PRON wrapped as Token(1,3) | `verb | dare-Inf-Ind-Imp-S2~pron |
per favore dammela | per/ADP + favore/NOUN + dammela/dammelo/ADJ (no MWT expansion mid-sentence) | `adp | per noun |
Three conclusions, all pipeline-verified:
- The
%morcount invariant is NOT violated by any of these. Stage 3’sassemble_morscollapses MWT Range components into a single compound%morusing~/+, so every CHAT word gets exactly one%moritem. Themor_count_parity_reference_corpus.rstest still passes. Defect 6 is not an injection-gate failure , it’s a linguistic-content failure downstream of Stanza’s POS layer. dammelaalone is handled correctly. Stanza produces the right imperative+clitic analysis (darelemma +me+laclitics), Stage 3 assembles the compound%mor, and the output matches UD convention for Italian clitic compounds. No bug here.dammelamid-sentence (per favore dammela) fails for a different reason. Stanza misclassifies the entire compound as ADJ with lemmadammelo, skipping MWT expansion entirely. This is a separate Italian Stanza defect (Defect 8, mitigated by a single-chunk POS/lemma override, see Defect 8 below).
A discriminator between Defect-6-style junk (parla → par+la with
lemma=par) and a legitimate clitic compound (dammela → da+me+la
with lemma=dare) is not needed at the %mor injection level
, the mapper already handles both correctly in terms of count.
The per-language reconciler in crates/batchalign-transform/src/morphosyntax/lang_it.rs takes
a different approach than lemma-equality heuristics, it uses a
closed allowlist of known-defective input-token texts (parla,
arancione, piccolo, gomitolo, divano, Defect 7’s la) and
overrides only those. The dammela regression guard test confirms
genuine verb+clitic compounds remain correctly merged. A
lemma-equality heuristic might have broader coverage but would risk
false positives on legitimate Italian verbs whose lemma happens to
match the surface form. Allowlist-first is safer; corpus-sweep
evidence can extend it case-by-case.
Defect 7: Italian sentence-initial article la gets junk MWT expansion (il + i)
- Stable slug:
stanza-it-la-sentence-initial-split - Stanza version: 1.11.1, 1.12.0, 1.12.1, and 1.13.0 (all confirmed; re-verified via MWT probe matrix
xfailmarkers held on 2026-05-28 and again on 2026-06-19, zero XPASS) - MWT package: Italian default
- Failure class: linguistic-content quality. Stage 3’s
assemble_morscollapses the bogus 2-word expansion into a single compound%morentry per CHAT word, so the count invariant holds. The content is wrong:det|il-Masc-Def-Art-Sing~det|il-Masc-Def-Art-Plurfor a feminine-singular article carries wrong lemma, wrong features, and wrong number agreement. - Construction: Sentence-initial feminine singular article
la(as inla storia,la casa) is wrapped by Stanza in an MWT Token whose inner words areil+i, both tagged DET and both lemmatized toil. This is spurious,lais a single-morpheme article that should be analyzed asdet|la-Fem-Def-Art-Sing.
Input and observed end-to-end %mor output
*CHI: la storia parla di un bambino .
%mor: det|il-Masc-Def-Art-Sing~det|il-Masc-Def-Art-Plur noun|storia-Fem
verb|parlare-Fin-Ind-Pres-S3 adp|di det|uno-Masc-Ind-Art-Sing
noun|bambino-Masc .
%gra: 1|3|DET 2|3|DET 3|4|NSUBJ 4|0|ROOT 5|7|CASE 6|7|DET 7|4|OBL 8|4|PUNCT
The first %mor chunk is ONE item for the CHAT word la (Range
collapse works), but the linguistic content is two masculine-article
readings, neither of which matches the input’s feminine-singular
la. The rest of the utterance is linguistically correct
(parla mid-sentence gets its proper parlare-Fin-Ind-Pres-S3
analysis, unrelated to Defect 6).
Correct output
*CHI: la storia parla di un bambino .
%mor: det|la-Fem-Def-Art-Sing noun|storia-Fem verb|parlare-Fin-Ind-Pres-S3
adp|di det|uno-Masc-Ind-Art-Sing noun|bambino-Masc .
One item per CHAT word with the right feminine-singular analysis on
la. Stanza’s Italian MWT model does not produce this today.
Scope: position sensitivity not yet characterized
The spurious expansion has only been observed at sentence-initial
position in the current probe matrix. It is not yet known whether
mid-sentence la (e.g., vedo la storia) also triggers it. The
MWT probe in scripts/analysis/probe_stanza_italian_mwt_metadata.py
has a hook for extending the case list; do that before proposing a
fix.
Why no tokenizer hack rescues it
Same structural limitation as Defect 6: the expansion is produced by
Stanza’s MWT processor, which runs after tokenize. The
tokenize_postprocessor hook sees a single token la and cannot
block the downstream MWT rule from firing.
BA3 mitigation (ACTIVE)
The per-language reconciler introduced for Defect 6 also handles this
case. The la entry in IT_MIS_SPLIT_OVERRIDES in
crates/batchalign-transform/src/morphosyntax/lang_it.rs catches the
Range parent whose text is la, regardless of the specific
component texts Stanza emits. The reconciler replaces the junk
det|il-Masc-Def-Art-Sing~det|il-Masc-Def-Art-Plur with a
single det|il-Fem-Def-Art-Sing (or equivalent) %mor entry.
Same reconciler architecture as Defect 6, different allowlist
entry. The two defects are orthogonal at the detection level
(Defect 6’s components concatenate back to the input text;
Defect 7’s do not, e.g. il + i ≠ la) but the reconciler’s
range-parent-text lookup handles both uniformly, it doesn’t
need to distinguish defect families because the allowlist key
IS the input token, not the component signature.
See Italian §“Reconciler for Defect 6 / 7” for the full allowlist.
Tests
- Pinned UD-level observation (xfail):
batchalign/tests/investigations/test_stanza_mwt_probe_matrix.py::test_stanza_mwt_probe_with_postprocessor[ita__parla_3sg_storia_context]asserts that Stanza produces exactly 6 UD words for the 6-word CHAT input. It fails because Stanza produces 7 (spuriousla → il + isplit at UD level). The xfail is a Stanza-behavior pin, not an injection-gate failure indicator, the actual%mortier emits correctly with 6 items because Stage 3 collapses the Range. The pin exists so a Stanza upgrade that fixes the UD-level anomaly surfaces as XPASS; when that happens,%morcontent will also improve automatically.
Re-evaluation criteria (when Stanza upgrades)
- Re-run the xfail test. If it flips to unexpected pass, Stanza has fixed the sentence-initial expansion, remove the xfail mark and update this entry to a resolved state.
- If the expansion shifts to a different surface form (e.g.,
leorlialso start expanding), extend the probe case list and re-document.
Related
Shares the “post-tokenize architectural gap” with Defect 6. A single post-POS/MWT reassembly pass could in principle address both, but the detection rule is different: Defect 6 concatenates back to the input token, Defect 7 does not. See the Italian chapter and Defect 6 for the full audit context.
Defect 9: English forms the CHILDES lexicon licenses for one category get another from sentence-final punctuation
- Stable slug:
stanza-en-lexicon-unambiguous-category-lost-to-terminator - Stanza version: 1.11.1 and 1.14.0 (both confirmed 2026-09-10 with
combined_charlm; theewtandgumPOS packages show the same behaviour forwhoopsand differ only ondoggy) - MWT package:
gum - Failure class: linguistic-content quality. Tokenization and the parse are fine; the category, lemma and features of one word are wrong.
Construction
Stanza’s English model is trained on punctuated sentences and reads the
terminator as evidence about the last content word. For most sentences that
evidence helps (a 100-file measurement of withholding it lost more copulas,
demonstratives and verb/gerund distinctions than it gained; see
nlp-engine-text-input.md). For a word whose category is not in doubt, it
can push the model into a category the CHILDES lexicon never licenses:
| input to Stanza | whoops | doggy |
|---|---|---|
whoops / where 's the doggy (no terminator) | INTJ | NOUN |
whoops . / where 's the doggy ? | NOUN Number=Plur, lemma whoop | ADJ Degree=Pos |
The MOR lexicon (TalkBank/mor, eng/eng/lex) has whoops only in
co.cut (communicator) and doggy only in n-irr.cut ("dog-DIM").
MOR+POST could not have produced either analysis: MOR licenses categories
from the lexicon and POST only chooses among them.
Input and observed output
*MOT: whoops .
%mor: noun|whoop-Plur . ← observed, Stanza 1.14.0 with the terminator
%mor: intj|whoops . ← correct (and what BA3 produced before 2026-08-01)
*MOT: where's the doggy ?
%mor: adv|where~aux|be-Fin-Ind-Pres-S3 det|the-Def-Art adj|doggy-S1 ? ← observed
%mor: adv|where~aux|be-Fin-Ind-Pres-S3 det|the-Def-Art noun|doggy ? ← correct
BA3 mitigation (ACTIVE)
The lexicon is applied the way MOR applies it, as a constraint on the category, at stage 4 (post-depparse, pre-map-UD) beside the Defect 1 rewrite:
crates/batchalign-transform/data/eng_lexicon_verdicts.json: for every surface form whose entries across all.cutfiles license exactly one of {communicator, plain noun} (and, for nouns, agree on lemma and number), the verdict. Derived, not hand-written:cargo run -p batchalign-transform --example gen_eng_lexicon_verdicts -- <mor>/eng/eng/lex <commit> <out>reads the lexicon at a pinnedTalkBank/morcommit recorded in the file’ssourceblock, fails closed on any line it cannot parse, and validates the result through the runtime’s own constructor. Ambiguous forms (whoopisco,nandv;well,back,seed) get no verdict.- A noun verdict is also withheld when MOR could derive a non-noun reading
by rule: the form minus a productive suffix (verb inflection
-s,-ing,-ed; the0affix.cutderivations whose result is not a noun, such as adjectival-y,-ish,-less,-able, adverbial-ly, verbal-ize) is a lexicon base of the class the suffix attaches to. That is what keepssticky(stick+-y),buildingandfeeling(verb +-ing) andlives(live+-s) out of the verdicts; without it every one of them was wrongly rewritten in the first Bates run. It is also whydoggy(dog-y) gets no verdict and its ADJ reading stands: the lexicon alone cannot settle a form MOR itself would hand to POST. Communicator verdicts carry no such guard:co.cutis a closed, curated class for child-directed speech, and across the Bates measurement none of its roughly seventy overrides was wrong.
crates/batchalign-transform/src/morphosyntax/lexicon.rs: the.cutparser, verdict derivation with the derivability guard, and the validatedLexiconVerdictstype.crates/batchalign-transform/src/morphosyntax/invariants/lexicon_category.rs: where a verdict exists and Stanza’s UPOS contradicts it, the category, lemma and category-determined features are replaced (intj|whoops,intj|byebye,noun|banana); the parse is kept, as the Cantonese POS override keeps it. Three kinds of word are never touched: MWT components (gonnaarrives asgon+na, andnais a communicator), capitalized words (CHAT main tiers are lowercase, so capitalization is the transcriber marking a name:Gin), and a PROPN reading of a lexicon noun (Rose,Daisy). It runs before the Defect 1 rescue, which outranks it.
Onomatopoeia is not part of this: UD has no tag for it, and the transcriber
marks it (wooo@o), which the special-form path already honours. Verb
verdicts are deliberately excluded: a verb’s tense and person were computed
by Stanza under the wrong category and cannot be recovered from the lexicon.
Regenerating the verdicts on 2026-09-10 required first fixing the upstream
lexicon: a committed git conflict block in adj.cut, a stray marker in
n.cut, 268 verbatim duplicate entries, 68 whitespace irregularities and one
) for }; all repaired and pushed to TalkBank/mor (6ef0745, a1c3fce).
Tests
lexicon::tests::embedded_english_verdicts_loadpins that the shipped data loads, carrieswhoopsandbanana, and does NOT carrywhoop,doggy,stickyorbuilding.invariants::lexicon_category::tests::whoops_with_a_period_is_a_communicator_in_mor,banana_read_as_an_interjection_is_a_noun_in_moranddoggy_is_derivable_and_therefore_left_to_stanzarun the exact Stanza 1.14.0 analyses through the production invariant dispatcher and mapping; the third pins the mechanism’s limit rather than hiding it.- The remaining tests in both modules pin the parser grammar (comments, glosses, stems, affix entries), the ambiguity rule, the constructor’s refusals, and that ambiguous, unknown, proper-noun, range and punctuation words are untouched.
Re-evaluation criteria
On a Stanza upgrade, run scripts/debug/stanza_pos_probe.py (talkbank
workspace) on whoops . and where 's the doggy ?. If the model returns
INTJ and NOUN with the terminator present, the two seam tests still pass
with the constraint disabled and the constraint can be narrowed; the data
file and rule stay until every verdict it carries is redundant, which no
single model version is expected to make true.
Defect 10: English CHAT contractions outside Stanza’s MWT vocabulary are left whole and given an invented category and lemma
- Stable slug:
stanza-en-chat-contractions-not-expanded - Stanza version: 1.14.0 (confirmed 2026-09-10,
combined_charlm, MWTgum) - MWT package:
gum - Failure class: tokenization and linguistic content. One CHAT word that is two or three words is left as one token, so the category, lemma and features are invented and the parse hangs the clause off the wrong word.
Construction
Stanza’s English MWT expander knows gonna, wanna and gotta from its
training data and returns gon + na, which the mapping renders as
verb|go-Part-Pres-S~part|to. CHAT transcribers also write hafta, hasta,
hadta, oughta, useta, sposta, gimme, lemme and dunno. The
expander has never seen them, leaves them whole, and the tagger guesses:
| input to Stanza | whole-token analysis | expanded input, Stanza’s own analysis |
|---|---|---|
you hafta put that one in . | hafta AUX, lemma hafta, aux of put (root) | have VERB root; put xcomp of have; to mark of put; you and . on have |
do you hafta go ? | hafta PART Polarity=Neg, advmod | have VERB root, do aux of have |
lemme see . | lemme INTJ, discourse of see | let root, me obj, see xcomp |
I dunno . | dunno VERB, lemma dunno | know root, do aux, n't advmod |
Hinting the expander would only produce a seq2seq guess; the expansions are fixed.
Input and observed output
*MOT: you hafta put that one in .
%mor: pron|you-Prs-Nom-S2 aux|hafta-Fin-Ind-Pres-S2 verb|put-Inf-S ... ← observed
%gra: 1|3|NSUBJ 2|3|AUX 3|0|ROOT ...
%mor: pron|you-Prs-Nom-S2 verb|have-Fin-Ind-Pres-S2~part|to verb|put-Inf-S ... ← correct
%gra: 1|2|NSUBJ 2|0|ROOT 3|4|MARK 4|2|XCOMP 5|6|DET 6|4|OBJ 7|4|COMPOUND-PRT 8|2|PUNCT
BA3 mitigation (ACTIVE)
crates/batchalign-transform/src/morphosyntax/invariants/english_contractions.rs,
first in the English stage-4 chain (tokens before categories): a typed table
(Contraction { surface, parts, complement }, each Part with its text,
lemma, UPOS, the features the form spells, and its Attachment: host,
infinitival marker, object pronoun, auxiliary or negation) synthesizes
exactly the shape Stanza produces for the words it does expand: a Range
parent plus components, every later id and head renumbered, what depended
on the token now depending on the host. The tree is reshaped the way Stanza
parses the expanded words: when Stanza made the token a dependent of the
verb it governs, the host is raised into that verb’s position, the verb
becomes its xcomp, to marks the verb, and the verb’s dependents are
re-homed the way Stanza attaches them in the expanded sentence: clause-level
relations (subject, punct, discourse, vocative, parataxis) always
move to the host, the verb’s arguments (obj, iobj, xcomp, ccomp,
particles) never do, and everything else (auxiliaries, copulas, negation,
markers, conjunctions, adjuncts) goes with whichever predicate it precedes
or follows: do and n't in I don't hafta go to the host, be in
hafta be pottie and a final now to the verb (probed on 2026-09-10). The finite part keeps the Person and Number
Stanza read off the subject; the table supplies the rest. me is iobj
when the host already has an object (gimme that). Tokens Stanza already
expanded are ranges and are never touched.
Measured on the 100 Bates files (2026-09-10): 45 hafta/hasta
expansions, every %gra consistent with its %mor, all files valid CHAT.
Tests
invariants::english_contractions::tests::hafta_expands_to_have_plus_to_in_morandhafta_gra_has_the_host_as_rootrun Stanza 1.14.0’s exact whole-token analysis through the production dispatcher and mapping and pin the%morand the%gra.expansion_raises_the_host_over_the_verb_it_governs,a_host_read_as_a_modifier_is_raised_over_the_verb,a_host_that_is_already_the_head_keeps_its_xcomp_child,gimme_with_an_object_makes_me_the_indirect_object,lemme_is_raised_over_the_bare_infinitive,three_part_dunnopin each shape;every_contraction_has_one_hostchecks the table;tokens_stanza_already_expanded_are_untouchedandunknown_words_are_untouchedpin the no-op paths.
Re-evaluation criteria
On a Stanza upgrade, run scripts/debug/stanza_pos_probe.py (talkbank
workspace) on you hafta put that one in ., lemme see . and I dunno ..
A form the expander now splits arrives as a range and the table entry for it
is redundant; remove it only when the expansion Stanza returns matches the
table’s (lemma, category, tree).
Defect 11: English tags and responses set off by a pause take a content-word reading from the terminator
- Stable slug:
stanza-en-isolated-communicator-read-as-content-word - Stanza version: 1.14.0 (confirmed 2026-09-10,
combined_charlm) - MWT package:
gum - Failure class: linguistic-content quality. The parse is fine; the category of one word is wrong, and the evidence that settles it is in the transcript, not in what Stanza is given.
Construction
A word the CHILDES lexicon lists as a communicator among other categories
(okay is also an adjective; right an adjective and adverb; no a
determiner; honey, boom, morning nouns) is genuinely ambiguous to
MOR, and MOR hands it to POST. Stanza, shown the terminator, resolves a tag
question as the adjective: put the lady on the chair (.) okay ? gives
adj|okay-S1. Defect 9’s lexicon constraint cannot fire, because the
lexicon does not settle it. What settles it is the pause: the transcriber
set the word off, and a word set off on both sides (pause, separator or
utterance edge) is a discourse element, not a constituent of the clause
beside it. you okay ? has no pause and is a predicate.
Which readings the evidence may overturn is decided by whether the reading
can head an utterance by itself. An imperative can (look !, see ?,
wait . are complete clauses, and UD tags them VERB), so a verb or
auxiliary reading stands even where the lexicon has co|look. An isolated
adjective, adverb, determiner, noun or pronoun cannot be a whole utterance
without an elided predicate, so with the lexicon’s communicator reading
available, that is the reading. Measured against 716 files of CLAN MOR+POST
output for the convention: see alone is v|see 124 of 124 times, okay
alone is co|okay 731 of 768, right 90 of 91, no 903 of 923.
Input and observed output
*MOT: put the lady on the chair (.) okay ?
%mor: ... noun|chair adj|okay-S1 ? ← observed
%mor: ... noun|chair intj|okay ? ← correct
*MOT: ball (.) right .
%mor: noun|ball adj|right-S1 . ← observed
%mor: noun|ball intj|right . ← correct
*MOT: you okay ?
%mor: pron|you-Prs-Nom-S2 adj|okay-S1 ? ← observed and correct: no pause
BA3 mitigation (ACTIVE)
crates/batchalign-transform/src/morphosyntax/evidence.rs:UtteranceEvidence::from_utterancere-walks the utterance’s content in the Mor domain (the same descent the payload extractor used) and records, per payload word, whether a break precedes and follows it (WordIsolation). A pause is a break; a separator the payload kept (a comma, a CHAT tag or vocative marker) is a break on both sides; the utterance edges are breaks. A word-like item the payload did not keep (xxx, an omitted0word) is speech, not silence: it clears a pending break and keeps the word before it off the utterance edge, somy xxx xxx .is not a one-word utterance. A genuine mismatch leaves every later word unbroken, which is the direction in which no rewrite fires. Computed once per utterance ininjection.rsbeside the payload’s words and handed toapply_grammatical_invariants.crates/batchalign-transform/data/eng_lexicon_verdicts.jsonandlexicon.rs: a third verdict,CommunicatorAmongOthers, for the 90 formsco.cutshares with other categories (okay,right,no,well,honey,boom, …). Defect 9’s rule ignores it; only this rule uses it.crates/batchalign-transform/src/morphosyntax/invariants/discourse_marker.rs: after the contraction expansion and before the lexicon constraint, every top-level, non-terminator token is paired with its payload word by position (the same count the mapper makes: a range parent or a single token is one%moritem), and an isolated word with a communicator verdict whose Stanza category cannot stand alone (exhaustive match onUniversalPos) becomesintj|word. The parse is kept.
Measured on the 100 Bates files (2026-09-10): 181 rewrites in 76 files, all
tags, responses, vocatives or onomatopoeia (okay 48, right 77, no 14,
honey 10, boom 5, morning, babe, so, fine, alright, well,
vroom, knock, oh (.) my); look . (88), see ? (60), help !,
wait, say +"/. untouched; %gra byte-identical; all files valid CHAT.
Tests
evidence::testsparse CHAT and pin the isolation for a pause before the final word, a predicateokay, an initial word before a pause, a one-word utterance, a filler the payload drops,xxxslots, and a comma.invariants::discourse_marker::tests::an_isolated_okay_tag_is_an_interjection_in_morruns Stanza’s exact analysis through the production dispatcher and mapping with the embedded lexicon;without_the_pause_the_adjective_reading_stands,an_isolated_imperative_keeps_its_verb_reading,a_kept_separator_counts_as_a_payload_wordand the untouched cases pin the rule’s edges.
Re-evaluation criteria
Stanza cannot fix this: it never receives the pause. The rule is retired only if the payload starts carrying prosodic evidence into the model, which no Stanza release offers.
Defect 8: Italian imperative+enclitic compounds mid-sentence mis-tagged as ADJ
- Stable slug:
stanza-it-compound-imperative-mid-sentence-adj - Stanza version: 1.11.1, 1.12.0, 1.12.1, and 1.13.0 (all confirmed; re-verified via MWT probe matrix
xfailmarkers held on 2026-05-28 and again on 2026-06-19, zero XPASS) - MWT package: Italian default
- Failure class: linguistic-content quality. Stanza tokenizes the compound correctly (one UD word) but mis-classifies its POS and normalizes the lemma.
Construction
Italian imperative+enclitic compounds (dammela, dammelo,
similar) are correctly handled when they appear alone, Stanza’s
MWT processor fires and emits a three-word expansion
(verb|dare~pron|me~pron|la). In mid-sentence position
(e.g., per favore dammela), Stanza’s MWT processor does NOT
fire. The compound surfaces as a single UD word tagged
ADJ with lemma normalized to dammelo (masculine-singular
reading of the final clitic). The resulting %mor ships as
adj|dammelo-S1 instead of the correct verb|dare-Imp-S2 or
the decomposed verb|dare~pron|me~pron|la.
Input and observed output
Stanza input: ["per", "favore", "dammela"]
Stanza UD output (mid-sentence): [
(per, ADP, lemma=per),
(favore, NOUN, lemma=favore),
(dammela, ADJ, lemma=dammelo), ← mis-tagged
]
%mor without reconciler: adp|per noun|favore-Masc adj|dammelo-S1
BA3 mitigation (ACTIVE)
crates/batchalign-transform/src/morphosyntax/lang_it.rs carries a second
allowlist IT_COMPOUND_IMPERATIVES separate from the
Defect-6/7 IT_MIS_SPLIT_OVERRIDES. Entries name the surface
form, the correct verb lemma, and the correct feats. Current
entries: dammela → dare, dammelo → dare. The reconciler fires
inside map_ud_sentence’s UdId::Single branch, gated on
upos == ADJ + text match against the allowlist.
Scope: the mitigation emits a single-chunk Mor
(verb|dare-Imp-S2) rather than decomposing the compound into
verb + clitic post-clitics. This is a scope trade-off, a multi-
chunk emission from a single UdWord would require extending
build_gra_and_validate’s chunk-counting logic (currently
assumes UdId::Single → exactly one chunk). The single-chunk
fix captures the correct POS and verb lemma, which is a
substantial improvement over adj|dammelo. Multi-chunk
decomposition is a future enhancement.
Extension: new compound imperatives observed in corpus data
are added as one row to IT_COMPOUND_IMPERATIVES plus a
regression test in morphosyntax/tests.rs.
Tests
- Synthetic-UD unit tests in
crates/batchalign/src/chat_ops/nlp/mapping/tests/italian_defects.rs:test_italian_defect8_dammela_mid_sentence_becomes_verbtest_italian_defect8_dammelo_mid_sentence_becomes_verbtest_italian_defect8_genuine_adj_stays_adj(control)
- Allowlist unit tests in
crates/batchalign-transform/src/morphosyntax/lang_it.rs. - End-to-end golden in
batchalign/tests/pipelines/morphosyntax/test_italian_defect6_end_to_end.py::test_dammela_mid_sentence_becomes_verb, runsbatchalign3 morphotagon a CHAT fixture whose@Languages:header declaresita, contentper favore dammela, and asserts the output%morcarriesdare, notadj|dammelo. (Morphotag has no--langflag; language comes from each file’s@Languages:header.)
Re-evaluation criteria
If a Stanza upgrade produces a correct multi-chunk MWT expansion
for mid-sentence compound imperatives (i.e., Stanza emits a Range
for dammela wherever it appears), the Defect 8 allowlist entries
become redundant. Remove them and let the Defect-6/7 Range branch
handle the case uniformly. Tracked by the unit-test RED signal
when the reconciler is disabled and Stanza is re-observed.
Open residue (2026-07-31): the allowlist’s one-UD-word precondition
check_italian_compound_imperative matches IT_COMPOUND_IMPERATIVES rows
against a whole UdWord.text, so a row can only fire when the surface reaches
Stage 3 as ONE UD word. The Italian MWT policy in
batchalign/inference/_italian_mwt.py now force-splits enclisis candidates, so
for most of those rows the precondition no longer holds.
On the nine rows with a non-geminated base (prendilo, aprila, leggila,
…) this is benign and arguably better: the base (prendi, apri, leggi) is
unambiguous, so the general split reaches the same analysis the hand-written row
did, from a rule rather than a list.
dammela and dammelo are the two geminated rows, and there the observed
output was adp|da~pron|me-Prs-S1~pron|la-Prs-S3 with a prepositional parse
(%gra had favore as ROOT and da as CASE). Note this is a DIFFERENT shape
from the ADJ mis-tag above, and the deprels were wrong as well as the head, so
any repair that rewrote POS and lemma in place would have left the parse behind.
RESOLVED (2026-07-31): the utterance was reaching Stanza without its terminator. The worker built Stanza’s input by joining the CHAT words, and dropped the terminator the Rust side had supplied. Stanza’s Italian model uses sentence-final punctuation as evidence, so it was analysing a fragment.
Measured across four conditions with one variable, all through the with-postprocessor production pipeline:
| input | analysis of dammela |
|---|---|
per favore dammela | ADJ, lemma dammelo, amod; no MWT expansion at all |
per favore dammela . | VERB dare root + PRON me + PRON la |
per favore da me la | da ADP, case (the prepositional parse) |
per favore da me la . | VERB dare root + PRON + PRON |
The last two rows are the control that retires the standing hypothesis
“splitting hands the tagger a homograph base”: presented already split, the host
is still ADP without the terminator and VERB with it, so the enclisis split was
never what caused the prepositional reading. MorphosyntaxBatchItem.terminator
had existed all along, with a "." default, and nothing read it. A field that
is supplied, defaulted and unused is invisible to every test, which is why this
survived three rounds of investigation.
The fix is that the worker passes the terminator it is given, in both the text
and the realignment word list, which must move together. Locked as
ita__sentence_period__dammela_needs_its_terminator and its pre-split control
in the decision-probe matrix, so a Stanza upgrade that changes this surfaces as
a test failure rather than as corpus drift.
The question for whoever resolves it is NOT “extend the allowlist over the five
apocopated hosts”. It is whether this table should be REDUCED to the geminated
cases with its one-UD-word precondition stated, since the general rule already
covers the rest. is_geminated_split in _italian_mwt.py is defined, tested
and uncalled against that decision.
Process for adding entries
When a new Stanza limitation is discovered:
- Write a permanent test capturing what Stanza produces for the failing input, with the Stanza version recorded in a comment or docstring. The test asserts the CORRECT behavior and is therefore RED under the current Stanza.
- Add a section here following the format above.
- Implement a principled BA3 mitigation (or, if no mitigation is feasible yet, document the issue and leave the test RED as a known-defect marker with a clear comment).
- Link the test, the mitigation code, and this registry together so future contributors can trace all three in one step.
Process for re-evaluating on Stanza upgrade
- Disable the BA3 mitigations (comment out the override entry points or run with a feature flag).
- Run all tests in this document’s “Tests” sections.
- For each test that flips RED→GREEN without the mitigation, Stanza has improved. Remove or narrow the corresponding BA3 override, update this document, and re-enable normal CI.
- For tests that remain RED, the mitigation is still load-bearing; leave it in place.
This page last changed: 2026-09-10 (commit 0bbd998d). The whole book last changed: 2026-09-16 (commit 34d249d8).
Language-Specific Support Overview
Status: Current Last updated: 2026-05-02 11:20 EDT
batchalign3 processes 50+ languages through Stanza, but several languages have significant special treatment. This page indexes all language-specific behavior.
Adding a new language? Run through the checklist in Adding Language Support first. Skipping it produces silent quality bugs (validator rejections, missing number expansion, hallucinating ASR) that surface later as user complaints.
Languages with Dedicated Pages
| Language | Code | Dedicated Page | Key Special Treatment |
|---|---|---|---|
| Cantonese | yue | Cantonese | 4 ASR engines, text normalization, PyCantonese word segmentation, jyutping FA |
| Mandarin | cmn/zho | Mandarin | Stanza neural word segmentation, Chinese number expansion |
| Japanese | jpn | Japanese | Stanza combined package, retokenize merge/split |
| Hebrew | heb | Hebrew | Fine-tuned Whisper, RTL punctuation, HebBinyan/HebExistential features |
| French | fra | French | Native Stanza MWT + char-DP realign (BA2 elision/multi-clitic hacks removed) |
| Italian | ita | Italian | Native Stanza MWT + char-DP realign; %mor injection count invariant holds; Defect 6 (clitic-shaped words mis-analyzed as verb+clitic: parla, arancione, piccolo, gomitolo, divano) and Defect 7 (la → il + i) mitigated by the per-language allowlist reconciler in crates/batchalign-transform/src/morphosyntax/lang_it.rs: see Italian page §“Reconciler for Defect 6 / 7” |
| Portuguese | por | Portuguese | Native Stanza MWT + char-DP realign (d'água ForceMwt hack removed) |
| Dutch | nld | Dutch | Native Stanza tokenization ('s-suffix SuppressMwt hack removed as dormant) |
| Malayalam | mal | Malayalam | No Stanza pipeline; transcribe-only via HuggingFace Whisper fine-tunes (whisper_hub engine). Same pattern applies to other Stanza-stub languages. |
Special Treatment by Pipeline Stage
flowchart LR
subgraph asr["ASR"]
yue_asr["yue: 4 engines"]
heb_asr["heb: ivrit-ai Whisper"]
other_asr["others: default Whisper"]
end
subgraph norm["Normalization"]
yue_norm["yue: OpenCC + domain table"]
rtl_norm["heb/ara: RTL punct → ASCII"]
end
subgraph seg["Word Segmentation"]
yue_seg["yue: PyCantonese"]
cmn_seg["cmn/zho: Stanza neural"]
jpn_seg["jpn: Stanza combined"]
end
subgraph morpho["Morphosyntax"]
heb_mor["heb: HebBinyan, MWT"]
jpn_mor["jpn: combined package"]
mwt_mor["fr/de/it/es/...: MWT"]
end
Languages with MWT (Multi-Word Token) Processing
These languages load Stanza’s MWT processor for contraction expansion:
fr, de, it, es, pt, ca, cs, pl, nl, ar, tr, fi,
lv, lt, sk, uk, sv, nb, nn, is, gl, cy, gd, mt,
ka, hy, fa, hi, ur, bn, ta, te, kn, ml, th, vi,
id, ms, tl
Languages Excluded from MWT
These use tokenize_pretokenized=True (no MWT processor):
zh (Chinese/Cantonese/Mandarin), ja (Japanese), ko (Korean),
hr, sl, sr, bg, ru, et, hu, eu, el, he, af,
ga, da
Number Expansion Coverage
47 languages have dedicated number-expansion tables in num2lang.json,
plus dedicated converters for CJK and English-specific modes. Full
matrix at Number Expansion.
| Family | Coverage |
|---|---|
43 codegenned via num2words | English, Spanish, French, German, Italian, Portuguese, Dutch, Scandinavian languages, Russian, Polish, Czech, Turkish, Thai, Telugu, Bengali, Kannada, Indonesian, … |
| 4 hand-curated | Malayalam, Greek, Basque, Croatian |
| Chinese (Simplified) | num2chinese (一万), Mandarin |
| Chinese (Traditional) | num2chinese (一萬), Cantonese, Japanese |
| English-only | Ordinals (13th → “thirteenth”), decades (1950s → “nineteen fifties”), years |
| Validator-permits-digits | Welsh, Vietnamese, Min Nan, Hakka, no expansion needed |
All other languages pass digits through and trip E220 at validation.
See Also
- Language-Specific Processing, pipeline-stage-level overview
- Language Code Resolution, ISO 639-3 to Stanza mapping
- Language Data Model,
@Languagesheader and per-file language routing
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Cantonese Language Support
Status: Current Last updated: 2026-09-15 12:12 EDT
User reference for Cantonese (yue) processing in batchalign3, ASR engine
options, credentials, retokenize usage, and what to expect from each
pipeline stage. For the architecture and rationale (engine dispatch,
normalization pipeline, segmenter selection, source-file map), see
Cantonese and CJK, Architecture.
Quick Reference
| Pipeline stage | Cantonese-specific behavior |
|---|---|
| ASR | 5 engine options: FunASR/SenseVoice (default), Tencent Cloud, Aliyun NLS, Qwen3-ASR, Whisper |
| Text normalization | Simplified → Traditional + 31-entry domain replacement (automatic) |
| Number expansion | Traditional Chinese characters (五, 四十二, 一萬) |
| Character tokenization | Per-character splitting for timestamp alignment |
| Word segmentation | PyCantonese segment() via --retokenize |
| Utterance segmentation | PolyU BERT model (PolyU-AngelChanLab/Cantonese-Utterance-Segmentation) in standalone utseg and transcribe pre-CHAT segmentation; falls back to punctuation |
| Morphosyntax (POS) | PyCantonese override (~95% on core vocab) layered on Stanza Chinese (zh) |
| Morphosyntax (depparse) | Stanza Chinese (zh), Mandarin-trained, but better than nothing |
| Forced alignment | Jyutping romanization (PyCantonese) → Wave2Vec MMS |
ASR engine options
The default for yue is FunASR/SenseVoice: a local model that
empirically outperforms vanilla Whisper-large-v3 by a wide margin
on Cantonese child speech (42.8% CER vs 81.9% CER on TalkBank Tier
3 fixtures; see the 2026-05-26 Cantonese ASR benchmark). The
default is wired in batchalign/worker/_model_loading/asr.py’s
_LANG_DEFAULTS table and applies when no --asr-engine is passed
and no Rev.AI key is configured. Alternatives are selected with
--asr-engine; run batchalign3 transcribe --help for the full list.
| Engine | Type | Credentials | Word output | Strength |
|---|---|---|---|---|
| FunASR/SenseVoice (default for yue) | Local | None | Per-character | No cloud, VAD built-in, lowest measured CER on child speech |
| Tencent Cloud | Cloud | Required | Per-character | Speaker diarization, strong on clean adult speech |
| Aliyun NLS | Cloud | Required | Per-character | Real-time streaming |
| Qwen3-ASR | Local | None | Per-character | Alibaba open-weight ASR; competitive on per-utterance Cantonese child speech in external evaluations (unverified on TalkBank’s longer-form fixtures) |
| Whisper | Local | None | Per-character | General-purpose multilingual; worst measured on TalkBank Cantonese: not recommended unless other engines are unavailable |
Usage
# Default (FunASR/SenseVoice), no flag needed
batchalign3 transcribe input/ -o output/ --lang yue
# Tencent Cloud ASR (requires credentials)
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine tencent
# Aliyun NLS ASR (requires credentials)
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine aliyun
# Qwen3-ASR (default 1.7B variant; pinned via qwen_model for 0.6B)
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine qwen
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine qwen --engine-overrides '{"qwen_model": "Qwen/Qwen3-ASR-0.6B-hf"}'
# Whisper (explicit opt-in; not recommended for Cantonese)
batchalign3 transcribe input/ -o output/ --lang yue \
--asr-engine whisper
# Cantonese forced alignment
batchalign3 align input/ -o output/ --lang yue \
--fa-engine cantonese
Credentials
Cloud engines (Tencent, Aliyun) require credentials in ~/.batchalign.ini:
[asr]
# Tencent Cloud
engine.tencent.id = YOUR_SECRET_ID
engine.tencent.key = YOUR_SECRET_KEY
engine.tencent.region = ap-guangzhou
engine.tencent.bucket = YOUR_COS_BUCKET
# Aliyun NLS
engine.aliyun.ak_id = YOUR_ACCESS_KEY_ID
engine.aliyun.ak_secret = YOUR_ACCESS_KEY_SECRET
engine.aliyun.ak_appkey = YOUR_APPKEY
Missing or empty credentials raise ConfigError with a clear message.
Engine details
Tencent Cloud ASR. Speaker diarization with configurable count. Uploads audio to COS, submits ASR job, polls for results (10-min timeout). Returns pre-segmented words with per-word timestamps and speaker attribution. Automatic COS cleanup after job completes.
Aliyun NLS ASR. Cantonese only (lang=yue required). WebSocket
streaming with real-time callbacks. Automatic token refresh (23-hour TTL).
WAV format required (16 kHz mono).
FunASR/SenseVoice. Local model, no cloud credentials, no network.
Auto model selection: Paraformer or SenseVoice based on availability.
VAD built in. Per-character timestamp alignment. Wired as the
per-language default for yue via _LANG_DEFAULTS in
batchalign/worker/_model_loading/asr.py so a bare
batchalign3 transcribe --lang yue ... invocation no longer falls
through to Whisper.
Qwen3-ASR. Alibaba’s open-weight Cantonese-capable ASR
(qwen-asr Python package, model downloaded from HuggingFace on
first use). Two variants are publicly released, 1.7B (default,
heavier) and 0.6B (lighter, smaller download). Select the 0.6B
variant via --asr-engine qwen --engine-overrides '{"qwen_model": "Qwen/Qwen3-ASR-0.6B-hf"}'. Note the division of labour:
the ENGINE is chosen with the flag, while per-engine extras like
qwen_model and qwen_device stay in --engine-overrides, which is
what that option is actually for. External evaluations report competitive
CER on per-utterance Cantonese child speech with the 1.7B variant;
TalkBank’s own longer-form Cantonese fixtures show this engine
benefits from per-utterance segmentation rather than full-session
input.
The Qwen3-ASR worker always pairs the ASR model with
Qwen/Qwen3-ForcedAligner-0.6B, Qwen’s companion forced-aligner
model. The aligner is loaded at worker bootstrap (~1.2 GB
additional download on first use, then cached) so the ASR pipeline
emits word-level timestamps the downstream FA stage can consume.
This pairing is required, not optional: the qwen-asr library
raises ValueError from model.transcribe(..., return_time_stamps=True) whenever the aligner was not supplied at
Qwen3ASRModel.from_pretrained(...). Dropping
return_time_stamps=True to “fix” that error degrades every
downstream %wor tier and is therefore not a permitted shortcut;
the only correct configuration is the aligner-paired one.
Cantonese forced alignment. Converts Chinese characters to jyutping romanization (via PyCantonese), strips tone numbers for Wave2Vec compatibility, runs Wave2Vec FA on romanized text, maps word-level timings back to original characters.
Text Normalization
All Cantonese ASR output is automatically normalized regardless of which
engine produced it. No configuration. Simplified → Traditional via
OpenCC s2hk, then a 31-entry domain replacement table for
Cantonese-specific corrections (真系→真係, 中意→鍾意, 系→係,
呀→啊, 松→鬆, …).
Full example: 你真系好吵呀 → 你真係好嘈啊.
Where it runs, and how often. In the Rust server, once per monologue,
before any stage splits the words (stage 2d of ASR post-processing). The ASR
engines themselves hand back their own characters unchanged, so the transcript
reads the same whichever engine produced it. Normalizing per word would lose
every multi-character replacement, because these engines report one word per
Han character; normalizing twice would undo some of them, because the table
maps 繫 to 係 and would turn a converted 聯繫 back into 聯係.
What you would see if it ever could not run. Normalization hands each word
back exactly the characters it contributed, which is only sound while the
conversion preserves the character count. If it did not, the file is refused
with a message naming both counts, rather than producing a transcript whose
timings have quietly moved onto different characters. No measured input does
this: every Han code point and every entry of OpenCC’s own s2hk dictionaries
was checked (191,125 strings, none changed length).
The replacement table was originally written by Chuqiao Song in
batchalign2’s replace_cantonese_words() (Python + OpenCC C++). Rebuilt
in Rust for batchalign3, no C++ dependency, always available, correct
overlapping pattern handling.
Word Segmentation: --retokenize
FunASR/SenseVoice and Whisper output per-character tokens for Cantonese: each character becomes a separate word on the main tier. This makes word counts, MLU, and POS tagging unreliable.
# Morphotag has no --lang flag, the per-file @Languages: header drives
# routing. For Cantonese files (yue), retokenize is the right default.
batchalign3 morphotag --retokenize corpus/ -o output/
This uses PyCantonese’s segment() to group per-character tokens into
words before Stanza POS tagging. Cantonese files are detected from each
file’s @Languages: yue header, there is no morphotag --lang flag.
Before (per-character):
*CHI: 故 事 係 好 .
%mor: n|故 n|事 v|係 adj|好 .
After (--retokenize):
*CHI: 故事 係 好 .
%mor: n|故事 v|係 adj|好 .
Without --retokenize, tokenization is preserved unchanged. A diagnostic
warning is emitted when Cantonese input appears per-character:
warn: Cantonese input appears to be per-character tokens (42/50 single-CJK words).
Consider --retokenize for word-level analysis.
Validation across all 9 TalkBank Cantonese corpora
Word segmentation was tested against all 9 Cantonese corpora in TalkBank
(over 737,000 utterances). Multi-character preservation 84-90%,
vocabulary coverage 98-100% across MOST, LeeWongLeung, CHCC, EACMC, HKU
(CHILDES), MAIN, GlobalTales, and Aphasia HKU. Test:
batchalign/tests/languages/cantonese/morphosyntax/test_cantonese_all_corpora.py.
Number Expansion
Cantonese uses traditional Chinese number characters: 5 → 五,
42 → 四十二, 10000 → 一萬 (not 一万). Implemented via
num2chinese(n, ChineseScript::Traditional) in Rust. Runs as Stage 4 of
ASR post-processing, after the text-normalization stage (2d), so the numerals
it writes are already in their final form.
See Number Expansion for the full language table.
Utterance Segmentation
Uses the PolyU BERT model
PolyU-AngelChanLab/Cantonese-Utterance-Segmentation. Falls back to
punctuation-based splitting if the model is unavailable. The same model is used
for transcribe’s pre-CHAT segmentation when --lang yue. See
Utterance Segmentation.
Mixed-language morphotag (@s)
In bilingual files, Cantonese-marked words (@s:yue or bare @s resolved to
yue) go through the same default-on secondary-language L2 morphotag path as
other supported languages. Successful secondary dispatch produces real
%mor/%gra; unresolved or unsupported cases still fall back to L2|xxx.
Known limitations
- POS tagging on Cantonese vocabulary. Stanza’s
zhmodel is Mandarin-trained,佢/佢哋(he/they) →PROPN,嘢(thing) →PUNCT,唔(not) →VERB,係(is) →VERB. PyCantonese POS override fixes core vocabulary as post-processing but has dictionary gaps on compound nouns, some SFPs, and resultative verbs. See the architecture page for the full rationale and the trained-but-undeployed Cantonese model. - Word segmentation depends on PyCantonese dictionary. Words not in the dictionary won’t be grouped.
- All four ASR engines produce per-character output for Cantonese,
--retokenizeis needed for all Cantonese morphotag. - FunASR CER varies with speech clarity: increases with overlapping/soft/child speech.
- Per-character warning threshold (80%) is empirical-without-basis, not yet validated against real corpus data.
- Daemon warning visibility. The
tracing::warn!for per-character input fires in the daemon process, not the CLI. Users may not see it until SSE events or job results surface it.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Mandarin Language Support
Status: Current Last updated: 2026-09-16 03:36 EDT
Mandarin (cmn/zho) shares the Stanza zh model and Chinese number
expansion system with Cantonese, but has distinct word segmentation behavior.
Quick Reference
| Pipeline Stage | Mandarin-Specific Behavior |
|---|---|
| ASR | Rev.AI by default; paraformer (FunASR) is the common Mandarin choice, and every other engine works too. See ASR engines. |
| Text normalization | None (Cantonese normalization is yue-only) |
| Number expansion | Chinese number system (num2chinese with simplified script for both cmn and zho) |
| Utterance segmentation | talkbank/CHATUtterance-zh_CN for cmn / zho in standalone utseg and transcribe pre-CHAT segmentation |
| Word segmentation | Stanza neural tokenizer via --retokenize |
| Morphosyntax | Stanza Chinese (zh) model; @s Mandarin words in mixed-language files use the same Chinese morphosyntax path |
| Forced alignment | Wave2Vec MMS (standard) |
Language Codes
| ISO 639-3 | Stanza | Notes |
|---|---|---|
cmn (Mandarin) | zh | Standard Mandarin |
zho (Chinese, generic) | zh | Maps to same Stanza model |
Both cmn and zho map to Stanza zh (which is zh-hans internally).
ASR engines
An earlier version of this page said Mandarin had “no alternative ASR engines”, and listed Tencent, Aliyun and FunASR as Cantonese-only. That was incorrect. Every engine works for Mandarin; none of them is language-gated.
Tencent was the one real gap, and it was a defect rather than a missing
engine: a Mandarin job asked Tencent for a model named 16k_cmn, which
Tencent does not define. Every Han-script variety now asks for
16k_zh_large, the model Tencent does define. See
Tencent (cloud ASR).
# Paraformer, the usual choice for Mandarin.
batchalign3 transcribe Mandarin_mp3 -o out --lang zho --asr-engine paraformer
paraformer is shorthand for the FunASR engine loading the paraformer-zh
checkpoint, so it is equivalent to:
batchalign3 transcribe Mandarin_mp3 -o out --lang zho \
--asr-engine funaudio \
--engine-overrides '{"funaudio_model":"paraformer-zh"}'
An explicit --engine-overrides wins, so pass one to pick a different
checkpoint. The full engine list is in
transcribe, and
batchalign3 transcribe --help prints the same list.
Whichever route you take, the transcript records what ran. The paraformer
alias resolves to the checkpoint this build pins, together with the
voice-activity and punctuation models Paraformer loads with it, and all three
are written into the stamp’s asr_model= field. A checkpoint this build does
not pin still loads; it is recorded at the revision the worker reports for it,
so the transcript names the weights either way rather than only the engine.
Note that Paraformer’s checkpoint loads a punctuation model, so its raw output carries CJK punctuation; our post-processing converts 。,!? to token boundaries, which is what CHAT wants. Output therefore differs from tools that leave the punctuation in place.
Word Segmentation
Mandarin ASR output (from Whisper or Paraformer) may contain per-character tokens without word boundaries, the same problem that affects Cantonese.
The --retokenize Solution
# Morphotag has no --lang flag, Mandarin files are detected from each
# file's @Languages: cmn (or zho) header.
batchalign3 morphotag --retokenize corpus/ -o output/
This uses Stanza’s neural Chinese tokenizer (tokenize_pretokenized=False)
to segment text into words before POS tagging. The tokenizer model is loaded
lazily on first --retokenize request and cached in worker state under key
"{lang}:retok".
flowchart TD
input["CHAT input\n(per-character tokens)"]
retok{"--retokenize?"}
lazy{"Retok pipeline\nloaded?"}
load["load_stanza_retokenize_model()\n(_stanza_loading.py)"]
stanza_retok["Stanza zh tokenizer\n(pretokenized=False)"]
stanza_std["Stanza zh\n(pretokenized=True)"]
rust["Rust retokenize module\n(retokenize/mod.rs)"]
out_word["CHAT output\n(word-level tokens)"]
out_char["CHAT output\n(per-char tokens)"]
input --> retok
retok -->|yes| lazy
retok -->|no| stanza_std --> out_char
lazy -->|no| load --> stanza_retok
lazy -->|yes| stanza_retok
stanza_retok --> rust --> out_word
Segmentation Quality
Verified with real Stanza zh model (package gsdsimp):
| Input | Stanza Output | Correct? |
|---|---|---|
| 我去商店买东西 | 我 去 商店 买 东 西 | Mostly, groups 商店 but splits 东西 |
Stanza handles common compounds (商店 “store”) correctly but may split ambiguous compounds where individual characters have independent meanings (东西 “things” → 东 “east” + 西 “west”).
For word count and MLU analysis, this is substantially better than per-character tokenization but should not be treated as ground truth.
Utterance Segmentation
Both cmn and zho resolve to the same Mandarin utterance-segmentation model:
| Code | Model |
|---|---|
cmn | talkbank/CHATUtterance-zh_CN |
zho | talkbank/CHATUtterance-zh_CN |
This model is used in two places:
transcribepre-CHAT segmentation foreng/cmn/zho/yue- standalone
utsegwhen the utterance-model path is selected
This is separate from --retokenize, which is the morphotag word-segmentation
path for already-built CHAT text.
Number Expansion
Mandarin uses the Chinese number expansion system. Per
crates/batchalign-transform/src/asr_postprocess/num2text.rs:243-247,
both cmn and zho dispatch to ChineseScript::Simplified; only
yue and jpn use ChineseScript::Traditional:
| Code | Script | Example |
|---|---|---|
zho | Simplified | 5 → 五, 10000 → 一万 |
cmn | Simplified | 5 → 五, 10000 → 一万 |
Morphosyntax
Mandarin morphotag uses Stanza’s Chinese zh path. MWT is excluded, Chinese
has no contractions. In mixed-language files, @s:cmn, @s:zho, and bare
@s resolved to Mandarin all route through the same secondary-language L2
morphotag path rather than staying L2|xxx, unless the target is unresolved or
the user passes --no-l2-morphotag.
Default mode: tokenize_pretokenized=True (Stanza annotates existing word
boundaries without re-tokenizing).
Known Limitations
Stanza tokenizer is imperfect
The zh tokenizer is trained on the Chinese Treebank (Mandarin, formal text).
Performance may degrade on:
- Spoken/colloquial Mandarin
- Child speech
- Technical or domain-specific vocabulary
- Ambiguous compounds (东西, 大小, 多少)
No Cantonese normalization
Text normalization (simplified → traditional + domain replacements) only
runs for yue. Mandarin text passes through without character normalization.
Verified Behavior
| What | Test | Result |
|---|---|---|
Stanza zh tokenizer segments words | test_stanza_chinese_tokenizer_segments_multichar_words | Groups 商店 correctly |
pretokenized=True preserves chars | test_stanza_pretokenized_true_preserves_chars | 7 chars stay as 7 tokens |
| Language code mapping | test_language_code_mapping | cmn → zh, zho → zh |
Open Questions
- Paraformer output format: does Paraformer actually produce per-character tokens for Mandarin, or does it attempt some word segmentation? (Conflicting reports from users.)
- Child Mandarin speech: how does Stanza’s tokenizer perform on child language data?
- Would jieba be better than Stanza for Mandarin? jieba is a widely-used Chinese word segmenter; comparison with Stanza’s neural tokenizer would be informative.
Source Files
| File | Role |
|---|---|
batchalign/worker/_stanza_loading.py | load_stanza_retokenize_model() for lazy zh retok pipeline (:251) |
batchalign/inference/morphosyntax.py | Mandarin retokenize path in batch_infer_morphosyntax() |
crates/batchalign-transform/src/asr_postprocess/num2chinese.rs | Chinese number expansion |
crates/batchalign-transform/src/retokenize.rs + crates/batchalign-transform/src/retokenize/ | AST rewrite (language-agnostic) |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Japanese Language Support
Status: Current Last updated: 2026-05-20 20:17 EDT
Japanese (jpn) uses Stanza’s combined package and the retokenize system
for word boundary handling. This page covers the full picture: what works,
what doesn’t, and what’s planned.
Quick Reference
| Pipeline Stage | Japanese-Specific Behavior |
|---|---|
| ASR | Whisper (default), no Japanese-specific alternatives |
| Text normalization | None, Japanese characters passed through as-is |
| Number expansion | Chinese number system (num2chinese with simplified script) |
| Retokenize | Stanza combined package merges/splits CJK tokens |
| Morphosyntax | Stanza ja with forced combined package for all processors |
| MWT | Excluded, Japanese has no contractions |
| Forced alignment | Wave2Vec MMS (standard, no language-specific preprocessing) |
Stanza Configuration
Japanese uses two distinct Stanza configurations depending on --retokenize:
| Property | Keep-Tokens (default) | Retokenize |
|---|---|---|
| Stanza tokenizer | Bypassed (pretokenized=True) | Runs (no_ssplit=True) |
| Package | combined (all 4 processors) | combined (all 4 processors) |
| Word boundaries | Preserved from CHAT | Stanza may merge/split |
| MWT processor | Not loaded | Not loaded |
The combined package is forced for all four processors (tokenize, pos,
lemma, depparse) because it’s trained jointly. Using default for any
processor would load a mismatched model.
Why combined?
Standard Stanza packages train processors independently. Japanese combined
trains all four jointly, which is critical because Japanese tokenization,
POS tagging, and dependency parsing are interdependent (word boundaries affect
POS which affects dependencies).
Retokenize Behavior
With --retokenize, Stanza’s neural tokenizer may:
- Merge adjacent CHAT words into one token (e.g., ふ + す → ふす)
- Split one CHAT word into sub-tokens
The Rust retokenize module at
crates/batchalign-transform/src/retokenize.rs:195::retokenize_utterance
handles the AST rewrite using the same character-level span mapping
used for English contractions and CJK word segmentation. Sibling
helpers live under crates/batchalign-transform/src/retokenize/
(parse_helpers.rs, rebuild.rs).
Known Limitations
No Japanese-specific ASR engine
Japanese uses Whisper like most other languages. There are no Japanese-specific ASR alternatives (unlike Cantonese which has Tencent, Aliyun, and FunASR options).
Stanza combined package quality
The combined package is trained on the Japanese UD treebank, which is
based on formal written Japanese. Performance may differ on:
- Spoken/colloquial Japanese
- Child speech
- Code-mixed Japanese-English
Whitespace artifacts in Stanza output
Stanza sometimes produces whitespace artifacts in Japanese lemmas and POS tags. These require language-specific cleanup in the Rust POS mapping layer. See the Japanese Morphosyntax Pipeline for detailed whitespace handling documentation.
Verified Behavior
- Retokenize split-and-merge: covered by inline
#[cfg(test)]tests incrates/batchalign-transform/src/retokenize.rs(e.g.,deterministic_mapping_succeeds_for_split_and_mergeat:287). - Stanza
combinedpackage loads correctly forja(batchalign/worker/_stanza_loading.py:196-207). - MWT exclusion:
should_request_mwt()reportshas_mwt=Falseforja; the retokenize/utseg path uses a localmwt_exclude = {"zh", "ja", "ko", "th", "vi", "my"}set atbatchalign/worker/_stanza_loading.py:310. The global_MWT_EXCLUSIONwas retired alongsideMWT_LANGS(see Stanza Limitations Defect 5).
Open Questions
- Would a Japanese-specific ASR model improve accuracy? Whisper handles Japanese reasonably but a dedicated model might improve CER.
- How does retokenize affect timing? When words are merged/split, existing
%wortiming bullets become stale. Is this communicated clearly to users?
Detailed Reference
For the complete Stanza configuration details, POS mapping rules, verb form overrides, and whitespace artifact handling, see: Japanese Morphosyntax Pipeline
Source Files
| File | Role |
|---|---|
batchalign/worker/_stanza_loading.py | Japanese combined package selection (:196-207); MWT eligibility via should_request_mwt() |
batchalign/inference/morphosyntax.py | Stanza inference (shared with all languages) |
crates/batchalign-transform/src/retokenize.rs + crates/batchalign-transform/src/retokenize/{parse_helpers,rebuild}.rs | AST rewrite for merged/split tokens |
crates/batchalign-transform/src/morphosyntax/lang_ja.rs | POS mapping with Japanese-specific rules |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Hebrew Language Support
Status: Current Last updated: 2026-09-07 07:04 EDT
Hebrew (heb) ships with RTL punctuation handling, capability-driven
MWT for preposition+article contractions, and Hebrew-specific UD
features (HebBinyan, HebExistential).
Quick Reference
| Pipeline Stage | Hebrew-Specific Behavior |
|---|---|
| ASR | openai/whisper-large-v3 via --asr-engine whisper (no per-language fine-tune wired today) |
| Text normalization | RTL punctuation → ASCII (؟→?, ۔→., ،→,) |
| Number expansion | Not supported: digits pass through unexpanded |
| Morphosyntax | MWT enabled (preposition+article contractions), HebBinyan/HebExistential features |
| Forced alignment | Wave2Vec MMS (standard) |
ASR
| Engine | Model | Notes |
|---|---|---|
--asr-engine whisper | openai/whisper-large-v3 | Default; same model for every language |
--asr-engine whisper_hub | (empty entry today) | Opt-in HuggingFace fine-tune loader; requires explicit --engine-overrides model_id. No Hebrew fine-tune is seeded in batchalign/models/resolve.py::_RESOLVER["whisper_hub"] yet. |
| Rev.AI | Cloud API | Supports Hebrew |
If a Hebrew-specific fine-tune (such as the community
ivrit-ai/whisper-large-v3 checkpoint) is wanted, add it reactively to
_RESOLVER["whisper_hub"] with a dated provenance comment and an
empirical evaluation note. See
Whisper Hub ASR for the conventions.
RTL Punctuation
Hebrew text may contain Arabic-script punctuation. The ASR post-processing pipeline normalizes these to ASCII (runs for all languages, not just Hebrew):
| RTL | ASCII | Unicode |
|---|---|---|
| ؟ | ? | U+061F Arabic Question Mark |
| ۔ | . | U+06D4 Arabic Full Stop |
| ، | , | U+060C Arabic Comma |
| ؛ | ; | U+061B Arabic Semicolon |
Morphosyntax
MWT (Multi-Word Tokens)
Hebrew uses Stanza’s MWT processor for preposition+article contractions
(e.g., בַּ → ב + ה). MWT eligibility is capability-driven:
should_request_mwt(alpha2, get_cached_capability_table()) at
batchalign/worker/_stanza_loading.py:40 consults the cached Stanza
catalog and reports has_mwt=True for he. The earlier hardcoded
MWT_LANGS / _MWT_EXCLUSION sets were deleted,
Stanza Limitations Defect 5 has the
rewrite rationale.
HebBinyan (Verb Conjugation Pattern)
Hebrew verbs belong to one of seven binyanim: PAAL, NIFAL, PIEL, PUAL,
HIFIL, HUFAL, HITPAEL. Stanza outputs this as the HebBinyan UD feature.
batchalign3 converts to lowercase %mor suffix:
UD: HebBinyan=PAAL|Number=Sing|Person=3|Tense=Past|VerbForm=Fin
%mor: -paal&3S&PAST
HebExistential
The existential (יש/אין) gets a special HebExistential=True feature,
mapped to %mor suffix -true.
No Other Workarounds
Unlike English, French, Japanese, Italian, Portuguese, and Dutch, Hebrew has no Stanza workarounds: the HebBinyan/HebExistential mapping is standard UD feature processing.
Known Limitations
No Hebrew number expansion
Hebrew digits pass through unexpanded. CHAT output will show 5 instead of
חמש. This is a known gap, a Hebrew number table for num2lang.json has
not been created.
RTL text layout
The CHAT format is line-oriented and primarily designed for LTR text. Hebrew CHAT files work correctly for parsing and analysis, but display rendering in editors depends on the editor’s BiDi support.
Verified Behavior
| Test | What |
|---|---|
test_hebrew_verb_hebbinyan | HebBinyan=PAAL → lowercase suffix |
test_hebrew_verb_hebexistential | HebExistential=True → lowercase suffix |
test_hebrew_3letter_code_works | “heb” maps to Stanza “he” correctly |
Open Questions
- Hebrew number expansion: should we add a Hebrew number table? What are the conventions for Hebrew CHAT transcripts (digits vs word forms)?
- Hebrew-specific ASR errors: are there systematic Stanza POS/dep errors for Hebrew that need workaround rules (like English/French/Japanese)?
Source Files
| File | Role |
|---|---|
crates/batchalign-transform/src/morphosyntax/features.rs:43,46 | HebBinyan/HebExistential extraction |
crates/batchalign-transform/src/morphosyntax/types.rs:38 | heb → he Stanza alpha-2 mapping |
crates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rs | Hebrew integration tests (:15, :45, :268) |
batchalign/worker/_stanza_loading.py | Stanza pipeline config; MWT eligibility driven by should_request_mwt() |
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
French
Status: Current Last updated: 2026-05-19 14:18 EDT
Scope
French is a Romance language with productive MWT and clitic-elision phenomena that Stanza’s neural tokenizer and MWT processor handle natively:
- Preposition+article contractions:
au → à + le,aux → à + les,du → de + le,des → de + les - Clitic-article elisions:
l'ami → l' + ami,l'eau → l' + eau - Clitic-pronoun and complementizer elisions:
c'est,qu'il,d'un,n'avait,l'on,j'ai - Elision-prefix words (single CHAT token, apostrophe-internal):
jusqu'à,puisqu'il,quelqu'un,aujourd'hui - Multi-clitic stacks:
d'l'attraper,qu'l'on
French carries no per-language BA3 MWT-override rules. All earlier
overrides ported from BA2 (ud.py:662-695) were audited and removed
, see History.
What Stanza handles natively
Paired probes (free-tokenize vs our postprocessor) on 50+ French constructions with Stanza 1.11.1. All produce identical output on both paths and satisfy the morphotag 1-to-1 invariant:
| Pattern | Example | Stanza output |
|---|---|---|
| au / aux / du / des | va au cinéma | à + le + cinéma (MWT expansion, 1 CHAT word → 2 UD words, Range preserved) |
| c’est / n’est / qu’il | c'est vrai | MWT split to c' + est (Range preserved) |
| l’X (noun, vowel-initial) | l'ami, l'eau, l'Ecosse | MWT split l' + ami, 1 CHAT word → 2 UD words |
| Elision prefix | jusqu'à, puisqu'il, quelqu'un, aujourd'hui | 1 CHAT word → 2 UD words; the DP realigner in align_tokens merges any over-split back |
| Multi-clitic | d'l'attraper, qu'l'on | Stanza emits correct Range expansion; DP realigner handles any residual splits |
Probes in
batchalign/tests/investigations/_cases/french.py
(typed ProbeCase fixtures consumed by the matrix harness at
test_stanza_mwt_probe_matrix.py).
Known Stanza limitations
No French-specific Stanza defects are tracked. Earlier missing_mor
flags on the eng,fra pair were produced by the
FRENCH_ELISION_PREFIXES splitting hack (see
History); these are expected to go to zero once the
removal is verified end-to-end.
History
Rules that existed and were removed
| Rule | What it did | Audit finding |
|---|---|---|
Exact("au") → ForceMwt | Force MWT expansion on au | Redundant. Modern Stanza French expands au → à + le natively; the hint was dead weight. |
Exact("aujourd'hui") → PlainText("aujourd'hui") | Replace with plain text (no MWT hint) | Dormant. Stanza emits aujourd'hui as 1 token in all tested positions (standalone, sentence-initial, sentence-medial, sentence-final). The override never fired on a real input. |
FRENCH_ELISION_PREFIXES split | For tokens matching jusqu', puisqu', quelqu', aujourd', split into (prefix, False) + (suffix, False) with no MWT hint | Actively broke the 1-to-1 invariant. The split produced N Stanza-facing tokens for 1 CHAT word without MWT Range metadata, causing the morphotag 1-to-1 gate to reject the utterance. Root cause of eng,fra missing_mor residuals and Wave 4 PipelineAbsorbedFailure anomalies. |
is_french_multi_clitic split | Same split logic for multi-apostrophe clitic stacks like d'l'attraper | Same invariant break. Removed alongside FRENCH_ELISION_PREFIXES. |
Why the DP alignment is the load-bearing piece
The character-level Hirschberg realignment in align_tokens merges
Stanza’s native 2-token expansion of jusqu'à (which Stanza emits
as jusqu' + à) back to 1 Stanza-facing token per 1 CHAT word,
preserving the MWT Range metadata. That rescue is unconditional and
language-agnostic, which is why the BA2 split hack was unnecessary
to begin with.
Tests
- Probe matrix cases:
batchalign/tests/investigations/_cases/french.py: typedProbeCasefixtures (elision prefixes, clitic contractions, MWT natives,aujourd'hui, plus the seed 040802:1620 utterance). - Matrix harness:
batchalign/tests/investigations/test_stanza_mwt_probe_matrix.pyruns every case through paired (free-tokenize vs postprocessor) pipelines. Invoke withuv run pytest batchalign/tests/investigations/ -m golden.
References
- Morphotag Invariants
- Wave 4 eval harness:
batchalign3 eval l2-morphotag
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Italian
Status: Current Last updated: 2026-07-28 13:27 EDT
Scope
Italian is a Romance language with productive MWT (multi-word token) phenomena that Stanza’s neural tokenizer and MWT processor expand natively:
- Preposition+article contractions:
al → a + il,del → di + il,nel → in + il,sul → su + il,della → di + la - Clitic-article elisions:
l'amico → l' + amico,dell'opera → di + l'opera,all'amore → a + l'amore
All BA2-inherited per-language MWT-override rules were audited and removed in 2026-04 (see History). Since 2026-07-28 Italian has a principled tokenize-stage MWT POLICY (not an override table): every split Stanza proposes is validated against the four multi-word patterns Italian actually has, and splits it wrongly withholds are forced. Design and evidence: Stanza Limitations.
Intervention status: the complete arc (as of 2026-07-28)
Italian has had three generations of intervention, each subsuming part of its predecessor. All three are documented on this page; this section is the authoritative summary of what is CURRENT.
Generation 1, BA2-inherited tokenize overrides: REMOVED (2026-04-21). The per-language string-manipulation rules ported from BA2 were audited with paired probes and deleted (dormant, redundant, or harmful on modern Stanza). See History.
Generation 2, the downstream %mor reconciler (2026-04/05):
per-surface allowlists. Hand-curated tables in
crates/batchalign-transform/src/morphosyntax/lang_it.rs repairing
specific observed damage after Stanza produced it: IT_MIS_SPLIT_OVERRIDES
(Defects 6/7, 23 entries), IT_COMPOUND_IMPERATIVES (Defect 8, 11
entries), IT_COMPONENT_REWRITES (Defects 9/10, 3 entries). Grown one
production incident at a time; each entry documented below.
Generation 3, the tokenize-stage MWT policy (2026-07-28): validate
every split, both directions. Implemented in
batchalign/inference/_italian_mwt.py and applied in the tokenizer
postprocessor, BEFORE Stanza’s MWT processor runs. Italian has exactly
four legitimate multi-word patterns (preposition+article,
ecco+enclitic, clitic cluster, verb+enclitic); a forced probe
previews Stanza’s split for every candidate, the four patterns judge
it, and the pipeline is told to suppress an illegitimate split or force
a withheld genuine one. Full design, structural guards, and the
measured evidence:
Stanza Limitations.
Per-defect status
| Defect | Shape | Handled now by | Notes |
|---|---|---|---|
6 (parla/arancione -> fake verb+clitic) | spurious split | policy: suppressed at source | reconciler table retained as dormant backstop; no Range reaches it |
7 (la -> il+i) | spurious split | policy: suppressed at source | same |
8 (dammela mid-sentence, no expansion) | withheld split | policy: forced at source | arrives as a Range and gets full decomposition natively; allowlist dormant backstop |
9 (dagliela head ADP/da) | wrong head POS on a real Range | reconciler rewrite, STILL ACTIVE | the policy validates split SHAPE, not component analyses |
10 (posala head lemma posa) | wrong head lemma on a real Range | reconciler rewrite, STILL ACTIVE | posare-specific |
12/13 (aprilo -> `verb | aprilare`) | withheld split, fabricated lemma | policy: forced at source |
Singleton skips (soffioni, pettole, babbolo) | spurious split, unlisted | policy: suppressed at source | correct without listing; verified whole with real lemmas |
non-UD iob relation | %gra content | repaired on the production path (2026-07-28), and since reported as a typed relation_alias repair that the file’s ud_repairs= counts | was unit-tested but uncalled for months |
Corpus repair status
Measured 2026-07-28 with a language-resolving Rust audit over the typed
CHAT AST (all 106,158 corpus files): 657 Italian-primary files; 1,608
Italian-resolved single-word utterances across 338 files carry
verb+enclitic %mor (committed damage plus genuine imperatives).
Regeneration of all Italian-primary files with the fixed pipeline is
the repair step; outputs are diffed before entering the data repos.
What Stanza handles natively
Paired probes (free-tokenize vs our postprocessor) were run on 60+ Italian constructions on Stanza 1.11.1. All of the following produce identical output on both paths and satisfy the morphotag 1-to-1 invariant:
| Pattern | Example | Stanza output |
|---|---|---|
| al / del / nel / sul / dal | al cinema | a + il + cinema (MWT expansion, 1 CHAT word → 2 UD words, Range preserved) |
| della / dello / degli | parla della casa | di + la + casa (MWT) |
| l’X (common nouns) | l'amico, l'opera, l'uomo, l'anno, l'oggetto | 1 UD word with accented apostrophe preserved; character-DP in align_tokens merges any Stanza over-split back to 1 |
| preposition+clitic | all'amore, nell'anno, sull'ora | 1 MWT expansion; 1 CHAT word stays 1 |
lei (3sg.f pronoun) | dice lei, lei mangia | 1 UD word, no spurious split into le + i |
Probes in
batchalign/tests/investigations/_cases/italian.py (typed
ProbeCase fixtures consumed by the matrix harness at
test_stanza_mwt_probe_matrix.py).
Known Stanza limitations
All three issues below are content-quality defects. The %mor
count invariant holds, Stage 3’s assemble_mors in
crates/batchalign-transform/src/morphosyntax/mapping_helpers.rs collapses
Stanza’s MWT Range tokens into a single compound %mor entry per
CHAT word, but the emitted entry carries linguistically wrong
content: fake lemmas, spurious features, or the wrong POS.
Each row below shows the %mor that actually ships downstream from
a minimal ita probe CHAT run through batchalign3 morphotag.
Defect 6: words with clitic-shaped endings split into fake verb+clitic compounds
Italian words whose last one-to-two characters look like a clitic
(-la, -lo, -le, -li, -ne, -no, -ni, -mi, -ti,
-ci, -vi, -si) get wrapped by Stanza in an MWT Token and
analyzed as verb stem + enclitic pronoun with a bogus stem lemma
, regardless of actual part of speech. The defect fires on:
- Verbs in imperative position:
parla forte→verb|par~pron|la(should beverb|parlare-Imp-S2). - Common nouns:
arancione(orange) →verb|arancio~pron|newithPart Past;seggiola(chair) →verb|seggio~pron|la;gomitolo(ball of yarn) →verb|gomito~pron|lo;divano(sofa) →verb|diva~pron|no;bottone(button) →verb|botto~pron|ne;cavallone(big horse) →verb|cavallo~pron|ne;cielo(sky) →verb|cie~pron|lo(cieis not a word). - Adjectives:
piccolo/piccola(small, m/f) →verb|picco~pron|lo/verb|picco~pron|lawithPart Past. - Baby-talk diminutives:
coccole,babbolo,pettole.
Most non-verb hits carry Part Past features, Stanza confidently
treats the whole surface as a past participle plus clitic.
Every row ships one %mor item per CHAT word (Stage 3’s
assemble_mors collapses the MWT Range correctly), so the count
invariant holds. Every row’s linguistic content is wrong.
A corpus-wide audit of committed %mor content (pre-parsed JSON
snapshot of the TalkBank CHAT corpora) found 65 Defect-6 hits
across 417 Italian files and 15 distinct surface forms. Mid-sentence
position does protect verbs in context, la storia parla di ...
gets correct verb|parlare-Fin-Ind-Pres-S3: but the noun/adjective
pseudo-analyses fire independent of position.
Pinned as stanza-it-verb-clitic-pos-split in
Stanza Limitations, Defect 6.
Prevented at source since 2026-07-28 by the tokenize-stage MWT
policy (the spurious split is suppressed, so Stanza analyzes the whole
word); the IT_MIS_SPLIT_OVERRIDES reconciler entries remain as a
dormant backstop.
Defect 7: sentence-initial article la gets junk il + i MWT expansion
Input:
*CHI: la storia parla di un bambino .
Current %mor:
%mor: det|il-Masc-Def-Art-Sing~det|il-Masc-Def-Art-Plur noun|storia-Fem
verb|parlare-Fin-Ind-Pres-S3 adp|di det|uno-Masc-Ind-Art-Sing
noun|bambino-Masc .
One %mor item per CHAT word, count is right. But the first item
has lemma=il with masc-singular + masc-plural compound features,
for a feminine-singular article la. Correct would be
det|la-Fem-Def-Art-Sing.
parla mid-sentence gets its proper analysis
(verb|parlare-Fin-Ind-Pres-S3), confirms Defect 6 is
position-sensitive and unrelated to Defect 7. Position sensitivity
of Defect 7 itself (whether mid-sentence la also gets the junk
expansion) has not yet been characterized.
Pinned as stanza-it-la-sentence-initial-split in
Stanza Limitations, Defect 7.
Prevented at source since 2026-07-28: the policy rejects il + i
because it is not a genuine preposition+article fusion (no preposition
in the base and la is not in the contracted paradigm), so the split
never happens.
Defect 8 (candidate): mid-sentence dammela tagged as ADJ, lemma normalized to dammelo
Input:
*CHI: per favore dammela .
Current %mor:
%mor: adp|per noun|favore-Masc adj|dammelo-S1 .
One %mor item per CHAT word, correct count. But dammela in
mid-sentence position gets tagged ADJ with lemma dammelo (wrong
gender) and no clitic decomposition at all. The bare-compound case
(dammela alone as a single utterance) is handled correctly,
verb|dare-Inf-Ind-Imp-S2~pron|me-Prs-S1~pron|la-Prs-S3: so this is
a context-dependent Stanza misclassification, distinct from Defect 6.
Not yet pinned as a named Defect in the registry (proposed slug
stanza-it-dammela-mid-sentence-adj); a corpus scan for
-(la|lo|le|li|mi|ti|ci|vi|si|ne)$ verb+clitic compounds in
mid-sentence position would quantify prevalence and inform whether
it warrants its own entry.
Defect 9: Range-expansion with wrong head POS (dative -glie- stack)
Input:
*CHI: per favore dagliela .
Before Defect 9 reconciler:
%mor: adp|per noun|favore-Masc adp|da~pron|gli-Prs-S3~pron|la-Prs-S3 .
Stanza expands dagliela (2sg imperative of dare + 3sg.dat +
3sg.f.acc) as a structurally-correct 3-piece MWT, but tags the head
component da with ADP/da instead of VERB/dare. The preposition
da (“from, by”) is homographic with the imperative verb form, and
Stanza’s POS layer prefers the preposition reading even though the
clitic stack only makes sense under the verb reading.
This is distinct from Defect 6 (where Stanza spuriously creates an MWT split for a non-compound word) and Defect 8 (where Stanza omits MWT expansion entirely mid-sentence). The expansion shape is right; only component 0’s POS/lemma/feats are wrong.
Sibling forms in the same dative stack (digliela →
di/VERB/dire, portagliela → porta/VERB/portare,
prendigliela → prendi/VERB/prendere) are Stanza-correct,
verified by direct probe. The defect is specific to
surfaces where the head clitic-stripped form is homographic with a
non-verb word.
Pinned observation-only case in the probe matrix:
dagliela_mid_sentence in _cases/italian.py.
Defect 10: head-lemma-only rewrite for genuine imperative+clitic MWTs
Input:
*CHI: posala .
Before Defect 10 reconciler:
%mor: verb|posa~pron|la-Prs-S3 .
Stanza expands posala as a 2-piece MWT (posa/VERB + la/PRON)
, structurally correct: this IS a genuine imperative (2sg of
posare, “put down”) + accusative clitic. However the head
component’s lemma is posa (surface-echo) rather than the
canonical infinitive posare.
Unlike Defect 9 (dagliela), the head POS is correct
(VERB); only the lemma is wrong. The component-rewrite
mechanism in IT_COMPONENT_REWRITES handles both shapes
because rewriting a field that Stanza already got right is
idempotent, no new allowlist or new reconciler path was
needed.
Defect 10 is verb-specific to posare: the
cross-verb probe confirmed guardare, toccare, aspettare,
mangiare, chiamare, lasciare, cambiare, provare,
giocare, portare, suonare, chiudere all lemmatize
correctly in this position. Stanza’s Italian model has a
specific weakness on the posare paradigm. Allowlist entries:
posala, posalo (IT_COMPONENT_REWRITES).
Singleton audit hits deliberately NOT added to the allowlist
A fleet JSON audit surfaced 5 singleton Defect 6 surfaces that were deliberately skipped despite confirming as mis-splits under current Stanza:
soffioni(plural ofsoffione, dandelion), obscurecoccolo: dialectal / obscurepettole: dialectal / obscurebabbolo: likely a typo or child-speech approximationtecala: non-standard; likely corrupted
If any of these recur in new corpus processing, promote to the
allowlist at that point. The probe matrix records each as
observation-only (<surface>_alone in _cases/italian.py), so
a Stanza upgrade that fixes them would flip those probes from
silent-pass-with-stanza_words=2 to stanza_words=1.
Correctly-handled constructions (preserve, do not rewrite)
Bare single-utterance imperative+clitic compounds are produced correctly and must not be touched by any future content-quality rule. Pinned as counterexamples in the probe matrix:
| Input | %mor | Correct? |
|---|---|---|
dammela | `verb | dare-Inf-Ind-Imp-S2~pron |
dammelo | (same shape with la → lo) | Yes |
portalo | `verb | portare-Inf-Ind-Imp-S2~pron |
Any future content-quality rule for Italian must leave these analyses untouched, they’re the correctness control group.
Reconciler architecture
This section is the overview. The per-defect subsections below are the detail. A successor who has never seen this subsystem should be able to read this section alone and understand what the Italian reconciler does and where the code lives.
Why the reconciler exists
Stanza 1.11.1’s Italian model has several distinct defect
shapes in its output for imperative+clitic compounds and
clitic-shaped-ending nouns. Each defect type produces malformed
%mor content even when the structural (1-to-1) invariant
between CHAT words and %mor items holds. The reconciler is a
closed curated set of per-surface overrides that post-processes
Stanza’s output to produce correct %mor content without
retraining Stanza or writing a full Italian morphological
analyzer.
It is explicitly a hack layer: every entry is expected to
be retired eventually as Stanza improves upstream. The retirement
workflow is to empty the allowlists in lang_it.rs, rerun the
Italian integration tests
(crates/batchalign/src/chat_ops/nlp/mapping/tests/italian_defects.rs),
and remove any entry whose dependent test now passes without it.
Defect taxonomy
Eight numbered defect shapes have been observed in Stanza’s Italian output. Five are actively reconciled; three are deliberately not (documented below).
flowchart TD
Start["Italian input word from Stanza"]
MWT{"Stanza emits MWT Range?"}
Range["UdId::Range(start, end)<br/>+ N component UdWords"]
Single["UdId::Single(id)<br/>(one UD word)"]
SpuriousSplit{"Is the Range a<br/>spurious split of<br/>a non-compound?"}
Def6["Defect 6<br/>Range collapse to 1 Mor<br/>(parla, arancione, piccolo, …)"]
Def7["Defect 7<br/>Sentence-initial la → il+i<br/>Range collapse"]
HeadMisPOS{"Head component<br/>POS wrong?"}
Def9["Defect 9<br/>Component head rewrite<br/>POS+lemma (dagliela)"]
HeadLemmaOnly{"Head lemma<br/>surface-echo?"}
Def10["Defect 10<br/>Component head rewrite<br/>lemma only (posala)"]
NormalRange["Normal assemble_mors<br/>(correct MWT)"]
SingleMisPOS{"Single mis-tagged<br/>ADJ/NOUN/VERB<br/>as compound?"}
Def8["Defect 8<br/>Multi-chunk emit<br/>(dammela, aprila, finila)"]
Def12["Defect 12<br/>VERB correct lemma<br/>missing MWT<br/>(aprilo)"]
Def13["Defect 13<br/>VERB fabricated lemma<br/>(leggila)"]
NormalSingle["Normal map_ud_word_to_mor"]
Start --> MWT
MWT -->|"yes"| Range
MWT -->|"no"| Single
Range --> SpuriousSplit
SpuriousSplit -->|"yes: parla/arancione/…"| Def6
SpuriousSplit -->|"yes: sentence-initial la"| Def7
SpuriousSplit -->|"no"| HeadMisPOS
HeadMisPOS -->|"yes: dagliela → da/ADP"| Def9
HeadMisPOS -->|"no"| HeadLemmaOnly
HeadLemmaOnly -->|"yes: posala → posa lemma"| Def10
HeadLemmaOnly -->|"no"| NormalRange
Single --> SingleMisPOS
SingleMisPOS -->|"ADJ: dammela, finila"| Def8
SingleMisPOS -->|"NOUN: aprila, aprili"| Def8
SingleMisPOS -->|"VERB missing MWT: aprilo"| Def12
SingleMisPOS -->|"VERB fabricated lemma: leggila"| Def13
SingleMisPOS -->|"no defect"| NormalSingle
Verified against: crates/batchalign-transform/src/morphosyntax/lang_it.rs
(allowlist definitions), crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs::map_ud_sentence
(dispatch), _cases/italian.py probe observations (defect
signatures).
Defects 11 (unused number in current taxonomy), and other per-verb anomalies surfaced during probing but deemed too rare to reconcile individually, live in the “Singleton audit hits” and “Open work” sections below.
Levels of processing
The reconciler operates in two logical layers over Stanza’s
output. The first layer rewrites UD content (mutating
UdWord-equivalent state before %mor synthesis); the second
layer fixes up the chunk-index and GRA accounting so the
reconciled output aligns with CHAT’s per-chunk %gra convention.
flowchart LR
subgraph Input
direction TB
Stanza["Stanza UD sentence<br/>(UdWord[], UdId::Range|Single)"]
end
subgraph "Layer 1: UD rewrite (lang_it.rs)"
direction TB
MisSplit["IT_MIS_SPLIT_OVERRIDES<br/>(Defect 6 + 7)<br/>Range → synthetic single UdWord"]
Compound["IT_COMPOUND_IMPERATIVES<br/>(Defect 8 + 12 + 13)<br/>Single → verb UdWord + N clitic UdWords"]
Component["IT_COMPONENT_REWRITES<br/>(Defect 9 + 10)<br/>Range components mutated in place"]
end
subgraph "Layer 2: Mor synthesis + GRA accounting (morphosyntax)"
direction TB
MapWord["map_ud_word_to_mor<br/>per UdWord"]
Assemble["assemble_mors<br/>(Range → multi-chunk Mor)"]
WithClitic["Mor::with_post_clitic<br/>(Single → multi-chunk Mor)"]
BuildGra["build_gra_and_validate<br/>, chunk index<br/>, GRA relations<br/>, count invariant"]
end
subgraph Output
direction TB
Mors["Vec<Mor><br/>(each chunk countable)"]
Gras["Vec<GrammaticalRelation><br/>(1 per chunk + terminator PUNCT)"]
end
Stanza --> MisSplit
Stanza --> Compound
Stanza --> Component
MisSplit --> MapWord
Compound --> MapWord
Compound --> WithClitic
Component --> Assemble
MapWord --> Mors
Assemble --> Mors
WithClitic --> Mors
Mors --> BuildGra
BuildGra --> Gras
Verified against: crates/batchalign-transform/src/morphosyntax/lang_it.rs
(three allowlists + helpers), crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs::map_ud_sentence
(orchestration), crates/batchalign-transform/src/morphosyntax/mapping_helpers.rs::assemble_mors
(Range reassembly), talkbank-model::model::dependent_tier::mor::Mor::with_post_clitic
(clitic stacking).
Chunk accounting via provenance (a later refactor)
The most subtle part of the reconciler is how a single
UdId::Single can produce multiple %mor chunks (e.g.
dammela → verb|dare~pron|me~pron|la is 3 chunks from 1 UD
word). The original approach used two language-
specific side-tables (reconciled_ranges, reconciled_singles)
threaded through build_gra_and_validate. That coupling leaked
per-language reconciliation detail into a language-neutral
helper.
The current design uses a ChunkProvenance data structure
produced by every Mor synthesis site. map_ud_sentence now
returns two parallel vectors internally: Vec<Mor> and
Vec<MorProvenance>. Each MorProvenance carries one
ChunkProvenance per chunk of its Mor (main first, post-clitics
after). Each ChunkProvenance records:
source_ud_ids: which UD word ids map to this chunk (one for a normal Single, N for a collapsed Range, zero for a synthesized post-clitic).head: how to resolve the GRA relation’s head index (Root,FromUd(ud_id), orOwningMorMain).deprel: pre-normalized relation string.
build_gra_and_validate is now language-neutral: it takes
(mors, provenance) plus a TerminatorPolicy enum and emits
GRA relations by walking provenance. No side-tables, no
language awareness.
sequenceDiagram
participant MUS as map_ud_sentence
participant CI as check_italian_compound_imperative
participant ACIO as apply_compound_imperative_override
participant Mor as Mor::with_post_clitic
participant Prov as ChunkProvenance
participant BGV as build_gra_and_validate (language-neutral)
participant P1 as Pass 1, build ud_to_chunk_idx
participant P2 as Pass 2, emit GRA relations
MUS->>CI: ud.text + ud.upos (for dammela)
CI-->>MUS: Some(&override) with 2 clitics
MUS->>ACIO: override + ud.head + ud.deprel
ACIO->>Mor: main verb Mor
ACIO->>Mor: with_post_clitic(me)
ACIO->>Mor: with_post_clitic(la)
Mor-->>ACIO: Mor{main, post_clitics: [me, la]}
ACIO-->>MUS: multi-chunk Mor
MUS->>Prov: 1 main chunk (source_ud_ids=[ud.id], head=FromUd, deprel=ud.deprel)
MUS->>Prov: 1 clitic (source_ud_ids=[], head=OwningMorMain, deprel=me.deprel)
MUS->>Prov: 1 clitic (source_ud_ids=[], head=OwningMorMain, deprel=la.deprel)
MUS->>BGV: mors, provenance, TerminatorPolicy
BGV->>P1: walk provenance; ci += chunks per Mor; map source_ud_ids → ci
BGV->>P2: walk provenance; emit one relation per chunk; resolve head by ChunkHead variant
P2-->>BGV: Vec<GrammaticalRelation>
BGV->>BGV: validate: gras.len() == sum(count_chunks) + terminator offset
Verified against: map_ud_sentence (synthesis sites at each
reconciler branch), map_ud_sentence_expanded (uniform
push_ud helper), build_gra_and_validate
(language-neutral in
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs,
re-exported through crates/batchalign/src/chat_ops/nlp/mapping/mod.rs),
provenance.rs (data types), helpers.rs (normalize_deprel +
assemble_mors + provenance_for_ud_word), apply_compound_imperative_override
in lang_it.rs, and the test test_italian_defect8_dammela_emits_multi_chunk_mor.
Invariants checked at the end of build_gra_and_validate:
mors.len() == provenance.len()- For every
i,mors[i].count_chunks() == provenance[i].len() - A chunk with
ChunkHead::Rootwas encountered gras.len() == sum(mor.count_chunks()) + terminator_offset
Violations surface as MappingError::ChunkCountMismatch /
InvalidRoot / InvalidHeadReference. Because provenance is
produced alongside the Mor at a single site, the two can’t drift
, the chunk count check is a tripwire if a new synthesis site
ever produces mismatched counts.
Allowlist design invariants
Three allowlists, each corresponding to a distinct reconciler mechanism:
| Allowlist | Hook point | Action | Backing type |
|---|---|---|---|
IT_MIS_SPLIT_OVERRIDES | Range branch, before assemble_mors | Collapse Range to 1 Mor | MisSplitOverride |
IT_COMPONENT_REWRITES | Range branch, before assemble_mors (after mis-split check) | Mutate component 0 in place, fall through to assemble_mors | ComponentRewriteOverride |
IT_COMPOUND_IMPERATIVES | Single branch | Synthesize main verb + post-clitic Mors | CompoundImperativeOverride + CliticSpec[] |
Shared invariants across all three:
- Closed set. Each allowlist is a hand-curated
&'static []. No runtime extension, no auto-detection. Every entry corresponds to a specific Stanza output shape observed via direct probe (_cases/italian.py). - Gate + lookup. Each reconciler has both a POS/context gate AND a surface-text lookup. Both must match for the override to fire. Controls tests pin that legitimate (non-mis-classified) surfaces pass through unchanged.
- Idempotent rewrite. If Stanza already got a field right
(e.g., POS=VERB for Defect 10), the allowlist’s override
still specifies the “correct” value, the rewrite is
harmless. This lets shape-9 and shape-10 entries share
IT_COMPONENT_REWRITESeven though their Stanza-error signatures differ. - Retirement workflow. Empty all three allowlists in
lang_it.rsand rerun the reconciler-dependent integration tests. Entries whose tests still fail are load-bearing; entries whose tests now pass without the reconciler are retirement candidates (Stanza fixed the defect upstream).
Reconciler for Defect 6 / 7 / 8 / 9 / 10 / 12 / 13
Implementation at
crates/batchalign-transform/src/morphosyntax/lang_it.rs; plumbed into
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs::map_ud_sentence.
Integration inside map_ud_sentence
The reconciler is not a standalone pass, it is two targeted branches inside the UD → CHAT mapping function, each guarded by an allowlist lookup. The diagram below shows the two hook points relative to the normal Range / Single handling:
flowchart TD
Start["map_ud_sentence(ud_sentence, lang)"]
PerTok{"per UD token id"}
Range["UdId::Range(start, end)\n(Stanza emits MWT components)"]
Single["UdId::Single(idx)"]
ChkMis{"check_italian_mis_split?\n(IT_MIS_SPLIT_OVERRIDES)"}
ApplyMis["apply_mis_split_override\n→ 1 MOR with corrected POS/lemma\n+ record reconciled range"]
AssMor["assemble_mors()\n→ per-component MOR (default)"]
ChkCmpd{"check_italian_compound_imperative?\n(IT_COMPOUND_IMPERATIVES)"}
ApplyCmpd["apply_compound_imperative_override\n→ 1 MOR with verb POS + compound lemma\n+ record reconciled index"]
Pass["normal Single handling"]
Gra["build_gra_and_validate(reconciled_ranges)\n→ single %gra relation per collapsed word"]
Start --> PerTok
PerTok --> Range
PerTok --> Single
Range --> ChkMis
ChkMis -->|"match"| ApplyMis
ChkMis -->|"no match"| AssMor
Single --> ChkCmpd
ChkCmpd -->|"match + ADJ gate"| ApplyCmpd
ChkCmpd -->|"no match"| Pass
ApplyMis --> Gra
AssMor --> Gra
ApplyCmpd --> Gra
Pass --> Gra
reconciled_ranges: Option<HashSet<(usize, usize)>> is allocated
lazily, it stays None for utterances where neither allowlist
fires, so the common path pays no allocation cost. When set, it
is threaded into build_gra_and_validate so the GRA builder
collapses per-component relations into a single relation on the
rebuilt parent word.
BA3 carries a per-language reconciler hack that collapses
Stanza’s known-bad Italian MWT mis-splits back to a single %mor
entry with corrected POS / lemma / features. This is explicitly a
hack, an allowlist of specific Stanza mis-splits we know about,
not a principled morphological analyzer.
Where the hack lives. crates/batchalign-transform/src/morphosyntax/lang_it.rs
(mirrors the pattern of lang_en.rs / lang_fr.rs /
lang_ja.rs). The reconciler is called from
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs inside
map_ud_sentence’s UdId::Range branch, before the normal
assemble_mors join. When it fires, the affected Range is also
recorded so build_gra_and_validate emits a single %gra
relation for the collapsed word rather than one per component.
The allowlist. Maintained in
IT_MIS_SPLIT_OVERRIDES in lang_it.rs. Each entry:
| Mis-split (Stanza emits) | Reassembled text | Override POS | Override lemma | Source |
|---|---|---|---|---|
par + la | parla | VERB | parlare | sentence-initial 2sg/3sg indicative |
arancio + ne | arancione | NOUN | arancione | Burgato/23 |
picco + lo | piccolo | ADJ | piccolo | Calambrone/Martina/020322 |
gomito + lo | gomitolo | NOUN | gomitolo | Tonelli/Marco/011026 |
diva + no | divano | NOUN | divano | Tonelli/Marco/010803 |
pallo + ne | pallone | NOUN | pallone | corpus scan (94× in CHILDES-ita) |
basto + ne | bastone | NOUN | bastone | corpus scan (48×) |
cappe + lo | cappello | NOUN | cappello | corpus scan (56×) |
diffici + le | difficile | ADJ | difficile | corpus scan (46×) |
seggio + la | seggiola | NOUN | seggiola | audit (4× in committed JSON) |
picco + la | piccola | ADJ | piccolo | audit (4×); fem of already-handled piccolo |
trotto + la | trottola | NOUN | trottola | audit (3×) |
botto + ne | bottone | NOUN | bottone | audit (2×) |
cie + lo | cielo | NOUN | cielo | audit singleton (common word) |
norma + le | normale | ADJ | normale | audit singleton (common word) |
cavallo + ne | cavallone | NOUN | cavallone | audit singleton (augmentative of cavallo) |
cocco + le | coccole | NOUN | coccole | audit singleton (child-speech) |
il + i (as expansion of la sentence-initial) | la | DET | il | Defect 7 |
How to extend. When a new Italian Defect 6 case surfaces in
corpus data, add one row to IT_MIS_SPLIT_OVERRIDES plus a
regression test in morphosyntax/tests.rs. The reconciler will
fire on the new allowlist entry without further plumbing.
Defect 8 allowlist (separate hook)
Defect 8, mid-sentence compound imperatives mis-classified
without MWT expansion, fires on UdId::Single, not UdId::Range,
so it uses a separate allowlist IT_COMPOUND_IMPERATIVES:
| Surface (as Stanza sees it) | Stanza POS | Verb lemma override | Source |
|---|---|---|---|
dammela | ADJ | dare | direct probe |
dammelo | ADJ | dare | direct probe |
prendilo | ADJ | prendere | corpus scan (52× in CHILDES-ita) |
prendila | ADJ | prendere | family (sibling of prendilo) |
prendili | ADJ | prendere | family |
prendile | ADJ | prendere | family |
aprila | NOUN | aprire | direct probe (-ire family) |
aprili | NOUN (homograph aprile) | aprire | direct probe |
finila | ADJ | finire | direct probe |
All entries carry Mood=Imp|Number=Sing|Person=2|VerbForm=Fin.
Gate accepts both ADJ and NOUN : the
original Defect 8 signature was ADJ-only, but -ire family
probes surfaced NOUN mis-classifications (aprila tagged
aprila/NOUN/aprila; aprili tagged aprili/NOUN/aprile
, the latter is Stanza homographing the form onto the month
name April). The allowlist is still a closed curated set;
legitimate nouns pass through unchanged, pinned by
test_italian_defect8_genuine_noun_stays_noun.
The prendere family was surfaced by a one-off corpus scan for
CHAT main-tier words with verb+enclitic shapes; the same scan
identified diglielo (already correctly handled by Stanza) and
mettilo/mettila/mettili/mettiti (tagged VERB but not
decomposed, a lemma-quality issue rather than a pure Defect 8
signature; deferred to a future investigation).
Scope : multi-chunk output is now emitted.
Each allowlist entry specifies the post-clitic stack via
CliticSpec entries, so dammela emits the full
verb|dare-Imp-S2~pron|me-Prs-S1~pron|la-Prs-S3: matching
Stanza’s own analysis for the bare-compound case. Previously
(earlier) the reconciler emitted only the single-chunk
main verb; that scope limit came from map_ud_sentence’s
UdId::Single branch assuming 1 chunk per UD word. The
a later refactor added a reconciled_singles side-table
threaded through build_gra_and_validate so multi-chunk
emission and its corresponding GRA relations stay consistent
with the chunk-count invariant. See “Reconciler architecture”
above for the full story.
How to extend Defect 8: same pattern, add a row to
IT_COMPOUND_IMPERATIVES plus a regression test. The ADJ-POS
gate limits false positives to words Stanza actively
mis-classifies.
Defect 9 allowlist (Range component rewrite)
Defect 9, Range-expansion with wrong head POS, fires on
UdId::Range, like Defect 6, but takes a different action: instead
of collapsing the Range into a single Mor, it rewrites
component 0’s POS/lemma/feats in-place and falls through to the
normal assemble_mors path. The 3-chunk ~-joined Mor shape is
preserved; only the head’s lexical analysis changes. Hook point
lives right after the Defect 6 check in
map_ud_sentence’s UdId::Range branch. Because the Range still
produces multiple chunks, the entry is NOT recorded in
reconciled_ranges: GRA reindexing proceeds as for a normal
multi-chunk MWT.
Separate allowlist IT_COMPONENT_REWRITES:
| Range parent | Defect | Stanza head (POS/lemma) | Rewritten head | Source |
|---|---|---|---|---|
dagliela | 9 | ADP / da | VERB / dare | direct probe |
posala | 10 | VERB / posa | VERB / posare | audit (1×) |
posalo | 10 | VERB / posa | VERB / posare | family (sibling of posala) |
All entries carry Mood=Imp|Number=Sing|Person=2|VerbForm=Fin as
head feats. The rewrite is idempotent, if Stanza already had a
field right (e.g. the POS for Defect-10 entries), re-setting it
is harmless.
Scope. The allowlist is minimal and closed. Forms Stanza analyses correctly must stay off it, and control tests pin those:
digliela,portagliela,prendigliela(Defect 9 controls) , Stanza analyses correctly;test_italian_digliela_stays_correctly_mergedguards against regression.guardala,toccala,aspettala,mangiala,chiamala,lasciala,cambiala,provala,giocala,portala,suonala,chiudila(Defect 10 controls), all probed Stanza-correct. Only theposareparadigm mis-lemmatizes, hence the narrow allowlist.
How to extend Defect 9: add a row to IT_COMPONENT_REWRITES
plus a regression test. A sibling control test should pin at
least one neighboring form that Stanza handles correctly, to
catch overzealous entries.
What the reconciler does NOT do.
- It does NOT touch genuine verb+clitic compounds like
dammela,dammelo,portalowhen they arrive via Stanza’s MWT Range (i.e., standalone, where Stanza gets them right). Those are correctly merged by Stanza; the allowlists are closed sets gated on Stanza’s mis-classification signatures. - It does NOT change raw Stanza output, the xfail probes in
_cases/italian.pycontinue to document what Stanza emits directly. - It does NOT auto-detect new Defect 6 cases. Each must be observed in corpus data and explicitly added.
- It does NOT fix Italian’s Stanza model upstream, a future Stanza release that repairs the defect will render the corresponding allowlist entries redundant. Periodic re-audits (e.g., once per Stanza major version) should retire entries whose Stanza-raw output no longer mis-splits.
Constraints validated by tests at three layers:
- Unit tests in
crates/batchalign-transform/src/morphosyntax/lang_it.rs, lookup semantics (case-insensitivity, positive and negative matches, exclusion of genuine compounds likedammela). - Synthetic UD integration tests in
crates/batchalign/src/chat_ops/nlp/mapping/tests/italian_defects.rs(search fortest_italian_defect6_), confirm the reconciler collapses known mis-splitUdSentenceshapes into the correct singleMor. - End-to-end golden in
batchalign/tests/pipelines/morphosyntax/test_italian_defect6_end_to_end.py, runsbatchalign3 morphotagon a CHAT fixture whose@Languages:header declaresita; the fixture contains all six allowlist words in context. Asserts no junkverb|STEM~pron|CLITICpattern appears in the output%mortier. (Morphotag has no--langflag; language is per-file.) This closes the loop: real Stanza output flowing through the full production pipeline.
The specific contracts:
- Each allowlist entry produces the overridden single
%morwith no~clitic:parla → verb|parlare,arancione → noun|arancione,piccolo → adj|piccolo,gomitolo → noun|gomitolo,divano → noun|divano. dammelacontinues to produce its correct Stanza-native merged%mor(verb|dare…~pron|me…~pron|la…), the correctness control group.- Allowlist lookup is case-insensitive.
- Non-Italian
MappingContextsees no behavior change (explicitlang2(&ctx.lang) == "it"gate). - Unit tests in
lang_it.rscover the allowlist semantics directly (check_italian_mis_splitfor positive and negative inputs).
Future work
The tokenize-stage policy changed what remains open. Items from the
reconciler era that the policy resolved outright: multi-chunk Defect 8
decomposition (the split now arrives as a Range and decomposes natively),
per-surface allowlist expansion for Defects 6/8 (items that proposed
scanning for more surfaces to list are OBSOLETE: the open verb+enclitic
class is covered generally, including 2pl forms like prendetelo,
which need no listing). What is genuinely left:
- Regeneration-diff adjudication. The corpus regeneration diff is the corpus-scale verification instrument for the whole policy; any surprise it surfaces becomes the next item on this list.
- In-context committed-damage signature scan. The language-resolved
audit enumerates the single-word signature; committed in-context
damage (
det|il~det|ilforla, verb+enclitic items on multi-word utterances) needs its own signature inlang_auditfor exact enumeration. Regenerating all Italian-primary files cures it regardless. - Reconciler allowlist retirement.
IT_MIS_SPLIT_OVERRIDES(23 entries, Defects 6/7) andIT_COMPOUND_IMPERATIVES(11 entries, Defect 8) are shadowed by the source-level policy and now no-op; removal needs per-entry verification (empty the table, run the dependent integration tests, retire entries whose tests still pass). The Defect 9/10 component rewrites (IT_COMPONENT_REWRITES) remain LOAD-BEARING and must not be retired. - Defect 9 breadth. The policy validates split SHAPE, not component
analyses, so head mis-POS on a legitimate Range (
dagliela->ADP/da) is still repaired by a 1-entry rewrite. Whether more homograph heads mis-POS is unmeasured; a components-level audit over regenerated output would bound it. - Stanza-upgrade re-audit. On every Stanza upgrade: the lexicon
extraction seam is pinned by
test_italian_mwt_lexicon.py(a moved private attribute fails CI, not silently); the probe matrix and the Italian ml_golden tests re-verify policy behavior; the reconciler retirement workflow above re-classifies remaining entries.
Known limitations and flaws
Stated plainly, so nobody rediscovers them as surprises:
- Lexicon-gap over-splits. A form satisfying all four tests that is
absent from Stanza’s lexicon is wrongly split (
pentolo-> pento + lo: exact reconstruction, real clitic, attested verb base, not a dictionary entry). Invisible to every test the rule has; a real morphological analyzer is the only principled cure. - Reflexive-imperative under-splits. Forms Stanza’s lexicon lists as
words in their own right are not split (
svegliati,vestiti), which is genuinely ambiguous context-free (vestiti= “get dressed!” or “clothes”). The policy deliberately under-splits when it must guess: a lost split leaves a word coarsely analyzed, a false split invents a verb. - Lemma quality is out of scope. The policy fixes tokenization, not
the lemmatizer: an unsplit unknown word can still receive a fabricated
lemma (
tecala->tecalare), and a correct split can carry a wrong lemma (finila-> fini + la lemmatizes tofine, notfinire). - The lexicon is a private Stanza attribute. Deliberate trade (hand-maintained tables drift; Stanza’s own lexicon tracks its model), isolated at one loudly-failing seam and pinned by tests, but a Stanza refactor will require re-plumbing it.
- Probe cost. One extra
tokenize,mwtpipeline pass per batch, on candidates only (Stanza-marked tokens plus lexical-pre-filter hits). Lazily loaded; negligible against model inference, but nonzero. - Residual RATES are not stated. The regeneration diff is the measuring instrument; per-form rates before it would be guesses.
- Language routing is trusted, not audited. The policy assumes the words reaching the Italian pipeline are Italian-attributed. The corpus data is properly marked (language-resolved audit, 2026-07-28: zero unresolved words); whether the L2 routing layer honors every marker end-to-end has not been separately audited.
History
Rules that existed and were removed
BA2 carried two per-language hacks for Italian in
ud.py:662-695, ported into BA3’s
crates/batchalign-transform/src/tokenizer_realign.rs (the
historical mwt_overrides.rs sub-module has since been
consolidated into the single tokenizer_realign.rs file)
and then emptied after a paired empirical audit:
| Rule | What it did | Audit finding |
|---|---|---|
MwtTaggedExact("l'") → SuppressMwt | Flip any Stanza-tagged MWT l' token to non-MWT | Dormant. Modern Stanza Italian never emits a standalone l' with an MWT hint, the 15-case probe showed identical output with and without the rule. |
le + i → lei adjacent-token merge | When the aligned buffer had le followed by i, collapse them into a single lei token | Harmful. The rule corrupted legitimate adjacent CHAT words le (f.pl. article) and i (m.pl. article), Stanza cannot reassemble a raw-text le i back into lei. Modern Stanza Italian emits lei as one token natively, so the merge was also redundant on its intended input. |
Both rules were dead weight or actively wrong. Both were removed in
favor of Stanza’s native MWT behavior plus the always-on
character-DP realigner in align_tokens.
Why the DP alignment is the load-bearing piece
When Stanza’s Italian tokenizer occasionally mis-splits a CHAT word
(e.g., emits [il, i, ami] for l'ami), the character-level
Hirschberg alignment in align_tokens merges the N Stanza tokens
back to 1 token per CHAT word based on surface-character identity.
That rescue is unconditional (not per-language) and does not depend
on any MWT-override table, so Italian benefits from it with zero
configuration.
Tests
- Probe matrix cases:
batchalign/tests/investigations/_cases/italian.py: typedProbeCasefixtures coveringl'X, preposition+clitic,leivariants, adjacentle i, and the Defect 6/7 xfails. Important: the xfails are UD-word-count observations that pin Stanza’s POS/MWT misbehavior. They do NOT indicate%morinjection failures, injection succeeds with junk content. See each case’sXfailMark.reasonfor the distinction. - Matrix harness:
batchalign/tests/investigations/test_stanza_mwt_probe_matrix.pyruns every case through paired (free-tokenize vs postprocessor) pipelines. Invoke withuv run pytest batchalign/tests/investigations/ -m golden.
References
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Portuguese
Status: Current Last updated: 2026-05-21 08:42 EDT
Scope
Portuguese is a Romance language with productive preposition+article contractions that Stanza’s neural MWT processor handles natively:
do → de + o,da → de + a,dos,dasno → em + o,na → em + a,nos,naspelo → por + o,pela,pelos,pelasao → a + o,aos,à → a + a,às- Elisions before vowel-initial nouns, e.g.
d'água(idiomatic, “of water”)
Portuguese carries no per-language BA3 MWT-override rules as of 2026-04-21. The single BA2-ported rule was audited and removed, see History.
What Stanza handles natively
Paired probes with Stanza 1.11.1 (free-tokenize vs our postprocessor) show identical output on both paths and 1-to-1 preservation for:
| Pattern | Example | Stanza output |
|---|---|---|
| do / da / dos / das | perto do rio | de + o + rio (MWT, 1 CHAT word → 2 UD words, Range preserved) |
| no / na / nos / nas | na cidade | em + a + cidade (MWT) |
| ao / aos / à / às | vou ao mercado | a + o + mercado (MWT) |
d'água (idiomatic elision) | copo d'água, d'água standalone | Stanza emits 1 token with the apostrophe preserved; no split needed |
Probes in
batchalign/tests/investigations/_cases/portuguese.py (typed
ProbeCase fixtures consumed by the matrix harness at
test_stanza_mwt_probe_matrix.py).
Known Stanza limitations
No Portuguese-specific Stanza defects are tracked.
History
Rule that existed and was removed
| Rule | What it did | Audit finding |
|---|---|---|
Exact("d'água") → ForceMwt | Force MWT expansion on the idiomatic elision d'água | Net harm. In the standalone case (d'água by itself), the forced expansion produced 2 UD words (de + 'água) for 1 CHAT word, violating the morphotag 1-to-1 invariant. In sentence context (um copo d'água), Stanza already handled the token correctly without the hint. The rule added zero benefit and broke the invariant on one case. |
Removed in the audit. Portuguese now runs through the default tokenizer-postprocessor path with no per-language overrides.
Why the DP alignment is the load-bearing piece
As with French and Italian, the character-level Hirschberg
realignment in align_tokens is the unconditional, language-agnostic
rescue that merges any Stanza over-split back to 1 token per CHAT
word. Portuguese inherits this behavior for free.
Tests
- Probe matrix cases:
batchalign/tests/investigations/_cases/portuguese.py: typedProbeCasefixtures coveringd'águastandalone and in context, plus native MWTs (do,da,na). - Matrix harness:
batchalign/tests/investigations/test_stanza_mwt_probe_matrix.pyruns every case through paired pipelines. Invoke withuv run pytest batchalign/tests/investigations/ -m golden.
References
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Dutch
Status: Current Last updated: 2026-05-21 08:42 EDT
Scope
Dutch is a Germanic language with a relatively simple MWT profile:
- Possessive
'ssuffixes on proper names:Claus's,Maria's,Jan's - Idiomatic time expressions with leading
's:'s-avonds(“in the evening”),'s-morgens(“in the morning”),'s-nachts - Short apostrophe contractions:
't(=het),'n(=een) - Occasional reduced-vowel contractions such as
het's(dialectal)
Dutch carries no per-language BA3 MWT-override rules as of 2026-04-21. The single BA2-ported rule was audited and removed, see History.
What Stanza handles natively
Paired probes with Stanza 1.11.1 (free-tokenize vs our
postprocessor) on 13 's-bearing Dutch constructions produce
identical output on both paths:
| Pattern | Example | Stanza output |
|---|---|---|
Possessive 's on proper name | Claus's, Maria's, Jan's | 1 UD word, no MWT expansion |
Pseudo-contraction het's / er's | het's koud | 1 UD word per whitespace-separated token |
Leading 's time idiom | 's-avonds, 's-morgens | 1 UD word (hyphen-joined form preserved) |
| Short contraction | 't is koud, 'n huis | 1 UD word per token |
| Plain noun | huis | 1 UD word (control case) |
Probes in
batchalign/tests/investigations/_cases/dutch.py (typed
ProbeCase fixtures consumed by the matrix harness at
test_stanza_mwt_probe_matrix.py).
Known Stanza limitations
No Dutch-specific Stanza defects are tracked.
History
Rule that existed and was removed
| Rule | What it did | Audit finding |
|---|---|---|
EndsWith("'s") → SuppressMwt | For any Dutch token ending in 's, flip the MWT hint to False to prevent expansion | Dormant. Modern Stanza Dutch does not emit 's-suffix tokens with MWT hints today, the 13-case probe showed identical output with and without the rule active. The rule was also worryingly broad: it would fire on any 's-suffix form (possessives, contractions, idioms), with no per-pattern scoping. |
Removed in the audit. Dutch now runs through the default tokenizer-postprocessor path with no per-language overrides.
Why the DP alignment is the load-bearing piece
As with the Romance languages, the character-level Hirschberg
realignment in align_tokens is the unconditional rescue when
Stanza’s Dutch tokenizer occasionally over-splits. Dutch
inherits this behavior for free.
Tests
- Probe matrix cases:
batchalign/tests/investigations/_cases/dutch.py: typedProbeCasefixtures covering possessives, contractions, time idioms, and control nouns (grep -c ProbeCaseagainst the file is the live count). - Matrix harness:
batchalign/tests/investigations/test_stanza_mwt_probe_matrix.pyruns every case through paired pipelines. Invoke withuv run pytest batchalign/tests/investigations/ -m golden.
References
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Malayalam Language Support
Status: Current Last updated: 2026-08-06 16:10 EDT
Malayalam (mal) is supported in transcribe-only mode. Stanza
ships no processor packages for Malayalam, so utseg / morphotag /
forced alignment via Stanza are unavailable. ASR runs via the
whisper_hub engine using a HuggingFace Whisper fine-tune.
This page covers Malayalam concretely; the same pattern applies to
the other Indo-Aryan / Dravidian / Austronesian languages whose
entries in Stanza’s resources.json are charlm/lang_name stubs
without processor packages, see the Stanza capability table at
worker startup for the authoritative current list.
Quick Reference
| Pipeline Stage | Malayalam-Specific Behavior |
|---|---|
| ASR | HuggingFace Whisper fine-tune via --asr-engine whisper_hub |
| Text normalization | None, Malayalam script passed through as-is |
| Number expansion | Rust-side NUM2LANG table. Digits like "3" reach CHAT as "മൂന്ന്" and pass E220. See Number Expansion for the dispatch path; Malayalam is registered in the per-language coverage matrix on that page. |
| Retokenize | Not applied, retokenize maps a Stanza-tokenized word list back to ASR tokens; without a Stanza pipeline there is nothing to map. |
| Morphosyntax | Not available: Stanza ships no Malayalam pipeline |
| Utseg | Not available: same reason |
| Forced alignment | Stock Wave2Vec MMS works on the audio; no Malayalam-specific preprocessing |
Running Transcribe
batchalign3 transcribe --lang mal \
--asr-engine whisper_hub \
audio_dir/ -o output_dir/
The whisper_hub engine routes to whichever HuggingFace Whisper
fine-tune the model resolver picks for mal. The output CHAT file
contains a single main tier with the transcribed Malayalam text and
a media reference; no %mor, no %utseg, no per-word timing.
Recommended model
thennal/whisper-medium-ml produces clean output on the test sample
the Whisper-Hub integration was validated against. Other Malayalam
fine-tunes exist on HuggingFace; the resolver in
batchalign/models/resolve.py accepts an explicit override if a
different checkpoint is preferred.
Why no morphotag / utseg
Stanza’s resources.json contains a Malayalam entry, but only with
backward_charlm, forward_charlm, and lang_name fields, no
packages key listing tokenizer / POS / lemma / depparse models.
The Python worker’s UnsupportedLanguageError preflight catches
this before stanza.Pipeline() runs and the optional sub-stages
in transcribe are dropped from the plan at job submission.
Adding morphotag / utseg for Malayalam would require either Stanza upstream shipping Malayalam pipelines, or training and integrating a non-Stanza tagger, neither is in scope.
Why a fine-tune instead of stock Whisper
Stock multilingual Whisper does include a Malayalam language ID and
can transcribe Malayalam audio, but accuracy on extended Malayalam
recordings is generally well below what a Malayalam-specific fine-tune
delivers. The whisper_hub engine exists specifically to route
audio to such fine-tunes when the user requests it.
For languages where stock Whisper is already strong (English,
Spanish, etc.), whisper_hub is unnecessary, the default
--asr-engine whisper is fine.
Related languages
This transcribe-only mode applies to other languages where Stanza has no processor packages. Check at worker startup which codes the capability table reports as supported; codes outside that set follow the same Malayalam pattern (transcribe via stock Whisper or a fine-tune; no Stanza-driven analysis).
For context on Indo-Aryan / Dravidian languages that do have
full Stanza support, Tamil (tam), Hindi (hin), Urdu (urd),
Telugu (tel), and Thai (tha) ship complete processor packages
in current Stanza, morphotag and utseg work the standard way,
no whisper_hub needed. The exact set varies by Stanza version;
the worker’s capability table at startup is authoritative.
Resolved issues
E220 on Whisper digit emissions (resolved)
HuggingFace Whisper fine-tunes for Malayalam (including
thennal/whisper-medium-ml) transcribe spoken numbers as Arabic
digits (“3”, “100”) rather than Malayalam script. Pre-fix, CHAT
validation rejected these with E220 because mal is not in the
digit-allowed language allowlist
(talkbank-tools/../chatter/crates/talkbank-model/src/validation/word/language/digits.rs,
which permits digits only for zho, cym, vie, tha, nan,
yue, min, hak), and the Python num2words library has no
Malayalam backend.
Fix: added a Malayalam entry to NUM2LANG in
crates/batchalign-transform/data/num2lang.json covering 0-20,
decades 30-90, plus 100/1000 anchor words. The per-word Rust pass
in crates/batchalign/src/pipeline/transcribe.rs:527::prepare_asr_chunks
calls expand_number(text, "mal") on every word, converting digits
to their Malayalam-script word forms. The Python IPC path no longer
exists, Malayalam expansion is end-to-end Rust. Tests at
crates/batchalign-transform/src/asr_postprocess/num2text.rs:540
(malayalam_single_digits_expand_to_script,
malayalam_digits_collected_for_expansion,
malayalam_anchor_decades_and_hundreds) lock in the expected
expansions.
Higher-magnitude numbers (4-digit and beyond) are decomposed by
decompose_with_table greedily against the anchor entries; if the
table can’t fully decompose, the original digit string is returned
(matching every other language without an exhaustive table).
Operational notes
- Audio files of arbitrary length are supported; ASR processes
in 25-second chunks via the HuggingFace pipeline
(
batchalign/inference/asr.py:177chunk_length_s=25). - Output CHAT can be edited by hand and re-run through other
TalkBank tooling; the absence of
%mor/%utsegdoes not affect downstream tools that don’t require them.
This page last changed: 2026-08-06 (commit ff0d5869). The whole book last changed: 2026-09-16 (commit 34d249d8).
Chinese/Cantonese Word Segmentation
Status: Current Last updated: 2026-09-15 12:12 EDT
Problem
CJK ASR engines (FunASR/SenseVoice for Cantonese, Paraformer for Mandarin) output character-level tokens without word boundaries. Each Chinese character becomes a separate word on the main tier:
*CHI: 故 事 係 好 .
This makes word-level analysis (word count, POS tagging, MLU) unreliable, every character gets tagged as an independent word.
All four Cantonese ASR engines (Whisper, Tencent, Aliyun, FunASR) produce
per-character tokens. --retokenize is needed regardless of which engine is used.
Solution: --retokenize on morphotag
The --retokenize flag on morphotag enables word segmentation before POS
tagging. The segmentation method depends on the language:
| Language | Code | Segmentation Engine | How It Works |
|---|---|---|---|
| Cantonese | yue | PyCantonese segment() | Dictionary-based Cantonese word segmentation |
| Mandarin | cmn, zho | Stanza neural tokenizer | Jointly-trained Chinese tokenization model |
Cantonese Example
Morphotag has no --lang flag, language comes from each file’s
@Languages: yue header.
batchalign3 morphotag --retokenize corpus/ -o output/
Before (per-character):
*CHI: 故 事 係 好 .
%mor: n|故 n|事 v|係 adj|好 .
After retokenize (word-level):
*CHI: 故事 係 好 .
%mor: n|故事 v|係 adj|好 .
Mandarin Example
Morphotag has no --lang flag, language comes from each file’s
@Languages: cmn (or zho) header.
batchalign3 morphotag --retokenize corpus/ -o output/
Default Behavior
Without --retokenize, existing tokenization is preserved, morphotag never
silently changes word boundaries. This is consistent across all languages.
When Cantonese input appears to be per-character tokens (>80% single-CJK-character words), a warning is emitted:
warn: Cantonese input appears to be per-character tokens (42/50 single-CJK words).
Consider --retokenize for word-level analysis.
Which ASR engines produce what
| Engine | Word Segmentation | Recommendation |
|---|---|---|
| Tencent Cloud ASR | Per-character tokens (verified 2026-03-23: 25 words, 0 multi-char) | Use --retokenize |
| FunASR / SenseVoice | Per-character tokens (verified) | Use --retokenize |
| Paraformer (Mandarin) | Per-character tokens, one timestamp each (verified 2026-09-15 against real FunASR output); see ASR token pipeline | Use --retokenize |
| Whisper | Variable (often per-character for CJK) | Use --retokenize |
Known Limitations
Mandarin word segmentation is imperfect
Stanza’s Chinese tokenizer (used for Mandarin --retokenize) handles common
compounds correctly (e.g., 商店 “store”) but may split ambiguous compounds
where individual characters have independent meanings (e.g., 东西 “things”
may be split into 东 “east” + 西 “west”). This is a known limitation of
statistical Chinese word segmentation.
For word count and MLU analysis, the segmentation is substantially better than per-character tokenization but should not be treated as ground truth.
Cantonese segmentation depends on PyCantonese’s dictionary
PyCantonese uses a dictionary-based segmenter. Words not in its dictionary
will not be grouped. Common Cantonese words like 佢哋 (they), 鍾意 (like),
and 故事 (story) are handled correctly.
Pipeline Flow
The following diagram shows how --retokenize routes through the morphotag
pipeline for CJK languages:
flowchart TD
input["CHAT input\n(per-character tokens)"]
retok{"--retokenize\nflag?"}
lang{"Language?"}
pyc["PyCantonese segment()\n(inference/morphosyntax.py)"]
stanza_retok["Stanza neural tokenizer\n(_stanza_loading.py:retok)"]
stanza_pretok["Stanza pretokenized\n(standard pipeline)"]
rust_retok["Rust retokenize module\n(crates/batchalign-transform/src/retokenize.rs\n+ retokenize/{rebuild,parse_helpers}.rs)"]
chat_word["CHAT output\n(word-level tokens + %mor/%gra)"]
chat_char["CHAT output\n(per-char tokens + %mor/%gra)"]
warn["warn: Consider\n--retokenize"]
input --> retok
retok -->|yes| lang
retok -->|no| stanza_pretok --> chat_char
chat_char -.->|"yue + per-char"| warn
lang -->|"yue"| pyc --> stanza_pretok
stanza_pretok --> rust_retok --> chat_word
lang -->|"cmn/zho"| stanza_retok --> rust_retok
Cache Behavior
Retokenize results are cached separately from non-retokenize results. The
cache key includes a |retok suffix when --retokenize is active, so
switching between modes does not produce stale cache hits.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Japanese Morphosyntax Pipeline
Status: Current Last updated: 2026-05-20 07:58 EDT
Overview
Japanese is a non-MWT language with a “combined” Stanza package. Unlike MWT-capable languages (English, French, Italian) that split contractions into syntactic sub-words, Japanese uses a fundamentally different tokenization strategy where Stanza’s neural models may merge or re-segment CJK characters.
Two tokenization modes (retokenize vs keep-tokens) behave very differently
for Japanese, and several Stanza output artifacts require language-specific
cleanup in the Rust POS mapping layer.
This document explains the full pipeline end-to-end: from Stanza configuration through verb form overrides, POS mapping, whitespace artifact handling, and current limitations.
Stanza Configuration
Japanese uses two distinct Stanza configurations depending on the retokenize
flag. Both modes force the combined package for all four processors.
Package Selection
All Japanese Stanza pipelines use combined instead of default.
The override is wired in
batchalign/worker/_stanza_loading.py:196-209 (the if alpha2 == "ja" branch that constructs the stanza.Pipeline with an explicit
package={...combined...}):
if alpha2 == "ja":
nlp = stanza.Pipeline(
lang=alpha2,
processors=processors,
download_method=DownloadMethod.REUSE_RESOURCES,
tokenize_no_ssplit=True,
tokenize_pretokenized=True,
package={
"tokenize": "combined",
"pos": "combined",
"lemma": "combined",
"depparse": "combined",
},
)
The combined package bundles tokenization, POS tagging, lemmatization, and
dependency parsing into a single model trained jointly. Using default for any
processor would load a mismatched model.
MWT Exclusion
Japanese never loads the mwt processor. The historical hardcoded
_MWT_EXCLUSION frozenset was retired in favour of the runtime
capability-driven helper should_request_mwt() at
batchalign/worker/_stanza_loading.py:40 (see Defect 5 in
Stanza Defect Mitigation Map):
the helper consults the live Stanza capability table and excludes
mwt for any language whose model does not ship that processor.
Japanese falls into the exclusion naturally, its combined
package does not include an mwt processor, so should_request_mwt
returns False for ja without a per-language entry. The package
override above is needed regardless of MWT mode, so the two
concerns stay independent.
Keep-Tokens Mode (retokenize=False)
Default mode. Stanza’s tokenizer is completely bypassed via
tokenize_pretokenized=True. The CHAT words are passed directly as
pre-tokenized input, giving a safe 1:1 mapping between input words and Stanza
tokens. No word merging or splitting can occur.
Retokenize Mode (retokenize=True)
Stanza’s neural tokenizer runs with tokenize_no_ssplit=True (no sentence
splitting). The combined tokenizer may merge adjacent CHAT words or split
single words into sub-tokens. The Rust retokenize algorithm (retokenize.rs)
then realigns the modified tokenization back onto the CHAT AST using
character-level DP alignment.
| Property | Keep-Tokens | Retokenize |
|---|---|---|
| Stanza tokenizer | Bypassed (pretokenized) | Runs (no_ssplit) |
| Word boundaries | Preserved | May change |
| Token mapping | 1:1 | N:M (DP alignment) |
| Whitespace artifacts | Rare | Common |
| Use case | Morphotag on existing transcripts | Full re-analysis |
Stanza Whitespace Artifacts
When Stanza’s combined tokenizer runs (retokenize mode), it may merge
adjacent CHAT words into a single token while preserving the ASCII space from
the join. For example, two CHAT words ふ and す become a single Stanza
token "ふ す" (with internal space).
This whitespace is a tokenization artifact, not a word boundary. It must be stripped, not split, because the space does not represent a separate word.
Fix 1: Token Text (crates/batchalign-transform/src/morphosyntax/injection.rs)
Before passing tokens to the retokenize algorithm, whitespace is stripped from token text:
if retokenize {
// Stanza's combined tokenizer (e.g. Japanese) sometimes merges
// adjacent CHAT words into a single token while preserving the
// ASCII space from the join. Strip any whitespace so the token
// text is a valid CHAT word.
let tokens: Vec<String> = ud_sentence
.words
.iter()
.map(|w| {
if w.text.contains(char::is_whitespace) {
w.text.chars().filter(|c| !c.is_whitespace()).collect()
} else {
w.text.clone()
}
})
.collect();
retokenize::retokenize_utterance(utt, &words, &tokens, /* ... */);
}
Fix 2: Lemma Text (crates/batchalign-transform/src/morphosyntax/ud_types.rs:426)
Stanza’s lemmatizer can also produce lemmas with internal whitespace
(e.g., "ふ す"). The sanitize_mor_text() function strips all
whitespace before %mor assembly:
#![allow(unused)]
fn main() {
pub fn sanitize_mor_text(s: &str) -> String {
let mut result = s.replace(['|', '#', '-', '&', '$', '~'], "_");
result.retain(|c| !c.is_whitespace());
result
}
}
This also replaces MOR structural separators (|, #, -, &, $, ~)
with underscores, preventing syntactic contamination of the %mor tier.
Why Stripping, Not Splitting
The internal space is a Stanza artifact from merging two CHAT tokens. The merged token is a single linguistic unit, splitting on the space would create two %mor entries for what Stanza considers one word, breaking the word↔mor alignment. Stripping produces a valid CHAT word that correctly maps to one %mor item.
Lemma Cleaning
The clean_lemma() function
(crates/batchalign-transform/src/morphosyntax/mor_word.rs:81)
performs generic lemma cleanup, but several rules are
Japanese-relevant:
Japanese Quote Fallback
When Stanza returns a Japanese bracket quote as the lemma, the
function falls back to the surface text (in the body of clean_lemma
at mor_word.rs:81):
// Handle Japanese quotes
if target.trim() == "\u{300D}" || target.trim() == "\u{300C}" { // 」 or 「
target = text.to_string();
}
After the fallback, any remaining quote characters are stripped:
target = target.replace('\u{300D}', ""); // 」
target = target.replace('\u{300C}', ""); // 「
Smart Quote Handling
If the lemma contains a left smart quote (U+201C "), the function
falls back to the surface text (within clean_lemma at
mor_word.rs:81). This catches cases where Stanza’s lemmatizer
produces a quote character instead of the actual lemma.
Empty Lemma Safeguard
After all cleaning, if the lemma is empty, the function falls back
to the surface text. If the surface text is also empty, it uses
"x" as a placeholder (still within clean_lemma at
mor_word.rs:81). This prevents the E342 “bare pipe” parse error
(pos| with no stem).
POS Mapping
The map_ud_word_to_mor() function
(crates/batchalign-transform/src/morphosyntax/mor_word.rs:13) applies
Japanese-specific overrides in steps 3-4.
Step 3: Verb Form Overrides
If the language is Japanese, verb form overrides run before generic
POS mapping (inside map_ud_word_to_mor at
mor_word.rs:13):
if lang2(&ctx.lang) == "ja"
&& let Some(ovr) = lang_ja::japanese_verbform(&effective_pos, &cleaned_lemma, &ud.text)
{
effective_pos = ovr.pos.to_string();
cleaned_lemma = ovr.lemma.to_string();
cleaned_lemma = cleaned_lemma.replace(',', "cm");
}
The comma→"cm" replacement handles the case where a verb form override
produces a lemma containing a comma (which would be illegal in a %mor stem).
Step 4: PUNCT → cm
All Japanese PUNCT tokens map to the cm (comma marker) POS
category, and Japanese commas (both full-width 、 and ASCII ,)
also map to cm (still inside map_ud_word_to_mor at
crates/batchalign-transform/src/morphosyntax/mor_word.rs:13):
if lang2(&ctx.lang) == "ja" {
if matches!(ud.upos, UdPunctable::Value(UniversalPos::Punct)) {
effective_pos = "cm".to_string();
}
if ud.lemma == "、" || ud.lemma == "," {
effective_pos = "cm".to_string();
}
}
| Input | UPOS | Resulting POS |
|---|---|---|
。 | PUNCT | cm |
、 | PUNCT | cm |
, | PUNCT | cm |
「 | PUNCT | cm |
This differs from other languages where PUNCT maps to punct and only
actual commas map to cm.
Verb Form Overrides
The japanese_verbform() function
(crates/batchalign-transform/src/morphosyntax/lang_ja.rs, 65 override
rules) is ported from the BA2 Python verb-form override file.
Structure
pub struct JaOverride {
pub pos: &'static str, // New POS category
pub lemma: &'static str, // New lemma
}
pub fn japanese_verbform(upos: &str, target: &str, text: &str) -> Option<JaOverride>
The function takes the lowercased UPOS tag, cleaned lemma, and surface text.
It returns Some(JaOverride) if a match is found, None otherwise.
Categories
The 65 rules cover these categories (in match order):
| Category | Count | Examples |
|---|---|---|
| Conditional/subjunctive conjunctions | 3 | ちゃ→ば, なきゃ, じゃ |
| Auxiliary verbs | ~8 | られる, ちゃう, おう, たら |
| Interjections | ~9 | はい, うん, おっ, ほら, あのね |
| Pronouns | ~2 | あたし |
| Verb lemma corrections | ~11 | 撮る, 貼る, 混ぜる, 釣る, 帰る |
| Noun overrides | ~8 | バツ, ブラシ, 引き出し, マヨネーズ |
| Adjective specializations | ~3 | 速い |
| 為る context overrides | ~5 | Verb/noun/aux disambiguation |
| Participles and other | ~16 | Various form-specific overrides |
Order Dependence
Order is significant. The function mirrors the original
Python’s exact if/elif chain in
crates/batchalign-transform/src/morphosyntax/lang_ja.rs: earlier
rules take precedence. For example, a word containing both ちゃ
and なきゃ would match the ちゃ rule because it appears first.
Execution Timing
Verb form overrides run before POS mapping (inside
map_ud_word_to_mor at
crates/batchalign-transform/src/morphosyntax/mor_word.rs:13). This
means they can change both the POS category and lemma that flow
into feature computation and %mor assembly.
No Clitic Detection
The is_clitic() function
(crates/batchalign-transform/src/morphosyntax/mor_word.rs:200)
identifies MWT sub-tokens that are clitics (e.g., English n't,
's; French l', -ce). Japanese has no entries, the function
returns false for all Japanese tokens:
fn is_clitic(text: &str, ctx: &MappingContext) -> bool {
match lang2(&ctx.lang) {
"en" => text == "n't" || text == "'s" || text == "'ve" || text == "'ll",
"fr" => text.ends_with('\'') || text == "-ce" || text == "-être" || text == "-là",
"it" => text.ends_with('\''),
_ => false, // Japanese falls through here
}
}
This is correct: Japanese does not use MWT expansion, so there are no clitic sub-tokens to identify.
Per-Word Language Routing: Current Limitation
CHAT supports per-word language markers (@s:jpn) for code-switching. The
Rust extraction layer (extract.rs) correctly extracts these markers into a
WordLanguageMarker enum with variants for bare (@s), explicit
(@s:jpn), multiple (@s:eng+jpn), and ambiguous (@s:eng&jpn).
However, the language code is currently discarded at the Rust→Python boundary
during morphosyntax processing. All language-marked words become L2|xxx in
the %mor output regardless of the specified language. Per-utterance language
routing is the current supported boundary; per-word language routing is not part
of the current public runtime contract.
Code Reference
| Concept | File | Anchor |
|---|---|---|
Capability-driven MWT exclusion (retired the historical _MWT_EXCLUSION frozenset) | batchalign/worker/_stanza_loading.py | should_request_mwt() @40 |
combined package forcing for Japanese | batchalign/worker/_stanza_loading.py | :196-209 (if alpha2 == "ja" branch) |
| Stanza config (keep-tokens vs no-MWT) modes | batchalign/worker/_stanza_loading.py | load_stanza_models() @126 |
| Token text whitespace strip | crates/batchalign-transform/src/morphosyntax/injection.rs | retokenize-mode token sanitizer |
| Lemma whitespace strip | crates/batchalign-transform/src/morphosyntax/ud_types.rs | sanitize_mor_text() @426 |
clean_lemma() (quote handling) | crates/batchalign-transform/src/morphosyntax/mor_word.rs | @81 |
map_ud_word_to_mor() (JA overrides) | crates/batchalign-transform/src/morphosyntax/mor_word.rs | @13 |
| Japanese PUNCT → cm | crates/batchalign-transform/src/morphosyntax/mor_word.rs | inside map_ud_word_to_mor @13 |
is_clitic() (no JA entries) | crates/batchalign-transform/src/morphosyntax/mor_word.rs | @200 |
| Verb form overrides | crates/batchalign-transform/src/morphosyntax/lang_ja.rs | japanese_verbform() |
| Retokenize algorithm | crates/batchalign-transform/src/retokenize.rs (+ retokenize/{rebuild,parse_helpers}.rs) | full module |
@s: marker extraction | ../chatter/crates/talkbank-transform/src/extract.rs | WordLanguageMarker extraction |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Hebrew Morphosyntax
Status: Current Last updated: 2026-09-07 07:04 EDT
Hebrew-specific handling in batchalign3’s morphosyntax pipeline.
Language Code
| Internal (ISO 639-3) | Stanza (ISO 639-1) | Notes |
|---|---|---|
heb | he | Standard mapping |
ASR
Hebrew uses a fine-tuned Whisper model for the HuggingFace engine:
| Engine | Model |
|---|---|
--asr-engine whisper | ivrit-ai/whisper-large-v3 (fine-tuned for Hebrew) |
| Rev.AI | Cloud API (supports Hebrew) |
The ivrit-ai/whisper-large-v3 model is trained on Hebrew conversational
speech and significantly outperforms generic Whisper on Hebrew audio.
RTL Punctuation
Hebrew text may contain Arabic-script punctuation from mixed content. The ASR post-processing pipeline normalizes RTL punctuation to ASCII:
| RTL | ASCII | Unicode |
|---|---|---|
| ؟ | ? | U+061F Arabic Question Mark |
| ۔ | . | U+06D4 Arabic Full Stop |
| ، | , | U+060C Arabic Comma |
| ؛ | ; | U+061B Arabic Semicolon |
This normalization runs for all languages, not just Hebrew, it ensures CHAT files contain only ASCII punctuation terminators regardless of source script.
Morphosyntax Features
Hebrew has two language-specific UD features that batchalign3 maps to CHAT %mor suffixes: HebBinyan and HebExistential.
HebBinyan (Verb Conjugation Pattern)
Hebrew verbs belong to one of seven binyanim (conjugation patterns): PAAL, NIFAL, PIEL, PUAL, HIFIL, HUFAL, HITPAEL.
Stanza’s Hebrew model outputs the HebBinyan feature on verbs. batchalign3
converts it to a lowercase suffix in %mor:
UD features: HebBinyan=PAAL|Number=Sing|Person=3|Tense=Past|VerbForm=Fin
%mor suffix: -paal&3S&PAST
The binyan is lowercased in the suffix: PAAL → paal, HIFIL → hifil.
HebExistential
The Hebrew existential (יש/אין, “there is”/“there isn’t”) gets a special feature in Stanza:
UD features: HebExistential=True|VerbForm=Fin
%mor suffix: -true
The value is lowercased: True → true.
Feature Format
The full verb suffix format (shared across all languages):
-VerbForm-Aspect-Mood-Tense-Polarity-Polite-HebBinyan-HebExistential-NumberPerson-irr
Hebrew-specific features slot into their dedicated positions. The -irr
suffix (English irregular verbs) is not applied to Hebrew, it is
gated to English only.
Implementation
The feature extraction is language-agnostic in implementation, the code
in features.rs checks for HebBinyan and HebExistential in any
language’s feature set, but only Stanza’s Hebrew model actually produces
these features:
// crates/batchalign-transform/src/morphosyntax/features.rs (lines 43, 46)
if let Some(v) = feats.get("HebBinyan") {
parts.push(v.to_lowercase());
}
if let Some(v) = feats.get("HebExistential") {
parts.push(v.to_lowercase());
}
MWT
Hebrew uses the MWT processor. Stanza’s Hebrew model handles Hebrew contractions (preposition + article combinations like בַּ → ב + ה).
Number Expansion
Hebrew does not have a dedicated number expansion table in num2lang.json.
Digit strings in Hebrew ASR output pass through unexpanded. This is a known
gap, Hebrew numbers in CHAT output will appear as digits rather than
Hebrew word forms (אחת, שתיים, שלוש, etc.).
No Other Language-Specific Workarounds
Unlike English, French, Japanese, Italian, Portuguese, and Dutch, Hebrew has no Stanza workarounds in batchalign3. The HebBinyan and HebExistential feature mapping is standard UD feature processing, not a bug workaround.
If systematic Stanza errors are discovered for Hebrew, a
crates/batchalign-transform/src/morphosyntax/lang_he.rs file should
be created following the pattern of existing language files
(lang_en.rs, lang_fr.rs, lang_it.rs, lang_ja.rs).
Source Files
| File | What |
|---|---|
crates/batchalign-transform/src/morphosyntax/features.rs:43,46 | HebBinyan/HebExistential extraction |
crates/batchalign-transform/src/morphosyntax/types.rs:38 | heb → he code mapping (Rust ISO-3 → Stanza ISO-1) |
crates/batchalign/src/chat_ops/nlp/mapping/mod.rs | Core mapping module that consumes the extracted features |
crates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rs | Hebrew integration tests |
batchalign/worker/_stanza_loading.py | Stanza pipeline configuration for Hebrew |
Test Coverage
| Test | File | What |
|---|---|---|
test_hebrew_verb_hebbinyan | crates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rs:15 | HebBinyan=PAAL → lowercase suffix |
test_hebrew_verb_hebexistential | crates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rs:45 | HebExistential=True → lowercase suffix |
test_hebrew_3letter_code_works | crates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rs:268 | “heb” (not “he”) processes HebBinyan |
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Number Expansion
Status: Current Last updated: 2026-09-15 09:35 EDT
ASR engines emit digit-bearing tokens ("3", "$5", "1950s",
"3rd", "80%") that the CHAT format does not allow on the main
tier for most languages (the validator rejects them as E220).
Number expansion rewrites those tokens to language-appropriate word
forms before they reach validation.
For developers: the architecture and per-language coverage matrix live at Architecture → Number Expansion. That page is the single source of truth and is kept in lock-step with the implementation.
What expansion does to your output
| Input token | Output (language) |
|---|---|
"3" (eng) | "three" |
"3" (mal, Malayalam) | "മൂന്ന്" |
"3" (zho / cmn) | "三" |
"3" (jpn / yue) | "三" (traditional script) |
"3rd" (eng) | "third" |
"21st" (eng) | "twenty-first" |
"1950s" (eng) | "nineteen fifties" |
"54ª" (por) | "quinquagésima quarta" |
"1.º" (por) | "primeiro" |
"54.ºs" (por) | "quinquagésimos quartos" |
"$12" (any) | "twelve dollars" |
"€50" (any) | "fifty euros" |
"80%" (eng) | "eighty percent" |
"21-22" (eng) | "twenty-one twenty-two" |
"3" (cym / vie / nan / min / hak) | "3" (validator allows digits inline) |
Expansion is fully deterministic, no ML model, no audio context. A bug-for-bug repeat of the same ASR output produces the same expanded text.
Coverage
The number-expansion table at
crates/batchalign-transform/data/num2lang.json covers the long tail
of European, Indic, East Asian, and Semitic languages, the active
list is the JSON file itself; treat it as the canonical source. Most
entries are codegenned from the Python num2words library at build
time; a handful are hand-curated where num2words is missing the
language or has known defects (Malayalam mal, Greek ell, Basque
eus, Croatian hrv).
CJK languages route through the dedicated num2chinese converter
(Mandarin → simplified, Cantonese / Japanese → traditional).
Languages whose CHAT validator already accepts inline digits need no
expansion to pass validation. Those with a table (Welsh cym,
Vietnamese vie, Thai tha) are still expanded; those without one
(Min Nan nan, Minangkabau min, Hakka hak) keep the digit, which
the validator accepts.
Languages outside this set hit the validator as E220. To add one, see the Adding Language Support checklist’s number-expansion section.
English-specific extras
Beyond cardinals (every covered language), English also has:
- Ordinals:
"3rd"→"third","21st"→"twenty-first","1234th"→"one thousand two hundred and thirty-fourth". - Decades:
"1950s"→"nineteen fifties","80s"→"eighties". - Years (when surrounded by year-form context): handled by the ordinal/year/decade composer.
These English-only modes are deterministic Rust composition rules
cross-validated against num2words output for every value in the
covered range.
Portuguese ordinals
Portuguese ordinals written as printed abbreviations expand with the
gender and number the abbreviation marks, for ranks 1 through 1000:
54ª → quinquagésima quarta, 54º → quinquagésimo quarto,
3.ª → terceira, 54.ºs → quinquagésimos quartos. The period in
54.ª belongs to the abbreviation and does not end the utterance; a
period after it still does. Outside that range (0ª, 1001.º) the
token is left as written and validation reports E220.
Other ordinal conventions
Other ordinal or decade conventions (Spanish "3º", German "3.",
French "1950s") currently pass the digit through; no observed
production traffic has needed them. File a request if your corpus
contains them; the implementation pattern is the same as for English
and Portuguese.
Currency, percent, and dash ranges
These are language-agnostic symbol patterns, expanded by Rust regardless of target language:
$ € £ ¥ ₹ ₩ ₽prefix or suffix → cardinal expansion of the digit portion + English currency word (“dollars”, “euros”, “pounds”, …). Rationale: morphosyntax can re-tag in-language later; CHAT just needs some non-digit word.%suffix → cardinal + per-language percent word (English “percent”, Spanish “por ciento”, etc.); falls back to “percent” for unlisted languages.5-7or5-6→ split into"five seven"/"five six"(em-dashes normalize to hyphens; both parts must be pure digits).
When expansion fails
If a token genuinely cannot be expanded (a language with no table, an ordinal or decade convention without an expander, a Portuguese ordinal above 1000, a number the table cannot decompose), the original token passes through. Validation later emits E220 with the file and line number. That is the design: silent fallthrough surfaces as a real validator error rather than a wrong-but-plausible word.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Utterance Segmentation
Status: Current Last updated: 2026-09-16 09:47 EDT
Utterance segmentation splits continuous ASR output into individual utterances for CHAT transcription. This is a critical step, CHAT requires one utterance per line, each terminated by a sentence-ending punctuation mark.
One important subtlety: the segmentation language and the upstream ASR request
language are related but not identical. For Rev.AI --lang auto, BA3 can reach
the English utterance model in two different ways:
- Rev language ID succeeds before transcript submission, so the request itself becomes the explicit-English path.
- Rev language ID fails, BA3 submits a true auto request, and only later resolves the returned transcript language to English for downstream segmentation.
Both branches can eventually run the English BA2 utterance model. Only the
first branch is provider-request-equivalent to explicit --lang eng.
Three Mechanisms
batchalign3 has three utterance segmentation mechanisms:
- Pre-CHAT utterance models: BA2-style token-classification models that
predict utterance boundaries from typed ASR word lists before CHAT exists.
Available for 3 language families / 4 supported codes (
eng,cmn,zho,yue). - Punctuation-based fallback: Rust-side retokenization over typed ASR words. Used for unsupported languages and as cleanup after model-backed segmentation.
- CHAT-text utterance segmentation (
utseg): a second text-task pass over already-built CHAT. It uses the configured TalkBank boundary model when one exists; Stanza is an explicit fallback for unsupported languages.
flowchart TD
asr["Typed ASR monologues"]
revauto{"Rev.AI --lang auto?"}
langid{"Language ID succeeds\nbefore transcript submit?"}
efflang["Effective postprocess language"]
check{"Language has\npre-CHAT model?"}
bert["BA2 utterance model\nreturns typed assignments"]
punct["Rust punctuation retokenization\n(. ? ! +... etc.)"]
utts["Pre-CHAT utterances"]
chat["CHAT AST"]
postchat["Second boundary-model pass\non built CHAT text"]
asr --> revauto
revauto -->|no or non-Rev engine| efflang
revauto -->|yes| langid
langid -->|yes| efflang
langid -->|no| efflang
efflang --> check
check -->|eng/cmn/zho/yue| bert
check -->|all others| punct
bert --> punct
punct --> utts
utts --> chat
chat --> postchat
BERT Utterance Models
| Language | Code | Model | Source | Architecture |
|---|---|---|---|---|
| English | eng | talkbank/CHATUtterance-en | TalkBank fine-tuned | BERT token classification |
| Mandarin | cmn/zho | talkbank/CHATUtterance-zh_CN | TalkBank fine-tuned | BERT token classification |
| Cantonese | yue | PolyU-AngelChanLab/Cantonese-Utterance-Segmentation | Hong Kong Polytechnic | BERT token classification |
These models predict utterance-boundary actions as a token classification task. In BA3, Python model inference stays token-based and returns typed word-assignment groups to Rust; Rust then applies those assignments to the prepared ASR chunks without round-tripping through ad hoc sentence strings.
The six semantic actions are ordinary, capitalized onset, period boundary, question boundary, exclamation boundary, and comma. Only the three boundary actions advance the assignment group. Current postprocessing suppresses the earlier action whenever two adjacent words both have any non-ordinary action; the retained evidence records raw and applied actions separately so this policy can be evaluated without rerunning the model.
The distinction is material. A controlled local replay over 598 retained
English source monologues and 15,141 words found 267 action differences between
the current policy and a policy that suppresses only the earlier of two true
sentence-end actions. Of those differences, 140 restored a sentence-end action
and changed the utterance assignments. This is evidence that the legacy rule
needs human-linked evaluation, not proof that all 140 restored boundaries are
correct. This first census isolates model decoding over retained provider
monologues; production preprocessing and speaker projection can change the
exact model inputs. The production default remains unchanged. See the
developer utseg reference for the reproducible, provider-free probe.
Worker protocol V2 also carries the sum of the three sentence-end
probabilities for each classified word at fixed micro precision, along with the
model ID and its exact revision, which is always present: the model is loaded
from a pinned snapshot, so the revision is a required part of its identity
rather than something reported when the library happened to expose it. A normalization omission and a
short input that bypasses model inference are explicit states. Rust refuses
the result unless assignments and evidence exactly parallel the dispatched
words. transcribe --debug-dir PATH retains these decisions in separate
versioned pre-CHAT and post-CHAT evidence files; see the transcribe guide.
flowchart LR
W["Dispatched words"] --> M["Boundary model"]
M --> R["Raw action + boundary probability"]
R --> P{"Selected normalization policy"}
P --> A["Applied action"]
A --> G["Assignment groups"]
R --> E["Per-word evidence"]
A --> E
G --> V{"Rust shape admission"}
E --> V
V -->|"lengths and assignments agree"| D["AdmittedUtsegPrediction"]
V -->|"mismatch"| F["Typed protocol failure"]
D --> S["Split prepared chunk or CHAT utterance"]
O["Normalization omission"] --> V
C["Model short-circuit"] --> V
These are text-model signals, not acoustic confidences. The model sees lexical context but does not receive pause duration, waveform energy, pitch, diarization overlap, or CHAT retrace structure. Those signals must be joined downstream under an explicitly tested policy.
Both standalone utseg and transcribe’s pre-CHAT segmentation path resolve
through the same manifest table, so cmn and zho both select
talkbank/CHATUtterance-zh_CN at the same commit.
The table above is stated in exactly one place, UTSEG_BOUNDARY_MODELS in
crates/batchalign/src/model_manifest.rs, which names each model AND pins the
revision it loads. UtsegRoute::resolve is the one function that turns a
language plus a fallback policy into a segmenter choice, and it reads
availability from that same table, so a language BA3 offers to segment is by
construction a language it can name a model for. A language with no boundary
model and no authorized Stanza fallback has no segmenter, and that is refused
when the job is planned, before any ASR is dispatched, rather than at the worker
after the transcription has been produced.
The Python side holds no language-to-model map at all. An id must be known before a load in order to pin its revision, so Rust resolves it and sends it to the worker with the spawn; the worker loads exactly that snapshot and reports the commit it found on disk.
For Rev.AI --lang auto, model selection happens after the effective language
is resolved for post-processing. That means an auto-submitted Rev transcript can
still run through the English BERT utterance model later, even if the original
provider request was not identical to explicit --lang eng.
Cantonese Model Details
The Cantonese model uses character-level tokenization (each Chinese character is a separate token) and predicts 6 action classes:
| Class | Meaning |
|---|---|
| 0 | Normal (continue) |
| 1 | Capitalize next |
| 2 | Period (.) |
| 3 | Question mark (?) |
| 4 | Exclamation mark (!) |
| 5 | Comma (,) |
Before feeding text to the model, Cantonese-specific preprocessing runs:
- Strip punctuation:
.,!!?。,?():* - Split on Cantonese sentence-final particles: 呀, 啦, 喎, 嘞, 㗎喇, 囉, 㗎, 啊, 嗯
- Feed each chunk to the BERT model as character-level tokens
Memory Footprint
Each utterance model is ~400 MB. In the worker runtime it is loaded alongside
the utseg task so transcribe can reuse the same typed text-inference boundary
for both pre-CHAT segmentation and later CHAT-level refinement.
Punctuation-Based Fallback
For languages without a dedicated utterance model, utterances are split by
punctuation in Rust (crates/batchalign-transform/src/asr_postprocess/mod.rs).
CHAT-Legal Sentence Terminators
. ? ! +... +/. +//. +/? +!? +"/. +". +//? +..? +. ... (.)
Additional Normalizations
Before splitting:
- Japanese period (。) →
. - Spanish inverted punctuation (¿, ¡) → removed
- RTL punctuation (؟, ۔, ،, ؛) → ASCII equivalents
Split Rules
- If a word is a terminator → flush the current utterance
- If a word ends with a terminator character → split the word, flush
- If no terminator is found → auto-append
.at the end - Trailing morphological punctuation (‡, „, ,) is stripped before flush
Long Turn Splitting
Before punctuation-based retokenization, monologues longer than 300 words are
split into chunks of 300. BA3 also applies a long-pause fallback split before
retokenization, but only in a narrow case: a gap of at least 800 ms whose
next word is one of a fixed list of English sentence starters (and, so,
what, and so on: LONG_PAUSE_SENTENCE_STARTERS in
crates/batchalign-transform/src/asr_postprocess/mod.rs). It never fires for
Chinese, Cantonese or other non-English text, so for those languages a long
unpunctuated run is split only by the utterance model (where one exists) and
by the 300-word cap.
Stanza Utterance Segmentation (CHAT-text path)
Separately from ASR post-processing, the utseg NLP task can also use
Stanza’s constituency parser to predict utterance boundaries during
standalone utseg processing on already-built CHAT text. On the live worker
boundary, Rust freezes a prepared-text batch and dispatches
execute_v2(task="utseg"). For model-backed languages, Python may return
direct typed assignments; for the Stanza path it returns raw constituency trees
and Rust computes assignments locally.
Not all languages have constituency parsing. Stanza has constituency
models for ~11 languages (en, de, es, it, pt, da, id, ja, tr, vi, zh-hans).
For other languages (e.g. Dutch, Polish, Russian), the utseg config builder
omits the constituency processor and falls back to sentence-boundary
segmentation. This is handled automatically by the Stanza capability table
(batchalign/worker/_stanza_capabilities.py), which reads Stanza’s
resources.json to discover per-language processor availability.
This is a different mechanism from the pre-CHAT utterance models above: it
operates on already-built CHAT text and can refine boundaries using syntactic
structure. The user-facing surface is the standalone utseg command and
transcribe’s post-CHAT pass. With utterance segmentation enabled, production
transcribe uses a closed two-pass plan: the language model first segments
prepared timed ASR words, CHAT is built, and the model then refines the main
tiers. Disabling utterance segmentation disables both passes. For a language
with a configured TalkBank boundary model, that model remains the primary
path; Stanza is an explicit fallback when no boundary model is configured.
The passes are not independent. A pre-CHAT boundary changes the main-tier contexts presented after CHAT construction. Offline evaluation therefore distinguishes an actual one-pass topology from policy isolation that retains both passes while changing only the pre- or post-CHAT decoder policy. See the developer transcribe reference for the four typed replay choices.
The second pass also has to project timing onto its new children. BA3 permits
%wor partitioning only after equal policy-selected counts pass canonical
lexical corroboration. Which main-tier words hold a %wor slot is Chatter’s
policy (WorSlotMembershipPolicy), asked per word rather than restated here,
so the splitter and the timing binding cannot come to disagree about the
count they are comparing. When every retained child then supplies complete
positive word timing, each new main-tier bullet is rederived as that child’s
word-timing hull. If complete per-child evidence is unavailable, no child
receives a main-tier bullet at all: the parent’s span measures the whole parent,
not any one of its children, so carrying it onto one of them would present an
unmeasured span as a measured one. This timing projection is downstream of the boundary decision and
must not be interpreted as evidence that the chosen segmentation is
linguistically unique or optimal. See the %wor reference for the exact
fallback conditions.
Why Only 3 Languages Have Models
Training utterance segmentation models requires large amounts of annotated conversational data with gold-standard utterance boundaries. TalkBank has this for English (extensive CHILDES/TalkBank corpora), Mandarin (growing corpus), and Cantonese (PolyU research corpus).
For other languages, the punctuation-based fallback produces acceptable results because ASR models (especially Whisper) tend to insert punctuation at natural utterance boundaries. The main limitation is run-on speech without clear sentence structure, the fallback will produce fewer, longer utterances.
Adding a New Language Model
To add utterance segmentation for a new language:
- Collect annotated conversational data with utterance boundaries
- Fine-tune a BERT token classification model (6 classes: normal, capitalize, period, question, exclamation, comma)
- Upload to HuggingFace Hub
- Add the language, model id and pinned commit to
UTSEG_BOUNDARY_MODELSincrates/batchalign/src/model_manifest.rs. That one edit both makes the language routable and pins what it loads; there is no second table to update - Add any language-specific preprocessing (e.g., character-level tokenization for CJK, particle-based chunking)
Source Files
| File | Purpose |
|---|---|
crates/batchalign-transform/src/asr_postprocess/mod.rs | Typed ASR normalization + punctuation retokenization |
batchalign/models/utterance/infer.py | BA2-style utterance model runtime |
batchalign/worker/_model_loading/utterance.py | Utterance model bootstrap |
batchalign/inference/utseg.py | Worker-side utseg dispatch (typed assignments or Stanza trees) |
batchalign/models/utterance/evidence.py | Closed model-action and per-word evidence states |
crates/batchalign-types/src/worker_v2/utseg_evidence.rs | Canonical Rust IPC evidence types |
crates/batchalign/src/utseg_evidence.rs | Versioned pre/post-CHAT experiment artifacts |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
%wor Tier Specification
Status: Current Last updated: 2026-08-31 00:03 EDT
How main tier words map to the %wor (word-level timing) dependent tier.
Overview
The %wor tier is a flat list of words, each optionally paired with a timing bullet. It mirrors the main tier’s spoken word slots in the same order, providing word-level audio timestamps. Unlike the main tier, %wor never contains groups, annotations, replacements, events, pauses, or any nested structure.
*CHI: I want cookies .
%wor: I 1000_1200 want 1200_1400 cookies 1400_1800 .
Correspondence to the Main Tier
%wor is a timing-annotation tier: it records word-level start/end
timestamps for tokens with a known phoneme sequence. It is NOT a structural
1-to-1 mirror of all main-tier content.
Both the forced alignment word extraction (collect_fa_words) and the %wor
generation (generate_wor_tier) walk the main tier AST identically,
applying the same alignability rules (TierDomain::Wor). Any token excluded
by these rules has no %wor slot and receives no timing bullet.
There is no CLAN-level positional indexing into %wor; %wor indices carry
no external semantics beyond tracking which word received which timing.
Internally, current Batchalign alignment groups also retain stable AST-derived
word identifiers. Those identifiers support evidence joins and experiments;
they are not serialized as %wor content. This separation lets the public
tier remain compatible and uncluttered without forcing research code to use
display text or flat position as identity.
What Text Appears in %wor
The %wor tier uses each word’s cleaned_text as display text, the
spoken slot remains the original main-tier word, but the rendered token has
CHAT-specific prosodic markup removed:
| Main tier | cleaned_text (in %wor) | Notes |
|---|---|---|
a::n | an | Lengthening : removed |
hel^lo | hello | Syllable pause ^ removed |
som(e)thing | something | Shortening expanded |
°softer° | softer | CA delimiters removed |
⌈word⌉ | word | Overlap points removed |
&-uh | uh | Category prefix &- stripped (filler, included) |
&+fr | (excluded) | Fragment, excluded from %wor |
&~um | (excluded) | Nonword, excluded from %wor |
xxx | (excluded) | Untranscribed, no phoneme sequence to align |
ice+cream | icecream | Compound marker + removed |
Inclusion Rules
Words INCLUDED in %wor
The %wor tier includes spoken main-tier word tokens:
| Form | Example | In %wor? | cleaned_text |
|---|---|---|---|
| Regular words | want, cookie | Yes | want, cookie |
| Fillers | &-uh, &-um | Yes | uh, um |
| Fragments | &+fr, &+w | No | , |
| Nonwords | &~gaga, &~um | No | , |
| Untranscribed placeholders | xxx, yyy, www | No | , |
| Words with error marks | goed [*] | Yes | goed |
| Words inside retrace groups | <I want> [/] I need | Yes (all 4 words) | I, want, I, need |
| Words inside reformulation groups | <I want> [//] I need | Yes (all 4 words) | I, want, I, need |
| Words inside quotations | +"/. … +". | Yes | word text |
| Words inside phonological groups | [pho] | Yes | word text |
| Words inside special form groups | [sin] | Yes | word text |
Words EXCLUDED from %wor
| Form | Example | Why excluded |
|---|---|---|
| Omitted words | 0is, 0det | Never spoken (WordCategory::Omission) |
| CA-style omissions | (word) in CA mode | Never spoken (WordCategory::CAOmission) |
| Untranscribed placeholders | xxx, yyy, www | No alignable phoneme sequence; CTC alignment cannot produce timings for unknown material |
| Fragments | &+fr, &+w | Incomplete phoneme sequences; FA engine cannot reliably anchor partial phonological material (matches batchalign2 policy) |
| Nonwords | &~gaga, &~um | Interactional/gestural sounds without stable lexical phoneme content (matches batchalign2 policy) |
| Timing tokens | 100_200 | %wor metadata artifacts, not lexical content |
| Empty words | (parser artifacts) | cleaned_text is empty string |
Non-word items that never appear in %wor
These main tier elements are not words and are simply skipped during tree traversal:
- Pauses:
(.),(..),(...),(2.5) - Events / actions:
&=laughs,0 [=! vocalizes] - Internal bullets: timing markers between words
- Linkers:
++,+<,+^, etc. - Postcodes:
[+ text],[+bch] - Tag separators:
,,‡,„ - Utterance-level annotations: language codes
[- spa], etc.
Replacement Words ([: ...])
For words with replacement annotations (original [: replacement]):
The ORIGINAL spoken word appears in %wor, not the replacement. The
replacement does not create a new %wor slot or replace the spoken one.
*CHI: what's is dis [: this] ?
%wor: what's 1000_1200 is 1200_1400 dis 1400_1600 ?
This means %wor follows the spoken surface slot, while %mor continues to
follow the editorial replacement.
Fragment / nonword with replacement
Fragments and nonwords are excluded from %wor even when they carry a
replacement. The replacement matters for %mor, but the original token
category (fragment or nonword) governs %wor membership:
*CHI: &+fr [: friend] is here .
%wor: is 1200_1400 here 1400_1800 .
(fragment excluded regardless of replacement)
Untranscribed placeholders (xxx, yyy, www) are similarly excluded from
%wor even when they carry a replacement:
*CHI: xxx [: something] is here .
%wor: is 1200_1400 here 1400_1800 .
(xxx excluded, no phoneme sequence regardless of replacement)
Omission with replacement
If an omission (0word) has a replacement, the omission is still excluded
(the replacement does not rescue it):
*CHI: 0gonna [: going+to] eat .
(omission, not in %wor regardless of replacement)
Retrace and Reformulation Groups
Retraced and reformulated content (<...> [/], <...> [//], <...> [///],
<...> [/?]) IS included in %wor.
This differs from %mor, where retraced content is excluded. Retrace ancestry
does not change %wor membership: the same spoken-token rule applies both
inside and outside retrace.
- %mor = linguistic/morphological analysis → retraced words are corrected speech, not linguistically intended
- %wor = word-level audio timing → retraced words were phonologically produced and occupy audio time, but they do not receive any special token class promotion or demotion
*CHI: <I want> [/] I need cookie .
%wor: I 100_200 want 200_400 I 500_600 need 600_800 cookie 800_1200 .
Both collect_fa_words() and generate_wor_tier() descend into retrace
content and then apply the same %wor word-membership rules to the leaves.
Timing Bullet Format
Each word may optionally have a timing bullet:
word \u0015start_ms_end_ms\u0015
Where:
\u0015is the Unicode control character U+0015 (NAK), used as the CHAT bullet delimiterstart_msandend_msare unsigned integers representing milliseconds- Words without timing simply appear without a following bullet
Example raw encoding:
%wor: hello \u00150_500\u0015 world \u0015500_1000\u0015 .
Words CAN lack timing bullets, this means timing is unknown, NOT an error.
What %wor cannot preserve
A %wor bullet is only a start/end pair. It cannot say whether a boundary was
measured by an engine, copied from an older transcript, derived from a neighbor,
or adjusted by a repair pass. It also cannot carry an aligner’s per-word model
score. Consequently, reusing an existing %wor tier is observable as
wor_reuse, but the provenance of the run that originally created its bullets
cannot be reconstructed from CHAT alone.
For research and adjudication runs, align --debug-dir DIR writes a versioned
<stem>_fa_evidence.json sidecar. Version 0.3.0 writes schema 2; version 0.4.0
writes schema 3. Both contain stable word IDs,
group cache keys and evidence sources, pre-injection timings, full start/end
origin chains, Wave2Vec-family model scores where the engine supplies them,
and the exact typed decisions that later clamped or removed timing. Schema 3
also records stable utterance ordinals beside input-line coordinates for
numeric monotonicity decisions, so header insertion cannot silently attach a
decision to the wrong final utterance.
Nested input identities receive a short digest suffix after the basename so
two corpus branches containing the same filename retain distinct sidecars.
The score is model evidence, not a calibrated boundary-correctness probability.
Neither schema contains final per-word post-processing results, so the
sidecar and output CHAT are complementary rather than interchangeable.
Tier-Level Structure
A %wor tier has:
%wor:\t[- lang_code] word1 [bullet1] word2 [bullet2] ... terminator
| Component | Required | Notes |
|---|---|---|
| Language code | No | Inherited from main tier’s [- code] |
| Words | Yes | Flat list of cleaned_text values |
| Timing bullets | No | Per-word, optional |
| Terminator | Yes | Same as main tier (., ?, !, +..., etc.) |
There is no tier-level %wor bullet. Chatter 0.17 removed that redundant
location because the only timing observations owned by %wor are the inline
word bullets. An utterance span belongs to the main tier; when it is safely
derivable from complete word timing, it is the minimum-start/maximum-end hull
of those inline bullets.
Main-tier bullets after utterance splitting
When CHAT-text utseg splits an utterance that already has a %wor tier, BA3
first asks Chatter to bind the pair under the named word-membership policy.
Which main-tier words hold a %wor slot is Chatter’s own
WorSlotMembershipPolicy (FilteredLexicalV1), asked per word through
WorSlotMembershipPolicy::admits rather than restated beside the splitter’s
walk, so the splitter and the timing binding cannot come to disagree about the
count they are comparing. A replaced word is admitted by its ORIGINAL, as the
projection admits it.
Equal counts admit lexical corroboration; only canonical token-for-token
correspondence admits partitioning. Thus, a same-count main-tier edit cannot
silently give an old word’s timing to a different child. If every retained
child then has positive timing for every one of its corroborated %wor words,
the split is in the complete per-child timing state: each child main tier
receives the minimum-start/maximum-end hull of its own word bullets.
If %wor is absent, count-drifted, lexically uncorroborated, empty for a
retained child, or has even one untimed or non-positive word interval, BA3 does
not mix exact child hulls with guessed spans. Count or lexical drift drops the
stale %wor tier entirely. Incomplete timing after safe partitioning keeps the
partitioned word bullets but selects the parent-only main-tier timing state, and
in that state no child receives a main-tier bullet.
The parent bullet is not carried onto one of them, because it does not measure
any of them. It measures the whole parent: its start is where the first child
began and its end is where the last one finished, and nothing observed the
boundary in between. Giving it to the last child would say that child began when
the parent did, a time nobody measured and one the earlier children are the
evidence against. Until 2026-09-16 BA3 did exactly that, so a split utterance
with no usable %wor produced a final child whose span silently claimed the
whole parent’s duration.
The one case where the parent bullet still travels is a split that kept a single child, which holds the parent’s whole content and therefore genuinely has the parent’s start and end. That happens when the assignment vector names more groups than there are words to fill them, which is separately reported as a misalignment bug.
The implementation follows Chatter’s explicit state transitions:
WorTimingBinding::CountMatched →
WorTimingCorrespondence::Corroborated →
WorTimingSequence::Complete. Only the final state exposes a hull. Child
%wor terminators are copied from their child main tiers after splitting, so
an earlier child cannot incorrectly retain the parent’s question mark or
exclamation mark.
flowchart LR
M["Main-tier timing members"] --> B{"Bind to %wor slots"}
W["%wor timing slots"] --> B
B -->|"equal policy-selected count"| C["WorTimingBinding::CountMatched"]
B -->|"count drift"| D["Drop stale %wor"]
C --> K{"Canonical token correspondence"}
K -->|"token-for-token match"| R["WorTimingCorrespondence::Corroborated"]
K -->|"lexical drift"| D
R --> T{"Every retained child has<br/>complete positive timings"}
T -->|"yes"| Q["WorTimingSequence::Complete"]
T -->|"no"| P["SplitMainTimingEvidence::ParentOnly"]
Q --> H["Complete child hulls"]
H --> O["Child main-tier bullets"]
P --> L["No child bullet: the parent span<br/>measures none of them"]
This policy concerns timing projection after a boundary has already been chosen. It neither selects utterance boundaries nor improves the lexical or speaker evidence received from ASR and diarization.
Generation Pipeline
- Forced alignment engines extract
%worword slots from the main tier AST viacollect_fa_words() - The FA model processes the audio and returns per-word
[start_ms, end_ms]pairs (ornullfor unaligned words) - Timings are injected back into the AST via
inject_timings_for_utterance(), stored on each word’stiming_alignmentfield - Post-processing (
postprocess_utterance_timings) heals small gaps between words unless--pauseswas given (WordGapHealing), and conditionally clamps word timings to the utterance bullet range. Clamping only applies when BOTH conditions hold: the bullet isBulletSource::Authoritative(not a runtime UTR hint) AND a%wortier already exists (indicating this is a re-alignment, not a first-time run). On first-time alignment, e.g., aftertranscribe+utseg: no clamping occurs, because the utterance bullet came from narrow ASR-derived timestamps that may not cover the full speech span. See Word timing clamping policy for the full rationale. Version 0.4.0’s experimental--existing-wor-boundaries rebuild-from-evidencepolicy disables this prior-boundary clamp and rebuilds each affected main bullet from the admitted word hull. It is a projection choice, not a raw-evidence cache-key dimension, so cache-required experiments can compare the two policies without another model run. Pre-grouping%worrefresh always uses compatibility preservation; rebuilding there would change audio windows and invalidate the controlled comparison. MainTier::generate_wor_tier()walks the AST one final time, collecting each spoken word slot’scleaned_textandtiming_alignmentinto a flatWorTier- The
WorTieris serialized viaWriteChatinto the%wor:\t...line
Steps 1 and 5 both use the same %wor membership rules (TierDomain::Wor),
guaranteeing identical traversal order. The %wor word count equals the
number of Wor-domain words (regular words and fillers), NOT a count of
all main-tier tokens. Fragments, nonwords, and untranscribed placeholders
are not counted.
Comparison with %mor Domain
| Aspect | %wor | %mor |
|---|---|---|
Fillers (&-uh) | Included | Excluded |
Nonwords (&~gaga) | Excluded | Excluded |
Fragments (&+fr) | Excluded | Excluded |
Untranscribed (xxx, yyy, www) | Excluded | Excluded |
Retraced groups (<...> [/]) | Included | Excluded |
Replacement (word [: repl]) | Original spoken word | Replacement text |
| Regular words | Included | Included |
Omissions (0word) | Excluded | Excluded |
Tag separators (,, „, ‡) | Included | Included (as cm|cm, etc.) |
Source Code References
- Content walker:
talkbank-model/src/alignment/helpers/walk/,walk_words(),walk_words_mut(),WordItem,WordItemMut. Centralizes recursive traversal ofUtteranceContentandBracketedItem; used by %wor generation, FA extraction, FA injection, and FA postprocessing. - Alignability rules:
talkbank-model/src/alignment/helpers/rules.rs,counts_for_tier(),should_skip_group(),should_align_replaced_word_in_pho_sin() - %wor tier model:
talkbank-model/src/model/dependent_tier/wor.rs,WorWord,WorTier, serialization - %wor slot membership:
talkbank-model/src/alignment/,WorSlotMembershipPolicyand itsadmits()method, public precisely so a per-content-item count in an utterance splitter can ask it instead of spelling the rule out again - %wor generation from AST:
talkbank-model/src/model/content/main_tier.rs,generate_wor_tier(),collect_wor_items_content()(useswalk_words) - FA word extraction:
crates/batchalign/src/chat_ops/fa/extraction.rs,collect_fa_words()(useswalk_words) - Timing injection:
crates/batchalign/src/chat_ops/fa/injection.rs,inject_timings_for_utterance()(useswalk_words_mut) - Timing postprocessing:
crates/batchalign/src/chat_ops/fa/postprocess.rs,postprocess_utterance_timings()(uses bothwalk_wordsandwalk_words_mut) - Word categories:
talkbank-model/src/model/content/word/category.rs,WordCategoryenum - Untranscribed status:
talkbank-model/src/model/content/word/untranscribed.rs,UntranscribedStatusenum - Tier domains:
talkbank-model/src/alignment/helpers/domain.rs,TierDomainenum
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
TextGrid Format and Conversion
Status: Current Last updated: 2026-05-21 08:43 EDT
What is TextGrid?
TextGrid is a file format used by Praat, a popular tool for phonetic analysis and speech research.
Purpose
TextGrid files allow researchers to:
- Visualize waveforms and spectrograms alongside transcriptions
- Measure acoustic properties (formants, pitch, duration, etc.)
- Annotate speech at multiple levels (word, phone, syllable, etc.)
- Align transcriptions with audio for detailed phonetic analysis
Structure
A TextGrid consists of tiers (one per speaker or annotation layer), where each tier contains intervals:
Interval {
xmin: 1.000 # Start time (seconds)
xmax: 1.200 # End time (seconds)
text: "hello" # Label text
}
Example TextGrid with two speakers:
File type = "ooTextFile"
Object class = "TextGrid"
xmin = 0.0
xmax = 10.5
tiers? <exists>
size = 2
item [1]:
class = "IntervalTier"
name = "CHI"
xmin = 0.0
xmax = 10.5
intervals: size = 5
intervals [1]:
xmin = 0.0
xmax = 1.2
text = "hello"
intervals [2]:
xmin = 1.2
xmax = 1.8
text = "world"
...
item [2]:
class = "IntervalTier"
name = "MOT"
xmin = 0.0
xmax = 10.5
intervals: size = 3
...
Both long (the example above) and short TextGrid formats are supported by the parser.
TextGrid Conversion in talkbank-tools
TextGrid ↔ CHAT conversion is implemented in Rust, inside the
talkbank-clan crate, and exposed through the chatter CLI.
Entry points
| Direction | Rust function | Location |
|---|---|---|
| TextGrid → CHAT | praat_to_chat(content) and praat_to_chat_with_options(...) | crates/talkbank-clan/src/converters/praat2chat.rs:199, :204 |
| CHAT → TextGrid | chat_to_praat(chat) | crates/talkbank-clan/src/converters/praat2chat.rs:292 |
Both functions operate on the typed ChatFile AST and return /
accept TextGrid strings; no intermediate Python layer is involved.
CLI
The conversions are wired into chatter clan as the
praat2chat and chat2praat subcommands (dispatch in
crates/talkbank-cli/src/commands/clan/converters.rs):
# Convert a TextGrid file to CHAT
chatter clan praat2chat input.TextGrid > output.cha
# Convert a CHAT file to TextGrid
chatter clan chat2praat input.cha > output.TextGrid
Programmatic use (Rust)
use talkbank_clan::converters::praat2chat::{praat_to_chat, chat_to_praat};
let chat_file = praat_to_chat(textgrid_content)?;
let textgrid_text = chat_to_praat(&chat_file)?;
Dependencies
talkbank-clancrate: owns the TextGrid parser, serializer, and converters.- No Python runtime dependency: the previous Python
implementation (
batchalign/formats/textgrid/generator.pyand thebatchalign_core.extract_timed_tiersPyO3 binding) was retired when the converter moved into Rust. Thepraatiopackage still appears inpyproject.tomlfor unrelated Python tooling, but the TextGrid pipeline no longer routes through it.
Reference fixtures
A canonical short-format example lives at
crates/talkbank-clan/tests/fixtures/sample.TextGrid and is used by
the round-trip unit tests in praat2chat.rs (see the
#[test] block starting at
crates/talkbank-clan/src/converters/praat2chat.rs:415::praat_to_chat_basic).
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Media Conversion
Status: Current Last updated: 2026-09-10 01:43 EDT
Overview
Batchalign commands that process audio (align, transcribe, opensmile,
avqi, benchmark) must resolve a media file for each input. Depending on the
command, Rust then either prepares typed PCM artifacts for worker-protocol V2
execution or passes through a normalized media path to a provider-specific
engine. Container formats that downstream audio libraries cannot read,
primarily MP4: must first be converted to WAV via ffmpeg.
This conversion is automatic, cached, and transparent to the user.
Formats
| Extension | Can soundfile read? | Conversion needed? |
|---|---|---|
.wav | Yes | No |
.mp3 | Yes | No |
.flac | Yes | No |
.ogg | Yes | No |
.mp4 | No | Yes |
.m4a | No | Yes |
.webm | No | Yes |
.wma | No | Yes |
The canonical list of forced-conversion extensions is defined in
crates/batchalign/src/ensure_wav.rs::FORCED_CONVERSION.
Align Pipeline End-to-End
The align command has the most complex media handling. Here is the
complete pipeline, from CLI invocation to output CHAT, showing where
media resolution and conversion fit in.
batchalign3 [--server http://<your-server>:8001] align input/ output/ --lang eng
│
├─ CLI: discover .cha files in input/ (sorted largest-first)
├─ CLI: detect dispatch mode
│ paths_mode / execution-host local: audio sits alongside .cha files
│ content mode: .cha text POSTed, server resolves media from its own view
│
├─ Server: POST /jobs/submit → create job (Queued → Running)
│
│ ┌──── For each .cha file (sequential, each has its own audio) ────┐
│ │ │
│ │ 1. PARSE │
│ │ parse_lenient() → ChatFile AST │
│ │ pre-validate (MainTierValid) │
│ │ │
│ │ 2. MEDIA RESOLUTION │
│ │ paths_mode: │
│ │ look alongside .cha for matching stem with known extensions │
│ │ content mode / shared-fs remap: │
│ │ trust server-visible local paths only │
│ │ source_dir when the server shares that filesystem │
│ │ or local media_mappings / explicit --media-dir │
│ │ │
│ │ 3. MEDIA CONVERSION (ensure_wav) ◄── THIS STEP │
│ │ .wav/.mp3/.flac/.ogg → pass through unchanged │
│ │ .mp4/.m4a/.webm/.wma → ffmpeg convert to WAV, cache result │
│ │ fingerprint: full-file BLAKE3 + versioned recipe │
│ │ cache dir: platform data_dir/batchalign3/media_cache/ │
│ │ file lock: per-fingerprint .lock prevents concurrent ffmpeg │
│ │ output: 16kHz mono PCM_S16LE WAV │
│ │ │
│ │ 4. AUDIO IDENTITY │
│ │ compute_audio_identity(path, mtime, size) │
│ │ used as cache key component for FA results │
│ │ │
│ │ 5. AUDIO DURATION PROBE (optional) │
│ │ ffprobe → total_audio_ms │
│ │ used for proportional estimation of untimed utterances │
│ │ │
│ │ 6. GROUP UTTERANCES │
│ │ split into ~20s time windows (Whisper) or ~15s (Wave2Vec) │
│ │ │
│ │ 7. CACHE LOOKUP │
│ │ BLAKE3(words + audio_identity + time_window + engine) │
│ │ hits → skip worker IPC │
│ │ │
│ │ 8. FA INFERENCE (cache misses only) │
│ │ checkout worker from pool │
│ │ execute_v2(task="fa", prepared_audio + prepared_text) │
│ │ Python reads prepared artifacts → model inference │
│ │ returns raw timings │
│ │ │
│ │ 9. DP ALIGNMENT │
│ │ Hirschberg align model tokens → transcript words │
│ │ convert chunk-relative → file-absolute milliseconds │
│ │ │
│ │ 10. POST-PROCESSING │
│ │ inject timings → chain word ends → update bullets │
│ │ generate %wor tier → monotonicity check (E362) │
│ │ same-speaker overlap enforcement (E704) │
│ │ │
│ │ 11. SERIALIZE │
│ │ validate → to_chat_string() → write output .cha │
│ │ │
│ └───────────────────────────────────────────────────────────────────┘
│
└─ CLI: poll /jobs/{id}/results → write output files
ensure_wav: Conversion Cache
Module: crates/batchalign/src/ensure_wav.rs
Implements content-fingerprinted WAV conversion with file-locking and atomic writes.
Algorithm
- Check extension: if
.wav/.mp3/.flac/.ogg, return unchanged. - Check ffmpeg: if not on PATH, return a clear error with install hint.
- Fingerprint: stream every source byte through BLAKE3 and retain its full
digest in the
whole-pcm16-mono16k-strict-v3recipe namespace. This reads the complete file with bounded memory; same-size edits in the middle of a recording cannot reuse the old key. Segment keys additionally bind the requested time window in their own strict-recipe namespace. - Cache lookup: check the media cache directory for
{fingerprint}.wav. If it exists, return immediately (cache hit). - Lock: acquire exclusive
fs2file lock on{fingerprint}.wav.lockto prevent concurrent ffmpeg invocations for the same source file. This is important for parallel FA processing where multiple groups reference the same audio. - Re-check: another task may have completed conversion while we waited.
- Convert:
ffmpeg -y -nostdin -v error -xerror -i source -acodec pcm_s16le -ar 16000 -ac 1 tmp.wav. The locked slot becomes a produced slot only after its own temporary path passes strict conversion. - Atomic rename: publish the produced slot while retaining its lock; release the lock only after the rename succeeds.
ffmpeg Arguments
| Flag | Purpose |
|---|---|
-y | Overwrite output without asking |
-nostdin | Prevent an unattended conversion from consuming terminal input |
-v error | Emit error diagnostics, which prevent output admission |
-xerror | Stop decoding at the first error |
-i source | Input file (mp4, m4a, etc.) |
-acodec pcm_s16le | 16-bit signed PCM (what soundfile reads natively) |
-ar 16000 | 16 kHz sample rate (FA/ASR model input rate) |
-ac 1 | Mono (models expect single channel) |
Cache Management
# Default cache location
ls ~/Library/Application\\ Support/batchalign3/media_cache/
# Or relocate it for isolated runs
export BATCHALIGN_MEDIA_CACHE_DIR=/tmp/ba-media-cache
# Inspect or clear both analysis + media caches
batchalign3 cache stats
batchalign3 cache clear --yes
Where ensure_wav Is Called
ensure_wav is called in four dispatch paths, always after media
resolution and before the audio path is passed to Python workers:
| Dispatch Path | File | Purpose |
|---|---|---|
| FA (align) | runner/dispatch/fa_pipeline.rs | Before audio identity + FA inference |
| Transcribe | runner/dispatch/transcribe_pipeline.rs | Before ASR inference |
| Benchmark | runner/dispatch/benchmark_pipeline.rs:process_one_benchmark_file | Before Rust benchmark orchestration dispatches ASR |
| Media analysis | runner/dispatch/media_analysis_v2.rs | Before openSMILE/AVQI prepared-audio execution |
Error Handling
Whole-file and segment conversions use strict ffmpeg decoding (-xerror),
with error-level diagnostics. A zero exit status accompanied by decoding
errors is also rejected. Partial output is removed before returning the
conversion error; it cannot be published as a successful new conversion.
The strict conversion recipe uses a new cache namespace, preserving old
entries without accepting them as strict-recipe hits. This does not certify
preexisting cache bytes or media formats passed through without conversion.
If conversion fails, the file is marked with a clear error:
Media conversion failed for ACWT01a.cha: ffmpeg not found in PATH.
Hint: install ffmpeg (https://ffmpeg.org/download.html) or convert
your input audio to .wav beforehand.
or:
Media conversion failed for example.cha: ffmpeg conversion failed
for /path/to/media/example.mp4: [stderr]
The job continues processing remaining files, one conversion failure does not abort the entire job.
Media Resolution
Before conversion can happen, the server must find the audio file. Resolution depends on the dispatch mode:
paths_mode / execution-host local
Audio files sit alongside the .cha files in the input directory. The
server looks for a file with the same stem and a known media extension:
input/ACWT01a.cha → input/ACWT01a.mp4 (or .wav, .mp3, etc.)
shared-filesystem server mode (--server for audio commands)
The CLI no longer asks the server to infer remote media from client-specific
path mappings. For audio commands, explicit --server submits filesystem paths
via paths_mode:
source_paths: absolute input paths the server must be able to readoutput_paths: absolute output paths the server must be able to write
This means the clean operational model is:
- run the CLI on the execution host itself, or
- use a standardized shared mount layout so the server sees the same paths
For direct HTTP content-mode submissions, Batchalign only trusts server-visible
local paths such as source_dir, local media_mappings, or an explicit
--media-dir. The important rule is that the mapping is local to the execution
host, not a way to dereference an arbitrary remote client’s private directory
layout.
MP4 Media on Network Volumes
Total: 16,739 MP4 files across all volumes.
| Volume | MP4 | MP3 | WAV |
|---|---|---|---|
| CHILDES | 7,988 | 20,924 | 11,042 |
| aphasia | 2,973 | 3,140 | 601 |
| ca | 1,801 | 4,696 | 4,139 |
| phon | 1,437 | 9,312 | 9,018 |
| fluency | 1,217 | 1,124 | 58 |
| class | 438 | 26 | 19 |
| tbi | 262 | 145 | 149 |
| rhd | 198 | 42 | 51 |
| asd | 101 | 47 | 37 |
| slabank | 83 | 5,478 | 3,649 |
| open | 82 | 0 | 0 |
| homebank | 65 | 2,320 | 22,455 |
| psychosis | 36 | 979 | 479 |
| samtale | 20 | 73 | 72 |
| dementia | 15 | 6,117 | 2,456 |
| psyling | 13 | 0 | 0 |
| biling | 0 | 315 | 228 |
| motor | 0 | 0 | 0 |
Benchmarking Considerations
- First run on MP4 files: includes WAV conversion time (~seconds per file depending on duration)
- Subsequent runs: WAV is cached, no conversion overhead
- For fair benchmarks: either use
--override-media-cacheor ensure both old/new runs have the same cache state (warm or cold) - For %wor-only fixes: conversion cache is irrelevant since the audio doesn’t change. FA cache keys include audio identity, so same audio = same cached alignment.
- Re-alignment scenario: if re-aligning files that already had
alignment, both the FA cache and the media conversion cache will be
warm. Use
--override-media-cachefor cold-start numbers.
Dependencies
- ffmpeg must be on PATH for mp4/m4a/webm/wma conversion. Without it, those formats fail with a clear error. WAV/MP3/FLAC/OGG work without ffmpeg.
- ffprobe (bundled with ffmpeg) is used for audio duration probing in the FA pipeline. Optional, if unavailable, proportional estimation uses a fallback.
- blake3 crate for content fingerprinting.
- fs2 crate for cross-platform file locking.
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Batchalign Command I/O Parity: Local CLI vs Server
Status: Current Last updated: 2026-09-15 09:28 EDT
This document describes the input/output flow for every batchalign command,
comparing direct local CLI execution with the server-based (--server)
dispatch.
For implementation details, treat the command-owned entrypoints under
crates/batchalign/src/commands/ plus the owning orchestrator modules
(compare.rs, benchmark.rs, transcribe/, fa/, and morphosyntax/) as
the source of truth for command semantics. The CLI and runner layers should
stay thin.
For each command: what goes in, where it comes from, what gets written, and whether files are mutated in place.
Global Path Semantics
Most processing commands use shared CommonOpts:
batchalign3 <command> PATH [PATH ...] [-o OUTPUT_DIR] [--file-list FILE] [--in-place]
- Inputs can be files and/or directories.
-o/--outputomitted means direct-write behavior for mutating commands.--file-listis its own input mode: the file’s contents become the input path set. Relative entries resolve against the list file’s directory, directory entries expand like positional directories, duplicates collapse to their first occurrence, and it cannot be combined with positional paths.--in-placeis available on commands that useCommonOpts.
Exceptions:
batchalign3 opensmile INPUT_DIR OUTPUT_DIRbatchalign3 avqi INPUT_DIR OUTPUT_DIR
For legacy readability, the tables below still use IN_DIR/OUT_DIR shorthand.
Interpret IN_DIR as “input path set” in current CLI usage.
When you are adding a new command or changing an existing one, remember the current architecture split:
- CLI args live in
crates/batchalign - released-command identity and top-level orchestration live in
crates/batchalign/src/commands/ - shared command-shape metadata lives in
crates/batchalign/src/command_family.rs - reusable text-batch helper types live in
crates/batchalign/src/text_batch.rs - job lifecycle / queueing live in
crates/batchalign/src/runner/ - output materialization belongs with the owning command or orchestrator module
When output resolves to the same path as input, mutating commands overwrite the
original .cha file (no automatic backup).
For generation commands such as transcribe and benchmark, omitting -o or
passing --in-place still creates new output files next to the source media; it
does not rewrite the media input.
Submission retry semantics
Every command eventually reaches the server via POST /jobs. The CLI’s
BatchalignClient::submit_job retries transient connect/timeout failures
with exponential backoff (3 attempts, starting at 2.0 s with jitter) and
never retries HTTP 4xx/5xx responses: those are deterministic server
rejections (validation error, conflict, payload too large, panic). This
retry contract is shared by every command in the tables below; it is not
command-specific. See
Submit-path retries
for the sequence diagram. The retry is load-bearing because the
local daemon has a brief accept-gap window during job finalization
when a fresh submission can transiently get Connection refused.
Command Reference
1. align
Purpose: Add word-level and utterance-level time alignment to existing CHAT transcripts by running forced alignment against the corresponding audio.
| Aspect | Local CLI | Explicit remote --server |
|---|---|---|
| Input files | .cha files in IN_DIR | .cha content sent over HTTP |
| Input media | Audio referenced by @Media: header, found adjacent to .cha or via --media-dir | Server resolves audio from @Media: against its own visible filesystem (media_roots, media_mappings, or --media-dir) |
| Extensions filter | ["cha"] | Same |
| Output | .cha with %wor timing line, word time fields populated | Same .cha returned to the client, which writes it to the requested output path |
| Mutation | If OUT_DIR = IN_DIR: overwrites original .cha in place. Media files untouched. | Same |
| Key options | --utr-engine, --utr-strategy, --fa-engine, --pauses, --wor/--nowor, --override-media-cache | All passed through typed command options |
What changes in the .cha: %wor tier added/updated with word-level
timestamps. Utterance-level bullet times (\x15start_end\x15) updated.
Existing %mor, %gra tiers preserved. Media file is read but never modified.
Non-matching files: For directory inputs, the current Rust CLI copies
non-.cha files and dummy CHAT files from IN_DIR to OUT_DIR before
submitting matching files, in both explicit-server content mode and local
paths-mode preparation. Input-directory provenance records
(PROVENANCE.json, *.provenance.json) are the one exception and are never
copied through; see Non-Matching File Handling
below.
2. transcribe
Purpose: Create a new CHAT transcript from audio files via ASR.
| Aspect | Local CLI / direct host | Explicit remote --server |
|---|---|---|
| Input files | .mp3, .mp4, .wav files in IN_DIR | Media filenames only; the server must resolve the audio on its own filesystem |
| Extensions filter | ["mp3", "mp4", "wav"] | Same |
| Output | New .cha files (audio extension replaced: foo.wav → foo.cha) | Same .cha files returned to the client, which writes them locally |
| Mutation | Never mutates input. Creates new .cha files in OUT_DIR. Original audio untouched. If OUT_DIR = IN_DIR, the new .cha appears alongside the audio. | Same |
| Key options | --asr-engine, --diarization, --speaker-engine, --wor/--nowor, --lang, --num-speakers, --batch-size | Same |
Current routing note (Rust CLI): when auto_daemon is enabled (the
default), transcribe-family commands try the local daemon first. Explicit
remote --server remains the fallback when that daemon path is disabled or
unavailable.
What gets created: A new .cha file per audio file. Contains @Comment
line with Batchalign version and ASR engine name, @Languages, @Participants,
@ID, and utterance lines with timing. No %mor/%gra tiers.
When both --diarization enabled and --debug-dir PATH are supplied, BA3
also writes <audio-stem>.turns.json under that server-side debug
directory. It contains the exact dedicated turns used for word projection,
uses the same PAR coordinate system as CHAT, and records the selected speaker
backend. An enabled write failure fails the file rather than silently losing
the requested evidence.
Segmentation note: speaker attribution and utterance segmentation are
separate. With Rev.AI, BA3 uses the provider’s speaker labels even without
--diarize, but it still re-segments the transcript into utterances. For
English, Mandarin, and Cantonese, BA3 uses dedicated utterance-boundary models
before CHAT assembly; for other languages, BA3 uses the later utseg stage.
Rev.AI --lang auto note: --lang auto is not always equivalent to
explicit --lang eng, even when the final transcript is treated as English.
There are two internal paths:
- Language ID succeeds before transcript submission: BA3 resolves the
request to English up front, and the Rev request path matches explicit
--lang eng. - Language ID fails or returns an unmapped code: BA3 submits a true Rev auto request. Later stages may still resolve the resulting transcript to English for segmentation and CHAT headers, but provider-side request options differ from explicit English.
This distinction matters because provider punctuation, diarization, and turn boundaries can differ across those two request paths.
Note on hidden BA2 aliases: Hidden compatibility flags such as --diarize,
--whisper, and --rev still parse, but they are migration shims. Public docs
should prefer --diarization and --asr-engine.
3. transcribe_s (transcribe –diarize)
Identical to transcribe above, except the pipeline may run a dedicated
speaker diarization stage when separate diarization is needed. The default is
pyannoteAI Precision-2; --speaker-engine pyannote and nemo select local
alternatives. Output
.cha files have multiple @Participants and speaker-attributed utterances.
Not a separate CLI command, triggered by batchalign3 transcribe --diarize.
When to use: This path is primarily for Whisper-based transcription
(--asr-engine whisper, whisper_hub, whisper_rs), where the ASR engine does
not return speaker labels. For Rev.AI (the default engine), speaker labels are
already present in the ASR response and are always applied without
--diarize, so the normal Rev.AI path already produces speaker-attributed
output. When --diarize is explicitly requested, BA3 ignores Rev’s speaker
projection, projects the dedicated segments onto timed ASR words, splits chunks
at speaker changes, and only then runs utterance segmentation and CHAT
assembly. Utterance segmentation remains a distinct model stage, but it now
receives speaker-safe chunks.
BA2 comparison note: BA2’s CLI/pipeline wiring for transcribe_s is
asr,speaker, and its speaker processor relabels already built utterances from
Pyannote segments. BA3 deliberately improves this integration by applying
speaker evidence before utterance segmentation, where it can preserve a real
speaker boundary instead of assigning one label to a mixed-speaker utterance.
4. morphotag
Purpose: Add morphosyntactic analysis (%mor and %gra tiers) to
existing CHAT transcripts.
| Aspect | Local CLI | Server (--server) |
|---|---|---|
| Input files | .cha files in IN_DIR | .cha content sent as text |
| Extensions filter | ["cha"] | Same |
| Output | .cha with %mor and %gra tiers added/replaced | Same .cha returned as text |
| Mutation | If OUT_DIR = IN_DIR: overwrites original .cha in place. | Same |
| Key options | --retokenize, --skipmultilang, --lexicon <CSV>, --override-media-cache, --merge-abbrev | All passed. Lexicon CSV is read on the client and injected into typed command options before submission. |
What changes in the .cha: %mor tier added/replaced with POS tags and
lemmas. %gra tier added/replaced with dependency relations. Main tier text
may be retokenized if --retokenize is set. Special %mor notation
(@Options: dummy) is auto-detected and preserved.
No media involved. This is a text-only operation.
5. utseg
Purpose: Segment a transcript into utterances using the TalkBank utterance-boundary model pinned for the language.
| Aspect | Local CLI | Server (--server) |
|---|---|---|
| Input files | .cha files in IN_DIR | .cha content sent as text |
| Extensions filter | ["cha"] | Same |
| Output | .cha with utterance boundaries recomputed | Same |
| Mutation | If OUT_DIR = IN_DIR: overwrites original .cha in place. | Same |
| Key options | --lang, --num-speakers, --merge-abbrev, --utseg-fallback-stanza | All passed |
What changes in the .cha: Utterance boundaries (*SPK: lines) are
recomputed. Existing %mor/%gra tiers may be invalidated (would need
re-running morphotag afterwards).
The segmenter is decided from the language alone, before any work is
dispatched. A language the manifest pins a boundary model for takes that
model. Stanza constituency parsing is the legacy fallback and is opt-in:
--utseg-fallback-stanza authorizes it, and without it a language with no
boundary model is refused at planning, naming the language, rather than being
segmented by a substitute nobody asked for.
No media involved.
6. translate
Purpose: Add English translations to non-English transcripts.
| Aspect | Local CLI | Server (--server) |
|---|---|---|
| Input files | .cha files in IN_DIR | .cha content sent as text |
| Extensions filter | ["cha"] | Same |
| Output | .cha with translation tiers | Same |
| Mutation | If OUT_DIR = IN_DIR: overwrites original .cha in place. | Same |
| Key options | --merge-abbrev | Passed |
What changes in the .cha: Translation tier added to each utterance.
No media involved.
7. coref
Purpose: Add coreference annotations to transcripts.
| Aspect | Local CLI | Server (--server) |
|---|---|---|
| Input files | .cha files in IN_DIR | .cha content sent as text |
| Extensions filter | ["cha"] | Same |
| Output | .cha with coreference annotations | Same |
| Mutation | If OUT_DIR = IN_DIR: overwrites original .cha in place. | Same |
| Key options | --merge-abbrev | Passed |
No media involved.
8. compare
Purpose: Compare CHAT transcripts against gold-standard references to compute word error rate (WER and cWER) and inject per-utterance comparison annotations.
| Aspect | Local CLI | Server (--server) |
|---|---|---|
| Input files | .cha files in IN_DIR | .cha content sent as text |
| Gold files | FILE.gold.cha in same directory as FILE.cha | Gold files sent alongside main files, or read from server filesystem in paths mode |
| Extensions filter | ["cha"] | Same |
| Output | .cha with %xsrep / %xsmor tiers + .compare.csv metrics | Same, client writes both files to OUT_DIR |
| Mutation | If OUT_DIR = IN_DIR: overwrites original .cha in place. Gold files are never modified. | Same |
| Key options | --lang, --merge-abbrev, --override-media-cache | All passed through typed command options |
What changes in the .cha: The released output is the projected
gold/reference transcript written at the main file’s output path. BA3
morphotags the main transcript, keeps the gold transcript raw during artifact
construction, projects structurally safe %mor / %gra / %wor information
onto the gold AST, and injects %xsrep / %xsmor on that projected reference
output. %xsrep uses word, +word, and -word; %xsmor mirrors the same
alignment with POS tags such as NOUN, +ADJ, and -?. Those tiers are now
materialized from typed compare-tier models and lowered once at the final CHAT
serialization boundary.
Additional output: A companion .compare.csv file is written alongside each
.cha output with aggregate metrics (WER, cWER, accuracy,
match/insertion/deletion counts, total word counts) plus per-POS rows. See
Benchmarks for how to read WER against cWER, and for the
2026-07-30 accuracy change that makes older WER numbers non-comparable. The CSV is emitted from a typed
metrics table model via the Rust csv crate, not by assembling row strings by
hand.
Gold file convention: For each FILE.cha, the gold companion is
FILE.gold.cha in the same directory. Files ending in .gold.cha are
automatically skipped as inputs (they are companions). If no gold file is
found, the file is marked as failed with an error message.
Pipeline: pair main + gold → morphosyntax on main only → parse raw gold →
BA2-style per-gold-utterance local-window alignment → ComparisonBundle
(main view, gold view, structural word matches, metrics) → materialization. The
command-owned compare layer now models compare as a reference-projection
command rather than “just another per-file mutator.” The semantic unit is the
comparison bundle, not a flat text rewrite.
Output shapes: compare can materialize more than one view of the same
comparison bundle. The released command now emits the projected reference view.
Benchmark-style flows can still materialize a main-annotated view internally.
The projection path works over the CHAT AST: exact structural matches can copy
%mor / %gra / %wor, while partial matches stay conservative instead of
reconstructing tiers from strings. Compare parity is semantic, the workflow
matches BA2 behavior without copying BA2’s string/document shell.
No media involved. This is a text-only operation.
9. benchmark
Purpose: Run ASR and evaluate word accuracy against ground truth.
| Aspect | Local CLI | Explicit remote --server |
|---|---|---|
| Input files | .mp3, .mp4, .wav files in IN_DIR | Media filenames only; the server must resolve the audio on its own filesystem |
| Extensions filter | ["mp3", "mp4", "wav"] | Same |
| Output | New .cha files with ASR output + eval metrics | Same files returned to the client, which writes them locally |
| Mutation | Never mutates input. Creates new .cha files. | Same |
| Key options | --asr-engine, --lang, --num-speakers, --wor/--nowor | All passed |
Same I/O pattern as transcribe: creates new .cha files with audio
extension renamed. Additionally includes evaluation metrics from comparing
ASR output against reference transcripts.
benchmark is a composite command: it runs transcribe first and then calls a
main-annotated compare path internally. It deliberately shares compare-side
internals, but it does not share compare’s released projected-reference
contract. If you are changing benchmark behavior, look at the command-owned
Rust layer first rather than adding logic in CLI dispatch.
10. opensmile
Purpose: Extract acoustic features from audio files.
| Aspect | Local CLI | Explicit remote --server |
|---|---|---|
| Input files | .mp3, .mp4, .wav files in INPUT_DIR | Media filenames only; the server must resolve the audio on its own filesystem |
| Extensions filter | ["mp3", "mp4", "wav"] | Same |
| Output | .opensmile.csv files (NOT .cha) | Same .opensmile.csv files returned to the client, which writes them locally |
| Mutation | Never mutates input. Creates new .opensmile.csv files in OUT_DIR. | Same |
| Key options | --feature-set (eGeMAPSv02, etc.), --lang | All passed |
Special output: This command produces non-CHAT output.
11. avqi
Purpose: Calculate Acoustic Voice Quality Index from paired .cs/.sv
audio files.
| Aspect | Local CLI | Explicit remote --server |
|---|---|---|
| Input files | Paired .cs.* and .sv.* audio files in input paths | Media filenames only; the server must resolve the partner files on its own filesystem |
| Output | .avqi.txt with metrics per file pair | Same .avqi.txt files returned to the client, which writes them locally |
| Mutation | Never mutates input. Creates new .avqi.txt files. | Same |
Current routing note: when auto_daemon is enabled (the default), avqi
prefers the local daemon and ignores explicit --server. Explicit remote
--server is only used when that daemon path is disabled or unavailable.
Current syntax note: opensmile and avqi do not use the shared PATHS /
-o command form. Their CLI syntax is positional:
batchalign3 opensmile INPUT_DIR OUTPUT_DIR
batchalign3 avqi INPUT_DIR OUTPUT_DIR
12. diarize
Purpose: Detect anonymous acoustic speaker turns without transcription or CHAT mutation.
| Aspect | Local CLI | Explicit remote --server |
|---|---|---|
| Input files | .mp3, .mp4, .wav files in input paths | Media filenames only; the server must resolve the audio on its own filesystem |
| Extensions filter | ["mp3", "mp4", "wav"] | Same |
| Output | One <media-stem>.turns.json file per input | Same JSON artifacts returned to the client, which writes them locally |
| Mutation | Never mutates input. Does not read or write CHAT. | Same |
| Key options | --num-speakers, --speaker-engine, --lang | Same |
The output identifies anonymous acoustic tracks (PAR0, PAR1, …), not
semantic CHAT roles. Standalone diarize defaults to local Pyannote and can
explicitly select paid pyannoteAI Precision-2 or local NeMo. Integrated
transcribe --diarization enabled is a separate product path whose dedicated
speaker backend defaults to paid pyannoteAI Precision-2. Both paths share the
same raw/derived speaker-evidence cache.
Summary: Input Sources and Mutation Patterns
Commands that mutate .cha files in place (when OUT_DIR = IN_DIR)
| Command | Input | What changes |
|---|---|---|
| align | Existing .cha + audio | Adds %wor tier, updates bullet times |
| morphotag | Existing .cha | Adds/replaces %mor + %gra tiers |
| utseg | Existing .cha | Recomputes utterance boundaries |
| translate | Existing .cha | Adds translation tier |
| coref | Existing .cha | Adds coreference annotations |
| compare | Existing .cha + gold .cha | Writes projected reference .cha with %xsrep / %xsmor, plus .compare.csv |
These commands read .cha, process the Document, and write the result
back. When OUT_DIR = IN_DIR, the original file is overwritten. The
audio files referenced by align are read but never modified.
Commands that create new files (never mutate input)
| Command | Input | Output created |
|---|---|---|
| transcribe | Audio files (.mp3/.mp4/.wav) | New .cha files |
| benchmark | Audio files | New .cha files with eval metrics |
| diarize | Audio files | New .turns.json files |
| opensmile | Audio files | New .opensmile.csv files |
| avqi | Paired .cs/.sv audio | New .avqi.txt files |
These commands never touch the input files. The output always has a different extension or name than the input.
Server Dispatch: What Crosses the Network
The table below describes explicit remote --server (content mode). On
the local-daemon path the CLI uses paths mode and no file contents cross
the process boundary for any command listed here; see
Submission Modes.
| Direction | Text/CHAT commands (morphotag, compare, …) | Explicit remote audio commands (align, transcribe, opensmile, …) |
|---|---|---|
| Client → Server | Full .cha text (~2KB each) | .cha text for align, or media filenames for media-input commands |
| Server → Client | Processed .cha text | Processed outputs returned over HTTP and written locally by the client |
| Media | No media transfer | Execution host still resolves media from its own visible filesystem |
Audio/video payload bytes do not cross the network in the current explicit server path. The execution host must already have a way to resolve the media.
Submission Modes: paths_mode=true vs paths_mode=false
Every POST /jobs from the CLI carries a paths_mode flag. The two modes
differ in what crosses the HTTP boundary and what the server reads from disk.
Selection rule
paths_mode = allow_paths_mode
&& released_command_supports_paths_mode(command)
&& is_local_server(server_url)
allow_paths_modeis set by the CLI dispatch layer. It istruefor the local-daemon path and for an auto-detected loopback server; it isfalsefor an explicit--server URL, even if that URL happens to resolve to localhost.released_command_supports_paths_mode(command)is the authoritative predicate, defined atcrates/batchalign/src/commands/mod.rsand re-exported frombatchalign::lib.rs. It reads each command’sio_profile(theio_profilefield of the command’sCatalogEntry, declared incrates/batchalign/src/recipe_runner/catalog.rs) and returnstruefor thePathsModeTextandPathsModeAudiovariants.is_local_server(url)atcrates/batchalign/src/cli/dispatch/single.rsreturnstrueonly forlocalhost,127.0.0.1, and::1(loopback). Any non-loopback host is treated as remote, so a--server http://<your-server>:8001submission stays on content mode even when the CLI is running on that server itself.
Paths mode is therefore strictly a local, same-filesystem routing mode. Remote submissions always use content mode.
Per-command paths_mode support
| Command | io_profile | Why |
|---|---|---|
align | PathsModeAudio | Forced alignment needs audio paths the server can open |
transcribe / transcribe_s | PathsModeAudio | ASR runs on server-visible media files |
benchmark | PathsModeAudio | Composite of transcribe + compare over server-visible media |
diarize | PathsModeAudio | Runs speaker inference over server-visible media and writes turns JSON |
avqi | PathsModeAudio | Reads paired .cs/.sv audio directly from the filesystem |
morphotag | PathsModeText | Server-side runner reads CHAT input from source_paths |
utseg | PathsModeText | Same runner as morphotag |
translate | PathsModeText | Same runner as morphotag |
coref | PathsModeText | Same runner as morphotag |
compare | PathsModeText | Reads main .cha + gold .cha pair by path |
opensmile | ContentOnly | Intentional: kept on content mode this round |
Earlier in the project, only the five audio-first commands opted
in. Text-command local submissions were forced onto content mode, which
shipped full CHAT text in the request body. A single 500-file chunk of a
large corpus routinely exceeded max_body_bytes_mb (default 100 MB
previously, now 512 MB) and failed the whole chunk with
HTTP 413 Payload Too Large.
Extending paths-mode eligibility to the text commands (via PathsModeText)
is the structural
fix: on the local path, the request body no longer contains file
contents, so a 413 from a local submission is now unreachable. The 512 MB
default remains as a guard for remote submissions; see the troubleshooting
entry server returned 413: length limit exceeded.
Mode-by-mode detail
| Direction | paths_mode=true (local daemon) | paths_mode=false (remote --server) |
|---|---|---|
| Request body | JobSubmission { paths_mode: true, source_paths, output_paths, before_paths, display_names, ... }: path lists only (~KB) | JobSubmission { paths_mode: false, files: [FilePayload { filename, content }], ... }: full file bytes inline (can be 100+ MB) |
| Server read | Runner opens each source_paths[i] directly via tokio::fs::read_to_string (see crates/batchalign/src/runner/dispatch/infer_batched.rs:112-141) | Runner reads the staged copy from staging_dir/input/<filename> that the POST handler wrote before returning 202 |
| Server write | Runner writes outputs directly to output_paths[i] on the shared filesystem | Runner writes to staging_dir/output/; the CLI polls /jobs/{id}/results/<filename> and saves each file locally |
Body limit (max_body_bytes_mb) | Not a factor, body is a path list | Structural ceiling; operators raise max_body_bytes_mb for large remote payloads |
Where DirectHost fits in | DirectHost is a further optimization used when the CLI falls back to inline in-process execution (no HTTP). It uses the same source_paths / output_paths convention the local daemon uses, just without the HTTP hop | n/a |
Request/response sequence
sequenceDiagram
autonumber
participant CLI as "CLI dispatch<br/>(crates/batchalign/src/cli/dispatch/single.rs)"
participant Gate as "Gate: supports_paths_mode(cmd)<br/>&& is_local_server(url)<br/>(single.rs:105-107)"
participant Paths as "CLI paths builder<br/>(crates/batchalign/src/cli/dispatch/paths.rs)"
participant Content as "CLI content builder<br/>(crates/batchalign/src/cli/dispatch/single.rs:140-201)"
participant Srv as "POST /jobs handler<br/>(crates/batchalign/src/routes/jobs/mod.rs:161)"
participant Runner as "Runner file read<br/>(crates/batchalign/src/runner/dispatch/infer_batched.rs:112)"
CLI->>Gate: choose mode for (command, url)
alt Local daemon + supports_paths_mode
Gate-->>CLI: paths_mode = true
CLI->>Paths: prepare_paths_submission(...)
Paths-->>CLI: JobSubmission { paths_mode=true,<br/>source_paths=[...], output_paths=[...] }
CLI->>Srv: POST /jobs (path lists only, ~KB)
Srv-->>CLI: 202 Accepted { job_id }
Srv->>Runner: dispatch job
Runner->>Runner: read_to_string(source_paths[i])
Runner->>Runner: write outputs to output_paths[i]
Note over CLI,Runner: CLI does not download results;<br/>outputs land directly on shared FS.
else Remote --server (or opensmile)
Gate-->>CLI: paths_mode = false
CLI->>Content: classify_files + FilePayload {filename, content}
Content-->>CLI: JobSubmission { paths_mode=false,<br/>files=[FilePayload{..}], media_files=[..] }
CLI->>Srv: POST /jobs (full file bytes inline)
Srv->>Srv: stage files into staging_dir/input/
Srv-->>CLI: 202 Accepted { job_id }
Srv->>Runner: dispatch job
Runner->>Runner: read staging_dir/input/<filename>
Runner->>Runner: write staging_dir/output/<filename>
CLI->>Srv: GET /jobs/{id}/results/<filename>
Srv-->>CLI: result bytes (per file)
Note over CLI,Srv: CLI writes each downloaded file<br/>into the caller's -o OUT_DIR.
end
Diagram verified against:
crates/batchalign/src/cli/dispatch/single.rs,
crates/batchalign/src/cli/dispatch/paths.rs,
crates/batchalign/src/cli/dispatch/mod.rs,
crates/batchalign/src/recipe_runner/command_spec.rs,
crates/batchalign/src/commands/mod.rs,
crates/batchalign/src/routes/jobs/mod.rs,
crates/batchalign/src/runner/dispatch/infer_batched.rs.
Inline (no-HTTP) fallback: DirectHost
When the CLI cannot reach or start any daemon, it falls back to inline
in-process execution via DirectHost. DirectHost reuses the same
source_paths / output_paths convention that paths_mode=true uses on
the wire, canonical filesystem paths are prepared once and handed to the
direct host. No HTTP hop, no staging directory, no body limit.
Media resolution: Same as local CLI, the direct host resolves
@Media: headers against its filesystem (same machine, same paths).
Non-Matching File Handling
Current Rust CLI (crates/batchalign/src/cli/discover/,
crates/batchalign/src/cli/dispatch/single.rs,
crates/batchalign/src/cli/dispatch/paths.rs):
- Files that don’t match the command’s extensions are copied from
IN_DIRtoOUT_DIRfor directory inputs, with one exception below. - Dummy CHAT files (
@Options: dummy) are copied unchanged and are not submitted for processing. - Matching files are sorted by size descending before submission to reduce straggler effects on long runs.
This means current single-server content mode and direct local paths mode are closer than the older Python split: both preserve non-matching files and both filter dummy CHAT locally.
Exception: input-directory provenance records are never copied through. A
non-matching file named PROVENANCE.json or *.provenance.json is not a
piece of session content, it is a record describing the input directory as
a whole (written by an upstream pipeline stage, for example). Copying it
unchanged into OUT_DIR would leave a provenance record in the output that
describes a different artifact than what the run actually produced there:
exactly the misattribution a provenance record exists to prevent. Every other
non-matching file (media alongside a .cha input, a README, anything else
the command does not consume) still passes through unchanged.
copy_nonmatching (crates/batchalign/src/cli/discover/mod.rs) makes this a
typed decision per file, PassthroughDecision::CopiedThrough(_) or
PassthroughDecision::NotCopied(SkipReason::InputDirectoryRecord), and
returns a PassthroughReport naming every file it withheld; the CLI prints
one line per withheld file so the operator sees what did not travel through
and why.
--merge-abbrev Applies to What the Command WROTE
--merge-abbrev collapses runs of single letters that name a known
abbreviation (F B I becomes FBI). It is a policy applied to a command’s
OUTPUT, so it runs only on a document the command actually modified.
A document the command handed back untouched is written back byte-identical, with the merge NOT applied. That covers every declared pass-through:
| Case | Commands | Written |
|---|---|---|
@Options: dummy | align, morphotag, utseg, translate, coref | Unchanged |
@Options: NoAlign | align | Unchanged |
@Options: CA, where the command declines to analyze | morphotag | Unchanged apart from the decision-tier strip the command declares |
| No analyzable payload collected | utseg, translate | Unchanged |
This is a deliberate narrowing. The merge used to run on the finished TEXT at
the writer, after any output gate, so it applied to those documents too: a
dummy file passed through align --merge-abbrev came back with its letters
merged even though the command had otherwise declined to touch it, and the
bytes written were bytes no gate had judged. The merge is now a transition on
the typed output proof (PostValidated::with_abbreviations_merged), which
re-runs the same judgement over the merged model and returns a pass-through
unchanged.
If the MERGED document fails that judgement, the file fails and nothing is written, and the error says the merge is what broke it rather than blaming the command’s own output. The unmerged document was admissible and is deliberately not written in its place: a merge that breaks a document that had passed is a defect in the merge, and shipping past it would leave nobody looking at it.
The practical consequence for a corpus: running a command with
--merge-abbrev over a directory can no longer alter a file the command
skipped.
compare and benchmark reached that gate on 2026-09-07
Both write their primary CHAT output through the text writer rather than the
CHAT writer seam, and both ran the merge over the finished text and wrote its
result, so nothing had judged what they put on disk. Their two materializers
build a ChatFile and now hand back the same typed proof every other command
uses, with the merge as a transition on it.
Neither command admits its input at any validity level: the gold companion and the ASR transcript are both parsed leniently and neither is checked against a level. So neither is judged against a level on the way out either. What is checked is PRESERVATION: the artifact is compared against a census of the document it descends from, and it is refused only for what the command destroyed, an utterance dropped or a terminator lost that the input had.
A file that fails reports
compare post-validation failed (output must not lose what the input had): After compare: utterance by *PAR lost its terminator
as that file’s error, with the other files in the job unaffected.
Operator-visible consequence: a document that arrives already missing a
terminator is still written, exactly as it always was. Between the gate landing
and 2026-09-07 it was not: the gate ran validate_output, whose terminator
check asks whether the document HAS terminators rather than whether this command
kept them, so a gold companion with one terminator-less non-CA utterance became
a hard failure with no output at all for a fault compare could not have caused.
A reference transcript carrying @Options: CA waives the terminator
requirement in either direction and was never affected.
Parity Status
| Command | I/O parity | Options parity | Direct local path | Notes |
|---|---|---|---|---|
| align | Full | Full | Full | Media resolution differs (local path vs server lookup) but equivalent |
| transcribe | Full | Full | Full | With auto_daemon: true, the CLI tries the local daemon first and only warns when that reroute succeeds; otherwise explicit --server uses remote content mode with server-side media lookup |
| transcribe_s | Full | Full | Full | Triggered by --diarize; follows the same local-daemon-vs-explicit-server rules as transcribe |
| morphotag | Full | Full | Full | Lexicon CSV read on client, sent as parsed dict |
| utseg | Full | Full | Full | |
| translate | Full | Full | Full | |
| coref | Full | Full | Full | |
| benchmark | Full | Full | Full | Prefers the local daemon when auto_daemon is enabled; explicit --server stays the fallback if the daemon path is unavailable |
| diarize | Full | Full | Full | Produces anonymous turns JSON; shares validated raw/derived speaker evidence with integrated transcription |
| opensmile | Full | Full | Full | Special CSV output handling on both sides |
| compare | Full | Full | Full | Gold file resolved locally or server-side |
| avqi | Full (local) | Full (local) | Full | Prefers the local daemon when auto_daemon is enabled; explicit --server stays the fallback if the daemon path is unavailable |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Filesystem Paths Used by batchalign3
Status: Current Last updated: 2026-08-05 22:40 EDT
All current filesystem paths used by the public batchalign3 runtime.
Unless otherwise noted, paths are rooted under
batchalign::types::config::layout::ba_state_dir() (defined at
crates/batchalign/src/types/config/layout.rs:98), which defaults
to ~/.batchalign3 and can be overridden with BATCHALIGN_STATE_DIR.
Configuration
| Path | Purpose | Defined in |
|---|---|---|
~/.batchalign.ini | User config shared with older tooling (for example Rev.AI credentials and default ASR selection) | batchalign/config.py, crates/batchalign/src/cli/setup_cmd.rs |
~/.batchalign3/server.yaml | Server/daemon configuration | crates/batchalign/src/types/config/ (directory module: layout.rs, server.rs, resolve.rs, tests.rs) |
Runtime data
| Path | Purpose | Defined in |
|---|---|---|
~/.batchalign3/logs/ | Structured CLI run logs (run-*.jsonl) and exported log zips | crates/batchalign/src/cli/logs_cmd.rs |
~/.batchalign3/server.pid | Handshake for the main server: the PID and the port it actually bound, written by the server after its bind succeeds | crates/batchalign/src/server_handshake.rs |
~/.batchalign3/sidecar-server.pid | The same, for the transcribe sidecar daemon | crates/batchalign/src/server_handshake.rs |
~/.batchalign3/server.log | stderr log for manual batchalign3 serve start | crates/batchalign/src/cli/serve_cmd.rs |
~/.batchalign3/daemon.json | main auto-daemon state | crates/batchalign/src/cli/daemon.rs |
~/.batchalign3/daemon.lock | main auto-daemon startup lock | crates/batchalign/src/cli/daemon.rs |
~/.batchalign3/daemon.log | main auto-daemon stderr log | crates/batchalign/src/cli/daemon.rs |
~/.batchalign3/sidecar-daemon.json | sidecar-daemon state for transcribe-heavy workloads | crates/batchalign/src/cli/daemon.rs |
~/.batchalign3/sidecar-daemon.lock | sidecar-daemon startup lock | crates/batchalign/src/cli/daemon.rs |
~/.batchalign3/sidecar-daemon.log | sidecar-daemon stderr log | crates/batchalign/src/cli/daemon.rs |
~/.batchalign3/jobs/ | per-job staging directories | crates/batchalign/src/lib.rs, crates/batchalign/src/routes/jobs/mod.rs |
~/.batchalign3/jobs.db | SQLite job persistence database | crates/batchalign/src/db/mod.rs |
Caches
Analysis cache (SQLite)
Utterance-level cache entries for morphosyntax, utterance segmentation, translation, and forced alignment.
The default path comes from dirs::cache_dir() in Rust and intentionally
matches the Python-side platformdirs location.
| Platform example | Path |
|---|---|
| macOS | ~/Library/Caches/batchalign3/cache.db |
| Linux | ~/.cache/batchalign3/cache.db |
Override with BATCHALIGN_ANALYSIS_CACHE_DIR, which relocates the database to
$BATCHALIGN_ANALYSIS_CACHE_DIR/cache.db.
Defined in:
crates/batchalign/src/cache/sqlite.rscrates/batchalign/src/cli/cache_cmd.rs
Media cache
Directory used for cached media artifacts and exposed through
batchalign3 cache stats|clear.
The default path comes from dirs::data_dir().
| Platform example | Path |
|---|---|
| macOS | ~/Library/Application Support/batchalign3/media_cache/ |
| Linux | ~/.local/share/batchalign3/media_cache/ |
Override with BATCHALIGN_MEDIA_CACHE_DIR.
Defined in:
crates/batchalign/src/ensure_wav.rscrates/batchalign/src/cli/cache_cmd.rs
Legacy compatibility note
BA2-era tooling used:
~/.batchalign.ini~/.batchalign/~/.cache/batchalign/~/Library/Application Support/batchalign/media_cache/
The current release intentionally still shares ~/.batchalign.ini, but the
state directory, jobs DB, logs, daemon state, and caches otherwise use the
batchalign3 prefix.
This page last changed: 2026-08-05 (commit 7ae429e7). The whole book last changed: 2026-09-16 (commit 34d249d8).
Overlapping Speech in CHAT
Status: Current Last updated: 2026-05-21 08:39 EDT
Two Encodings for Overlapping Speech
When two speakers talk at the same time, CHAT supports two ways to represent this in the transcript. Both are valid; which one you use depends on your transcription conventions and analysis needs.
&*: Embedded Overlap Marker
The &* marker embeds one speaker’s words inside another speaker’s utterance.
The syntax is &*SPEAKER:word or &*SPEAKER:word_word (underscores join
compound expressions because &* only allows a single token).
*PAR: I went to the store &*INV:mhm and bought some milk . 0_6000
Here, INV said “mhm” while PAR was talking. The &*INV:mhm is placed at
the approximate position in PAR’s text where the overlap occurred.
Properties:
- INV’s backchannel has no timing of its own: it is subsumed by PAR’s bullet.
- INV’s backchannel has no %mor, %gra, or %wor: it is invisible to all dependent tiers and alignment.
- INV’s backchannel cannot be counted as an independent utterance by analysis tools (FREQ, MLU, etc.).
- Multi-word overlaps use underscores:
&*INV:oh_okay_yeah.
Corpus scale: ~35,000 &* markers across ~2,200 files in 8 corpora.
+< with Separate Utterances: Recommended
Each speaker’s words go on their own line. The +< (lazy overlap) linker
marks that the utterance started before the previous one finished:
*PAR: I went to the store and bought some milk . 0_6000
*INV: +< mhm . 3500_4000
Properties:
- INV’s backchannel gets its own timing from the aligner.
- INV’s backchannel can receive its own %mor and %wor tiers.
- INV’s backchannel is a separate utterance, countable by analysis tools.
- PAR’s utterance stays intact, the participant’s thought is one unit.
- Cross-speaker overlap is valid CHAT (E701 only requires non-decreasing start times).
Corpus scale: ~327,000 +< utterances across ~15,600 files in 14 corpora.
Which Should I Use?
For new transcription: Use +< with separate utterances. Each speaker’s
words belong on their own tier. This gives backchannels their own timing,
their own dependent tiers, and makes them countable by analysis tools. The
two-pass overlap-aware alignment strategy is available via
--utr-strategy two-pass; the default --utr-strategy auto currently
falls back to single-pass GlobalUtr until the two-pass algorithm is
validated against more operator corpora (per
crates/batchalign/src/runner/dispatch/utr.rs:94-100).
For existing files with &*: They work fine as-is. The aligner already
handles &* correctly (it is invisible to the DP alignment). No migration is
required. However, backchannels encoded as &* will never get independent
timing, they are invisible to the aligner by design.
Both encodings are valid CHAT. The aligner supports both. Files with &*
and files with +< can coexist in the same corpus.
Summary of tradeoffs:
&* encoding | +< separate utterances | |
|---|---|---|
| Backchannel timing | None (invisible to aligner) | Automatic (two-pass recovery) |
| Backchannel %mor/%wor | None | Yes (own tiers) |
| Countable as utterance | No | Yes |
| Main speaker alignment | Unaffected | Unaffected |
| Readability at density | Poor (3+ &* per line) | Clean |
| Requires migration | No (existing files work) | New convention for new transcripts |
How the Aligner Handles Each Encoding
&* encoding
The content walker skips OtherSpokenEvent (&*) nodes entirely. They do not
participate in UTR word extraction, forced alignment, or %wor generation.
The backchannel words are invisible to the DP reference sequence.
Result: The main speaker’s alignment is unaffected. The backchannel gets no independent timing.
+< encoding
A two-pass UTR strategy is available for +< files. The
algorithm:
- Pass 1: Build the global alignment reference from non-
+<utterances only. Main-speaker words align correctly without backchannel interference. - Pass 2: For each
+<utterance, search the previous utterance’s audio window with adaptive widening to recover the backchannel’s timing. - Fallback: If two-pass timed fewer utterances than the standard global algorithm would have, the global results are used instead. This ensures the strategy is never worse than the original algorithm, important for languages where ASR quality is lower.
Today the strategy is opt-in. Per
crates/batchalign/src/runner/dispatch/utr.rs:94-100, the default
--utr-strategy auto always resolves to GlobalUtr (“Auto always
uses GlobalUtr until the two-pass algorithm is validated on an
operator’s problem files”); the two-pass path is only constructed
when the operator passes --utr-strategy two-pass. Files without
+< use the original single-pass algorithm regardless.
# Default: auto (currently resolves to global single-pass)
batchalign3 align corpus/ -o output/
# Explicit global single-pass
batchalign3 align corpus/ -o output/ --utr-strategy global
# Opt in to the two-pass overlap-aware strategy
batchalign3 align corpus/ -o output/ --utr-strategy two-pass
Multi-Backchannel Example
The &* encoding becomes hard to read with multiple backchannels:
*PAR: but I grew up in Princeton &*INV:oh_okay_yeah and came to
graduate school &*INV:mhm at Chapel_Hill &*INV:oh in ninety
one &*INV:mhm or maybe ninety two . 104745_118254
The +< encoding is cleaner:
*PAR: but I grew up in Princeton and came to graduate school at
Chapel_Hill and in ninety one or maybe ninety two . 104745_118254
*INV: +< oh okay yeah .
*INV: +< mhm .
*INV: +< oh .
*INV: +< mhm .
Each backchannel is a separate conversational act on its own line. PAR’s narrative is one unbroken utterance.
CHAT Validation Rules
Cross-speaker overlapping bullets are valid:
- E701 (global timeline): Start times must be non-decreasing. PAR at 104745 ≤ INV at any time after that. Passes.
- E704 (same-speaker self-overlap): Only prohibits the same speaker overlapping themselves beyond 500ms. Different speakers can overlap freely.
- E362 (monotonicity): Same as E701. Passes.
References
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Benchmarks
Status: Current Last updated: 2026-09-07 07:04 EDT
Batchalign provides a benchmark command to evaluate ASR accuracy against
gold transcripts. It transcribes each audio file, compares the result
against the corresponding gold .cha transcript, and reports word error
rate (WER).
What is WER?
Word Error Rate measures how many words the ASR system got wrong compared to a human-verified reference transcript. Lower is better, 0% means perfect, 100% means every word was wrong or missing.
WER = (insertions + deletions) / total_gold_words
cWER = (order-insensitive edits) / total_gold_words
Accuracy = 1.0 − WER (clamped to [0, 1])
WER and cWER: read them as a pair
cwer counts the same errors as wer except that a word recognised
correctly but placed in the wrong position within its utterance cancels,
instead of being charged twice, once as a deletion where it should have been
and once as an insertion where it landed.
| Reading | Means |
|---|---|
cwer well below wer | The words are right and the PLACEMENT is wrong. Look at diarization and the merge stage. |
cwer close to wer | The words themselves are wrong. Look at the ASR engine. |
Plain WER conflates those two failure modes, and for diarized output the distinction is most of the diagnostic value.
Accuracy changed on 2026-07-30, and older numbers are not comparable. Until then, compare aligned only inside a bag-of-words window chosen per gold utterance and silently discarded any hypothesis word outside it. Those words were counted in no category, so reported WER was systematically LOWER than the truth by an amount that varied with how ragged the transcript was. The two-phase compare aligns each main utterance’s full token span, so those words are now charged as insertions. Expect WER on the same file to RISE relative to a pre-2026-07-30 run; the new number is the honest one.
What the gold claims to cover
Compare maps each gold utterance onto a main utterance, and some main utterances are left over. Whether THEIR words count as errors is not something compare can work out from the two files, so the caller states it:
| Coverage | Meaning | Leftover main words |
|---|---|---|
Complete | The gold is a full reference for this recording | Charged as insertions |
Partial | The gold covers only a slice, one timepoint, one speaker | Not scored |
There is deliberately no default. Getting this wrong moves the headline WER in a direction nobody would notice, so the compiler makes the caller choose.
The compare and benchmark commands pass Complete, because a FILE.gold.cha
companion is a re-transcription of the same recording. A measurement built on a
sampled or single-speaker reference should pass Partial, and should say so
when it reports its numbers: a Partial WER describes the covered part only.
Word normalization is applied before comparison: compound splitting
(airplane → air plane), contraction expansion (he's → he is),
filler normalization (all fillers → um), abbreviation expansion
(FBI → F B I), and proper name replacement (all names → name).
The normalization logic lives in crates/batchalign-transform/src/wer_conform.rs.
Pipeline
benchmark is a two-stage composition, not a simple diff:
flowchart LR
audio["Audio file\n(.mp3/.wav/.mp4)"] --> transcribe["Stage 1: Transcribe\n(ASR → CHAT)"]
gold["Gold .cha file\n(same directory,\nsame stem)"] --> compare
transcribe --> morphotag["Morphotag\n(Stanza %mor/%gra)"]
morphotag --> compare["Stage 2: Compare\n(DP align → WER)"]
compare --> output_cha["Output .cha\n(with %xsrep / %xsmor tiers)"]
compare --> output_csv["Output .compare.csv\n(WER + cWER metrics)"]
Stage 1, Transcribe: Runs the full ASR pipeline (process_transcribe())
to produce a CHAT transcript from the audio. This includes all standard
ASR post-processing (compound merging, number expansion, disfluency
detection).
Stage 2, Compare: Runs morphosyntax on the transcribed CHAT (to generate
%mor/%gra), then DP-aligns the transcribed words against the gold
transcript words (Hirschberg case-insensitive alignment). Produces %xsrep /
%xsmor tiers and CSV metrics.
Gold File Discovery
For each audio file, benchmark looks for a .cha file with the same
basename in the same directory:
| Audio file | Expected gold file |
|---|---|
interview.wav | interview.cha |
sample.mp3 | sample.cha |
/data/recording.mp4 | /data/recording.cha |
Important: Benchmark resolves symlinks before looking for the gold file.
If you symlink audio.mp3 → /real/path/audio.mp3, benchmark will look for
/real/path/audio.cha, not the symlink’s directory. Copy audio files
into the working directory rather than symlinking them.
If no gold file is found, the file is skipped with an InputMissing error.
Input Requirements
The gold .cha file must be parseable by batchalign3’s tree-sitter grammar.
Files with parse errors (tree-sitter ERROR nodes) will fail at the
morphotag pre-validation gate with:
morphotag pre-validation failed: [L0] File has N parse error(s); input may be malformed
Note that chatter validate (from talkbank-tools) and batchalign3 use the
same tree-sitter grammar, but batchalign3’s pre-validation is stricter,
it rejects files with any parse errors at L0, whereas chatter validate
may report these as warnings.
Example
batchalign3 benchmark ~/ba_data/input -o ~/ba_data/output --lang eng
Options
| Option | Meaning |
|---|---|
--asr-engine NAME | ASR engine (default: rev). --help prints the list, which is generated from the engine set. |
--asr-engine-custom NAME | Deprecated alias for --asr-engine, still honoured, hidden from help |
--lang CODE | 3-letter ISO language code (default: eng) |
--num-speakers N | Number of speakers (default: 2) |
--wor / --nowor | Toggle %wor tier output |
--merge-abbrev | Merge abbreviations in output |
--bank NAME | Legacy remote media selector (unsupported in the current CLI; pass filesystem paths instead) |
--subdir PATH | Legacy remote media selector subdirectory (unsupported in the current CLI) |
Output
Two files are produced per input audio file:
1. Hypothesis CHAT file ({stem}.cha)
A full CHAT transcript with ASR results plus %xsrep / %xsmor comparison
tiers. Each utterance gets a %xsrep dependent tier showing the word
alignment and a matching %xsmor tier showing the POS alignment:
*PAR: hello big world today .
%xsrep: hello [+ main]big world [- gold]today .
%xsmor: INTJ +ADJ NOUN -? PUNCT
- Unmarked words = match (in both hypothesis and gold)
[+ main]= insertion (in hypothesis but not gold)[- gold]= deletion (in gold but not hypothesis)
The ? above is a deletion: a gold word the hypothesis does not contain, so
neither transcript tagged it. Matches and insertions carry a real tag even when
the gold companion has no %mor of its own; see
compare: where the part of speech comes from.
2. Metrics CSV file ({stem}.compare.csv)
metric,value
wer,0.2500
accuracy,0.7500
matches,3
insertions,1
deletions,0
total_gold_words,3
total_main_words,4
WER is NOT printed to stdout. The CLI shows only success/failure per file.
To extract WER programmatically, read the .compare.csv from the output
directory.
Language Considerations
The --lang flag affects ASR engine behavior:
- Rev.AI: ISO 639-3 codes are translated to Rev.AI codes. Some languages
(e.g., Hakka
hak) are not supported by Rev.AI, see Language Code Resolution for the mapping table. - Whisper: Uses
pycountryto resolve language names. Unknown codes raiseValueError. - Benchmark does not run utterance segmentation or forced alignment, it only transcribes and compares.
Implementation Details
| Component | File | Key function |
|---|---|---|
| Orchestrator | crates/batchalign/src/benchmark.rs | process_benchmark() (line 44) |
| Per-file dispatcher | crates/batchalign/src/runner/dispatch/benchmark_pipeline.rs | process_one_benchmark_file() (line 185) |
| Gold file resolution | crates/batchalign/src/benchmark.rs | gold_chat_path_for_audio() (line 72) |
| WER computation | crates/batchalign-transform/src/compare/engine.rs | compare() (line 173) |
| Word normalization | crates/batchalign-transform/src/wer_conform.rs | conform_words() (line 96) |
| CSV output | crates/batchalign-transform/src/compare/metrics.rs | format_metrics_csv() (line 284) |
| %xsrep / %xsmor injection | crates/batchalign-transform/src/compare/materialize.rs | inject_comparison() (line 266) |
See also
- Command I/O Parity, section 9 for full benchmark dispatch details
- CLI Reference, benchmark entry in the CLI docs
- Language Code Resolution, how
--langmaps to engine codes
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
L2 Morphotag: Per-Word Code-Switching Analysis
Status: Current Last updated: 2026-05-20 10:25 EDT
L2 dispatch is now on by default. After evaluation across 19 language pairs and 17,352
@swords yielded aggregate dispatch rate is high enough (well above 99% across 19 evaluated language pairs), the--experimental-l2-morphotagflag was removed and replaced with--no-l2-morphotag(opt-out for legacyL2|xxxbehavior). This design doc is maintained for implementers; users should read the user guide.
Problem
CHAT transcripts use @s markers for word-level code-switching, a word
spoken in a different language than the utterance’s primary language:
*EVA: was ich jetzt machen möchte ist , dass ich von der
Linguistik ein bisschen umsattele auf (.) film@s studies@s .
Here film and studies are English words in a German utterance, marked
with bare @s (shortcut for the secondary language declared in
@Languages).
Historically, batchalign3 blanked all @s words to L2|xxx in the %mor
tier, discarding morphological information entirely. This document starts
from that original failure mode because it motivated the current design:
%mor: ... adp|auf L2|xxx L2|xxx .
This is the safe conservative choice: the primary language’s Stanza model (German, in this example) produces wrong morphology for foreign words, and presenting that wrong morphology as valid analysis would be worse than admitting ignorance.
But L2|xxx is a loss. The word studies is a perfectly regular English
plural noun. If we could route it to the English Stanza model, we’d get
noun|study-Pl: real, useful morphological analysis.
Scale
Across TalkBank’s 24 data repos:
- 12,450
.chafiles contain@smarkers - Top languages: eng (32K occurrences), spa (7.5K), fra (2.5K), dan (1.8K), ita (1.6K), nan (1.3K), zho (1.2K), deu (1.1K)
- Top repos: childes-other-data (4,225 files), slabank-data (3,517), phon-other-data (1,005), childes-romance-germanic-data (781)
@s Marker Variants
| Form | Meaning | Frequency | Example |
|---|---|---|---|
@s | Bare shortcut, toggles to secondary language from @Languages | ~74% of uses | film@s |
@s:CODE | Explicit language code (ISO 639-3) | ~25% | tienda@s:spa |
@s:CODE+CODE | Multiple languages (code-mixing at sub-word level) | 229 files | ripiado@s:eng+spa |
@s:CODE&CODE | Ambiguous between languages | 290 files | wrap_o@s:eng&cym |
Common Code-Switching Patterns
- Single isolated word:
the tienda@s:spa is close .: one foreign word embedded in primary-language sentence - Contiguous span:
los@s:spa niños@s:spa: multi-word foreign phrase (a noun phrase, in this case) - Utterance-initial:
time@s out@s , kenne ich nicht .: English phrase at start of German utterance - Mixed secondary languages:
ok@s:eng damelo@s:spain a Tzutujil utterance, two different foreign languages - Morphologically integrated:
tagueé@s:eng+spa: English verb root with Spanish past participle morphology
Key Insight: Cross-Linguistic Information from the Primary Model
The primary model’s analysis of @s words is wrong as morphology, but it contains structurally valid cross-linguistic information that can be combined with the secondary model’s language-specific knowledge.
Universal Dependencies is explicitly designed to be cross-linguistic.
The UD dependency relations, UPOS tags, and structural attachments have
the same meaning in all languages by definition. When the German Stanza
model parses she was muy@s:spa nice . and attaches muy as advmod
of nice, it is making a structural claim that is valid regardless
of language: this word is an adverbial modifier of an adjective.
What Each Model Contributes
flowchart LR
subgraph Primary["Primary Model\n(full sentence context)"]
P1["deprel\n(syntactic role)"]
P2["Head index\n(attachment)"]
P3["UPOS\n(structural POS)"]
P4["Dependents\n(what attaches to this word)"]
end
subgraph Secondary["Secondary Model\n(language-specific knowledge)"]
S1["Lemma\n(dictionary form)"]
S2["Morph features\n(gender, number, tense)"]
S3["UPOS\n(lexical POS)"]
end
subgraph Merge["Merged Result"]
M1["POS: primary deprel → constraint,\nvalidated by secondary"]
M2["Lemma: secondary"]
M3["Features: secondary"]
M4["GRA: primary structural parse,\nupgraded from FLAT when POS known"]
end
P1 --> M1
P3 --> M1
S3 --> M1
S1 --> M2
S2 --> M3
P1 --> M4
P2 --> M4
M1 --> M4
Primary Model Outputs and Cross-Linguistic Validity
| Output | Cross-linguistic? | Rationale |
|---|---|---|
| deprel (dependency relation) | Yes | UD relations are language-universal by design. advmod means “adverbial modifier” in every language. |
| Head index | Yes | Syntactic attachment is structural. muy attaching to nice is valid in any language. |
| UPOS (universal POS tag) | Mostly | Defined cross-linguistically. Contextual models derive UPOS partly from position, which is language-independent. Risk: OOV heuristics may misfire (e.g., defaulting to PROPN). |
| Dependents (words attaching to this word) | Yes | If the is det of tienda, then tienda is definitively a noun head. |
| XPOS (language-specific POS) | No | English Penn Treebank tags are meaningless for Spanish words. |
| Lemma | No | Unknown words get identity-lemmatized. |
| Morphological features | No | Gender, number, tense require word-form recognition. |
Deprel → POS Constraint Mapping
The dependency relation alone strongly constrains the part-of-speech. This mapping is a pure function: no ML involved, exhaustively testable:
deprel → POS constraint set
───────────────────────────────────────────
det → {DET} ← unambiguous
amod → {ADJ} ← unambiguous
advmod → {ADV} ← unambiguous
case → {ADP} ← unambiguous
mark → {SCONJ} ← unambiguous
cc → {CCONJ} ← unambiguous
nsubj → {NOUN, PRON, PROPN} ← narrow
obj → {NOUN, PRON, PROPN} ← narrow
iobj → {NOUN, PRON, PROPN} ← narrow
obl → {NOUN, PRON} ← narrow
nmod → {NOUN, PROPN} ← narrow
xcomp → {VERB, ADJ} ← narrow
ccomp → {VERB} ← unambiguous
advcl → {VERB} ← unambiguous
acl → {VERB, ADJ} ← narrow
appos → {NOUN, PROPN} ← narrow
flat → {NOUN, PROPN, ADJ, ADV} ← broadest
root → {VERB, NOUN, ADJ} ← broad
conj → (inherit from conjunct head)
For most UD relations, the constraint set is 1-3 POS tags. Even for the
broadest cases (flat, root), the secondary model’s lexical knowledge
can disambiguate within the small set.
Dependent-Based Evidence
A word’s dependents provide additional POS evidence:
- If a word has a
detdependent → it is a noun (definitively) - If a word has an
nsubjdependent → it is a verb or adjective - If a word has an
advmoddependent → it is a verb, adjective, or adverb - If a word has a
casedependent → it is a noun (in an oblique/prepositional phrase)
Combined with the deprel constraint, this often narrows POS to exactly
one candidate, even when the deprel itself is broad (like flat).
Worked Example
*PAR: she was muy@s:spa nice .
Primary model (English Stanza) produces for muy:
| Field | Value | Cross-lingually valid? |
|---|---|---|
| UPOS | ADJ (wrong as English morphology) | Partly, position suggests ADV |
| deprel | amod or advmod | Yes |
| head | 4 (nice) | Yes |
| lemma | muy (identity) | No |
| feats | : | No |
Deprel constraint: advmod → {ADV}: exactly one candidate.
Secondary model (Spanish Stanza) on isolated word "muy":
- UPOS:
ADV← matches constraint ✓ - lemma:
muy - feats: (none for Spanish adverbs)
Merged result: adv|muy with deprel ADVMOD (upgraded from FLAT).
Without the primary model’s structural information, the secondary model
seeing just "muy" would still likely get ADV (since muy is almost
always an adverb). But for genuinely ambiguous words like bajo (noun
“bass” / adjective “short” / preposition “under” / verb “I descend”),
the deprel constraint from the primary model is decisive.
Architecture
Historical Baseline (L2|xxx blanking)
The pipeline already parses, resolves, and carries per-word language information through every stage. It is discarded only at injection time.
flowchart TD
A["ExtractedWord.lang\n(../chatter/crates/talkbank-transform/src/extract.rs)"] -->|"resolve_word_language()"| B["LanguageResolution\n(../chatter/crates/talkbank-model/validation/word/language/resolve.rs)"]
B --> C["special_forms[i].1\n(MorphosyntaxBatchItem\ncrates/batchalign-transform/src/morphosyntax/payload.rs)"]
C --> D["Grouping by utterance lang\n(crates/batchalign/src/morphosyntax/batch.rs)"]
D --> E["Primary Stanza worker\n(worker dispatch)"]
E --> F["inject_results()\n(crates/batchalign-transform/src/morphosyntax/injection.rs)"]
F -->|"resolved_lang.is_some()"| G["L2|xxx\n(information discarded)"]
style G fill:#f88,stroke:#a00
Default: Structural Merge with Secondary Dispatch
The default pipeline preserves the primary model’s structural analysis
and combines it with the secondary model’s lexical knowledge (pass
--no-l2-morphotag to opt out and return to the legacy L2|xxx
behavior shown above):
flowchart TD
A["Primary model processes\nfull utterance"] --> B["For @s words: extract\ndeprel, head, UPOS,\ndependents"]
B --> C["Infer POS constraint\nfrom deprel\n(deprel_to_pos_constraint)"]
A --> D["Defer L2 blanking\n(keep primary UD output)"]
D --> E["Plan contiguous @s spans\nplus host attachment\n(crates/batchalign-transform/src/morphosyntax/l2/plan.rs)"]
E --> F{"Stanza model\navailable?"}
F -->|"yes"| G["Dispatch spans to\nsecondary Stanza workers"]
F -->|"no"| H["Fall back to L2|xxx"]
G --> I["merge_planned_secondary_span()\nPOS ← deprel constraint ∩ secondary\nlemma ← secondary\nfeatures ← secondary"]
G -->|"dispatch fails"| H
I --> J["Upgrade GRA:\nif primary deprel = FLAT\nand POS is known,\nreplace with correct deprel"]
style I fill:#8f8,stroke:#0a0
style H fill:#ff8,stroke:#aa0
style C fill:#88f,stroke:#00a
The current implementation places deterministic span planning and host
attachment in talkbank-transform; batchalign is only the worker-dispatch
adapter for those planned spans.
Merge Algorithm
For each @s word, the merge operates on three inputs: the primary
model’s structural analysis of that word, the secondary model’s
lexical analysis, and the full secondary UD
sentence so the merge can see compound:prt relations for
phrasal-verb recognition.
flowchart TD
In[["For each @s word at position i"]] --> Ctx{"Secondary UD\ncontext available?"}
Ctx -->|"yes"| P0{"Priority 0:\nis this word a\ncompound:prt\nhead or particle?"}
Ctx -->|"no"| P1
P0 -->|"particle"| Part["Resolve POS = Part\nGRA deprel = compound:prt"]
P0 -->|"head (sec.UPOS=Verb)"| Head["Resolve POS = Verb\n(override primary constraint)"]
P0 -->|"no"| P1{"Priority 1:\ncopula dependent\nand sec.UPOS=Verb?"}
P1 -->|"yes"| Cop["Demote VERB → NOUN/ADJ\nper predicate-nominal rule"]
P1 -->|"no"| P2{"Priority 2:\nsec.UPOS matches\nprimary deprel\nconstraint?"}
P2 -->|"yes"| Agree["Resolve POS = sec.UPOS"]
P2 -->|"no"| P3{"Priority 3:\nsec.UPOS is\nclosed-class?"}
P3 -->|"yes"| Closed["Resolve POS = sec.UPOS\n(function word trusted)"]
P3 -->|"no"| P4{"Priority 4:\nsec.UPOS is\nNOUN or PROPN?"}
P4 -->|"yes"| Noun["Resolve POS = sec.UPOS\n(content noun override)"]
P4 -->|"no"| P5{"Priority 5:\nprimary UPOS in\nconstraint?"}
P5 -->|"yes"| Prim["Resolve POS = primary.UPOS"]
P5 -->|"no"| P6["Priority 6: most likely\nPOS from constraint"]
Part --> Out
Head --> Out
Cop --> Out
Agree --> Out
Closed --> Out
Noun --> Out
Prim --> Out
P6 --> Out
Out[["Override POS in Mor;\ncompute corrected_deprel"]]
style P0 fill:#bfd4ff,stroke:#4a90e2,stroke-width:2px
style Part fill:#d4f5dd,stroke:#28a745
style Head fill:#d4f5dd,stroke:#28a745
Priority 0 handles phrasal verbs. It runs
BEFORE the primary-constraint priority chain because the secondary
model’s sentence-level compound:prt analysis is more reliable than
either the primary’s deprel (which misclassifies foreign verbs as
advmod) or Priority 3’s blind trust of ADP as closed-class. See
Phrasal-verb recognition for the worked
example.
Concretely, the algorithm in pseudocode:
1. primary_deprel ← primary model's deprel for word i
2. primary_upos ← primary model's UPOS for word i
3. primary_head ← primary model's head index for word i
4. constraint_set ← deprel_to_pos_constraint(primary_deprel)
5. dependents ← words whose head = i (from primary parse)
refine constraint_set with dependent evidence
6. secondary_sentence ← Stanza UD response for the @s span
7. secondary_upos ← secondary[i].upos
8. secondary_lemma ← secondary[i].lemma
9. secondary_feats ← secondary[i].feats
// Priority 0 (phrasal-verb structural recognition):
10. if secondary[i].deprel == "compound:prt":
final_pos ← Part
final_deprel ← compound:prt
skip to 20
if some other word has head == i and deprel == "compound:prt"
AND secondary_upos == Verb:
final_pos ← Verb
skip constraint-based resolution
// Priorities 1-6 (existing constraint-based chain):
11. if has_copula and secondary_upos == Verb: final_pos ← NOUN or ADJ
12. if secondary_upos ∈ constraint_set: final_pos ← secondary_upos
13. if is_closed_class(secondary_upos): final_pos ← secondary_upos
14. if secondary_upos ∈ {NOUN, PROPN}: final_pos ← secondary_upos
15. if primary_upos ∈ constraint_set: final_pos ← primary_upos
16. else: final_pos ← most_likely(constraint_set)
// Merge lemma and features
17. final_lemma ← secondary_lemma
18. final_feats ← secondary_feats
// Merge GRA (outside Priority 0's explicit deprel)
19. if primary_deprel = "flat" or constraint mismatch with final_pos:
final_deprel ← infer_deprel(final_pos, head_upos)
else:
final_deprel ← primary_deprel
20. emit MergedL2Morphology { mor, corrected_deprel }
Phrasal-verb recognition
Stanza returns compound:prt for true verb-particle constructions
(wake up, give up, pick up, figure out). Before the
earlier behavior had the L2 merge processing each @s word in isolation
and could not see that structural evidence, so:
- The head (
wake) could be downgraded toadv|wakewhen the primary parser tagged it asadvmod(common for German parsing English roots). - The particle (
up) was always taggedadp|upbecause Priority 3 (closed-class trusted) returned ADP blindly.
The fix threads the full secondary UD sentence into
merge_primary_secondary_with_context()
(crates/batchalign-transform/src/morphosyntax/l2/merge.rs:542), which
delegates to the inner resolve_merged_pos_with_context() at :152
where Priority 0 is implemented. Result on German-English input:
| Main | %mor (before fix) | %mor (after fix) |
|---|---|---|
ich möchte wake@s up@s jetzt . | ... verb|wake adp|up adv|jetzt . | ... verb|wake-Fin-Imp-S part|up adv|jetzt . |
die kinder give@s up@s immer . | ... adv|give adp|up adv|immer . | ... verb|give-Fin-Imp-S part|up adv|immer . |
die zeit ist time@s out@s . | ... noun|time adp|out . | ... noun|time adp|out . (unchanged, correctly a compound noun, not a phrasal verb) |
The particle’s %gra deprel is COMPOUND-PRT. Test coverage for the fix:
crates/batchalign/src/chat_ops/morphosyntax_ops/l2/tests.rs: 4 unit tests (particle promotion, head promotion, non-phrasal ADP regression, non-VERB-secondary safety).crates/batchalign/tests/ml_golden/morphotag/golden_l2.rs::golden_l2_morphotag_phrasal_verbs, end-to-end ML golden locking in the table above.
Contiguous Span Grouping
Consecutive @s words with the same resolved target language are merged
into a single span and sent as a mini-sentence to the secondary model.
This gives the model useful context:
Input: we talked about los@s:spa niños@s:spa .
Span: ─────────────── ^^^^^^^^^^^^^^^^^^^^^^^^
"los niños" → Spanish Stanza
Result: det|el-Masc-Def-Art-Pl noun|niño-Masc-Pl
For contiguous spans, the secondary model has enough context to produce both correct POS and correct features. The deprel constraint from the primary model serves as validation rather than correction in these cases.
LanguageResolution Policy
| Variant | Policy | Rationale |
|---|---|---|
Single(lang) | Dispatch to lang | Unambiguous target |
Multiple(langs) | Fall back to `L2 | xxx` |
Ambiguous(langs) | Fall back to `L2 | xxx` |
Unresolved | Fall back to `L2 | xxx` |
Validation and normalization policy
Per-word L2 dispatch and transcript repair are intentionally separate concerns:
- explicit
@s:LANGstill dispatches toLANGwhen possible, even ifLANGis missing from@Languages, but validation emits warn-only E254 so the header drift is visible - whole-utterance same-language all-
@sruns are rejected as E255; the canonical CHAT representation is utterance-level[- lang] chatter debug fix-sis the normalization tool: it rewrites qualifying whole-utterance@sruns, clears the matching per-word shortcuts on fillers and nonwords as well as on regular words (a bare@sresolves relative to the surrounding tier language, so the new[- LANG]precode would otherwise flip filler resolution), appends missing explicit languages to@Languages, and skips already-correct files
Unsupported non-primary language handling
morphotag requires only the primary @Languages code to be
Stanza-supported. Non-primary content targeting an unsupported language
, whether via [- UNSUPPORTEDLANG] precode or @s:UNSUPPORTEDLANG
per-word marker, is partitioned out of Stanza dispatch by
partition_groups_by_stanza_support in
crates/batchalign/src/morphosyntax/worker.rs and emitted as L2|xxx
rather than crashing the worker. Supported-language utterances and
spans in the same file continue to receive real morphology.
GRA Upgrade: From FLAT to Correct Deprel
The primary model currently produces FLAT for most @s words because
it doesn’t recognize them. With a resolved POS, we can upgrade to the
correct UD relation:
| Resolved POS | Head’s POS | Upgraded deprel |
|---|---|---|
| ADV | ADJ | ADVMOD |
| ADV | VERB | ADVMOD |
| ADJ | NOUN | AMOD |
| DET | NOUN | DET |
| NOUN | VERB (with case dep) | OBL |
| NOUN | VERB (no case dep) | OBJ |
| NOUN | NOUN | NMOD |
This upgrade is conservative, only applied when the primary deprel is
FLAT (indicating the model gave up). Non-FLAT deprels from the primary
model are kept as-is, since they already carry correct structural
information.
Design Alternatives Considered
Alternative 1: Secondary Model Only (naive approach)
Send @s words to secondary model in isolation, discard all primary model output for those positions.
Rejected because: Throws away the primary model’s sentence-level structural understanding, the hardest information to recover from isolated words. POS accuracy degrades significantly for ambiguous words without sentence context.
Alternative 2: Full Utterance to Secondary Model
Send the entire utterance text to both primary and secondary models. Cherry-pick secondary results only at @s positions.
Deferred because: Tokenization mismatch between models makes position alignment fragile. The secondary model may split/merge words differently, creating the same retokenization problem we already know is complex. Worth investigating later but too fragile for v1.
Alternative 3: Multilingual Model
Use a single multilingual Stanza model (XLM-R based) that handles mixed-language input natively.
Rejected because: Multilingual models trade language-specific accuracy for breadth. For TalkBank’s morphological detail requirements (full CHAT features, language-specific POS subcategories), dedicated per-language models produce significantly better output.
Alternative 4: Dictionary Lookup
Use morphological dictionaries (UniMorph, Wiktionary) to look up @s words by form + deprel-constrained POS.
Not rejected, but deferred: Could serve as a fast fallback when no secondary Stanza model is available. The deprel constraint makes dictionary lookup much more reliable (since the POS is known). Worth adding as a future enhancement.
Alternative 5: Direct UPOS Transfer (no secondary model)
Use the primary model’s UPOS directly for POS. Only dispatch to the secondary model for lemma and features.
Partially incorporated: The merge algorithm uses primary UPOS as a fallback when the secondary model’s POS is outside the deprel constraint set. This avoids loading a secondary model when POS is all that’s needed, but lemma and features are the main value, so the secondary dispatch is still necessary.
Flag surface
L2 dispatch is the default. The --experimental-l2-morphotag flag
has been removed and replaced with --no-l2-morphotag (opt-out).
batchalign3 morphotag input/ -o output/ # L2 on (default)
batchalign3 morphotag input/ -o output/ --no-l2-morphotag # L2 off (legacy)
Morphotag has no --lang flag, every file’s primary language is read
from its own @Languages: header. The L2 dispatch path applies to
secondary-language tagged words (@s, @s:fra, etc.) inside any file
regardless of the primary.
Why keep an opt-out? Two legitimate users: researchers reproducing
older analyses exactly, and data producers who prefer the honest
L2|xxx to a silently-wrong analysis in cases where the secondary
Stanza model is known to be weak (the five L2|xxx survivors in
the cym,eng eval run fall into that category).
MWT Contraction Expansion
L2 secondary dispatch now sends retokenize=true to the worker. For
MWT-capable languages (English, French, Italian, etc.), Stanza’s free
tokenizer expands contractions into Range tokens:
| @s word | Without retokenize | With retokenize |
|---|---|---|
it's@s:eng | L2|xxx or pron|its | pron|it~aux|be |
don't@s:eng | L2|xxx or adv|dont | aux|do~part|not |
working@s:eng | noun|working | noun|work-Part-Pres-S |
The L2 path uses map_ud_sentence() (merged clitics), which collapses
Range token components into a single clitic MOR matching the original
@s word on the main tier.
Limitations
- Isolated word POS ambiguity partially mitigated. The deprel
constraint narrows POS for most words, but
flatandrootdeprels leave the constraint set broad. - Memory cost. Secondary models must be loaded alongside the primary model. Each Stanza model adds ~200-500 MB.
- Not all languages supported. Stanza covers ~70 languages, but
some
@stargets (e.g.,@s:nanTaiwanese,@s:sunSundanese , possibly mistagged in some corpora) have no model. In those cases the dispatcher falls back toL2|xxx; there is no silent wrong-analysis failure mode. - GRA upgrade is heuristic. The FLAT→correct-deprel upgrade covers common cases but may miss language-specific constructions.
- MWT coverage inherits Stanza’s per-language MWT support.
Contractions in
@swords expand via the same mechanism as non-@scontractions (retokenize=trueto the secondary model). Languages with Stanza MWT processors (English, French, Italian, Spanish, plus ~45 others) expand correctly; Swedish and a few others don’t. - Phrasal-verb coverage is Stanza-model-dependent. The merge
honors Stanza’s
compound:prtanalysis whenever the secondary model emits it. Stanza recognizes common English phrasal verbs (wake up,give up,pick up,figure out) but disagrees on borderline cases (look after,hang around), those returnadvmod, so the merge producesverb|look adv|afterrather thanverb|look part|after. Fixing these requires either a curated phrasal-verb lexicon or Stanza model improvements.
Implementation history
The implementation landed in four chunks:
| Date | Commit scope | What |
|---|---|---|
| (initial) | feat: experimental L2 morphotag for @s code-switched words | Core merge algorithm with POS priority chain and contiguous span dispatch. Feature gated behind --experimental-l2-morphotag (now removed). |
| (later) | feat: L2 morphotag + retokenize MWT fix, full contraction expansion | Three MWT bug fixes (English added to MWT_LANGS, Rust Range-token filter, expanded mapping for Retokenize path). L2 dispatch flipped to retokenize=true so clitics expand. |
| (later) | feat(l2): phrasal-verb merge via compound:prt | Priority 0 added to resolve_merged_pos_with_context. Secondary UD sentence threaded through merge. compound:prt head promotes to VERB; particle promotes to PART with correct GRA deprel. |
| (later) | feat(l2): flip L2 dispatch to default-on | Renamed --experimental-l2-morphotag to --no-l2-morphotag (inverted semantic). Default behavior is now L2 dispatch on. |
Aggregate evaluation validated the feature across 19 language pairs and triggered the ungating.
Related
- L2 Morphotag Literature Review, prior art survey
- Transcriber
$POSHints, complementary post-pass that overrides%morPOS with transcriber annotations (default on; opt out via--no-pos-hints). Attacks the sameFeaturePosMismatcherror class on embedded-language words that$POS-annotating transcribers have already labeled correctly. - L2 & Language Switching, current behavior reference
- Language Routing, full per-utterance + per-word routing, auto-detection, and the per-word routing gap
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
L2 Morphotag: Current Status
Status: Current Last updated: 2026-05-20 10:24 EDT
L2 dispatch is now on by default. Aggregate evaluation across 19 language pairs (17 at 100% dispatch;
cym,engat 99.8% andeng,yue,zhoat 99.9%; 99.96% aggregate) triggered removal of--experimental-l2-morphotagand addition of--no-l2-morphotag(opt-out).
What’s Done
Feature: L2 dispatch (default; opt out via --no-l2-morphotag)
Routes @s (code-switched) words to secondary language Stanza models
and merges the results with the primary model’s structural analysis.
Replaces L2|xxx with real morphological analysis.
Quality: ~95% acceptable on German-English (hogan2), ~90% on Spanish-English (herring12), ~97% on French-Dutch (Anouk). 100% splice rate (zero L2|xxx remaining when flag is on).
Architecture
morphosyntax/l2/
├── deprel.rs: UdDeprel newtype, deprel→POS constraint mapping
├── plan.rs: contiguous span planning + host attachment planning
├── merge.rs: POS resolution (6-level priority), planned Mor-based merge
├── extract.rs: primary structural info extraction from UD responses
├── spans.rs: contiguous span grouping for secondary dispatch
├── splice.rs: splice merged Mor into ChatFile
└── tests.rs: unit-test coverage for merge/splice/dispatch behavior
Dispatch (batch.rs:dispatch_secondary_l2):
- Plan per-utterance contiguous spans and host attachments in
plan.rs - Dispatch to secondary Stanza workers via
infer_batch - Map responses via
map_ud_sentence(handles MWT Range tokens) - Merge with primary structural info via
merge_planned_secondary_span - Splice into ChatFile via
splice_l2_into_chat
All 3 code paths wired: batch, single-file pipeline, incremental.
Key Design Decisions
-
POS resolution priority: copula check → constraint agreement → closed-class function word override → NOUN/PROPN override → primary structural fallback → constraint best guess
-
Secondary model’s NOUN/PROPN always trusted over primary deprel constraint (primary assigns wrong deprels to foreign words)
-
GRA correction: when resolved POS contradicts primary deprel, infer correct deprel from POS + head POS
-
UdDeprel newtype: typed distinction between UD lowercase and CHAT uppercase deprel labels
Tests
- focused Rust unit tests for planning, merge, splice, and phrasal-verb behavior
- ML golden tests for eng-spa, deu-eng, contractions, and flag-off regression
Documentation
l2-morphotag.md: design, architecture, Mermaid diagramsl2-morphotag-literature.md: 11-citation literature surveyl2-eval-runs/: aggregate ungating evidence (per-pair and per-word CSVs from the evaluation suite)
Input policy and repair tooling
- E255 now rejects whole-utterance same-language all-
@spatterns at validation time; transcripts should use[- lang]. - E254 is warn-only when explicit
@s:LANGnames a language absent from@Languages; dispatch still uses the explicit target language. chatter debug fix-snow repairs both transcript-side issues: it rewrites the qualifying whole-utterance@spattern, appends missing explicit languages to@Languages, and leaves already-correct files untouched.
What’s Not Done
(No known open items. The phrasal-verb gap listed here previously was resolved, see below.)
Recently Fixed
Phrasal-verb recognition: FIXED
Stanza returns compound:prt for true verb-particle constructions
(wake up, give up, figure out), but the L2 merge algorithm used
to process each @s word in isolation and could not see that relation.
Two consequences:
- When the primary parser tagged a foreign verb with
advmod(common for German parsing English), the deprel constraint rejected the secondary’s VERB at Priority 2 and downgraded the head to ADV (e.g.give@s up@s→adv|give adp|up). - The particle’s UPOS ADP was trusted by Priority 3 (closed-class) as
adp|up, not the CHAT-conventionalpart|up.
Fix. merge_primary_secondary_with_context now accepts a
SecondaryUdContext { sentence, word_position } and checks:
- the current word is a phrasal-verb particle (its own deprel is
compound:prt) → promote UPOS toPart, setcorrected_depreltocompound:prtso the CHAT %gra tier becomesCOMPOUND-PRT; - the current word is a phrasal-verb head (some sibling has deprel
compound:prtwith head pointing to this word) and the secondary UPOS is Verb → keep Verb, overriding the primary constraint.
Priority 0 runs before the existing priority chain, mirroring the Priority 4 NOUN/PROPN override that is already in place for content nouns. No Python changes, no cache-key changes.
Evidence. Running the pre-fix vs post-fix binary on a German-English fixture:
Before: die kinder give@s up@s immer → adv|give-Fin-Imp-S adp|up
After: die kinder give@s up@s immer → verb|give-Fin-Imp-S part|up
The isolated Stanza probe that anchored the test expectations lived
in the maintainers’ L2-eval working area outside this public repo;
the locked behaviour now lives in
crates/batchalign/src/chat_ops/morphosyntax_ops/l2/tests.rs and
the golden test cited below.
Test coverage.
crates/batchalign/src/chat_ops/morphosyntax_ops/l2/tests.rs: four unit tests exercising each merge branch (particle promotion, head promotion, non-phrasal ADP regression, non-VERB secondary safety).crates/batchalign/tests/ml_golden/morphotag/golden_l2.rs::golden_l2_morphotag_phrasal_verbs, end-to-end ML golden test onwake up/give up/pick up/time out, assertingverb|X part|upfor the first three andnoun|time adp|outfor the (non-phrasal) compound noun.
Earlier Fixes
MWT Hint Preservation Regression: FIXED
A follow-up Python regression in batchalign/inference/_tokenizer_realign.py
silently stripped Stanza’s (text, True) MWT hint tuples before the Rust
char-DP aligner saw them. Stanza’s tokenizer natively emits those tuples for
English contractions, and its MWT processor relies on them to expand Range
tokens. With the hint gone, MWT never fired and L2 contractions regressed
despite an earlier Rust-side fix being present.
Fix: _realign_sentence / _conform now overlay Stanza’s own tuples onto
aligner output where lengths match and no merging happened. Applies to every
language in MWT_LANGS.
Evidence, 4 L2 ML-golden tests all pass:
golden_l2_morphotag_eng_contractions:it's@s:eng→pron|it~aux|be,don't@s:eng→aux|do~part|notgolden_l2_morphotag_eng_spa: Spanish-English code-switching, ~90% acceptablegolden_l2_morphotag_deu_eng: German-English, ~95% acceptablegolden_l2_morphotag_off_produces_l2_xxx: flag-off regression guard
The prior “MWT blocker” note in this document is LIFTED.
Interaction with the English grammatical-invariant rewrite
A new Rust rewrite rule (see
Stanza Limitations, Defect 1) runs on the
primary English UD analysis to fix Stanza’s
copula-vs-possessive failure (the sink's overflowing). L2
extraction operates on the ORIGINAL ud_responses: captured at
crates/batchalign/src/pipeline/morphosyntax.rs:387 (assignment),
then read by l2::extract_l2_deferred_positions at :411 before
std::mem::take(&mut ctx.ud_responses) at :426, with L2 splice
gated on !l2_deferred.is_empty() at :435. The English rewrite
runs after that capture, so it cannot corrupt L2 position mapping.
The two features are decoupled by design.
Earlier Fixes (MWT contraction)
MWT Contraction Expansion: FIXED
English contractions (it's@s, don't@s) now get proper clitic
morphology: pron|it~aux|be, aux|do~part|not.
Root cause: Three bugs prevented MWT expansion in the L2 path:
"en"missing fromMWT_LANGS→ English pipeline loaded without MWT processor (dead English-specific branch in_stanza_loading.py)- The Rust injection layer at
crates/batchalign-transform/src/morphosyntax/injection.rsincluded Range parent tokens in the token vector → MOR count mismatch →retokenize_utterance()(atcrates/batchalign-transform/src/retokenize.rs:195) failed map_ud_sentence()merged Range components into clitics, wrong for the Retokenize path where each component needs its own MOR item
Fix: Added "en" to MWT_LANGS, filtered Range parents from token
vector, new map_ud_sentence_expanded() for the Retokenize path, and
flipped retokenize=false to true in the L2 secondary dispatch
(batch.rs:dispatch_secondary_l2).
Golden test: golden_l2_morphotag_eng_contractions verifies
it's@s:eng → pron|it~aux|be and don't@s:eng → aux|do~part|not.
Primary --retokenize for non-CJK: FIXED
The --retokenize flag now works for English (and all MWT languages).
golden_morphotag_retokenize_eng shows expanded output matching BA2:
gonna eat cookies . → gon na eat cookies . with per-component MOR.
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
Transcriber $POS Hints
Status: Reference, default on; opt out via --no-pos-hints
Last updated: 2026-09-05 05:21 EDT
CHAT main-tier words may carry a $POS suffix that encodes the
transcriber’s part-of-speech annotation in CLAN-MOR conventions
(e.g. school@s:eng$n, जब@s:hin$adv:temp, कि@s:hin$comp). By
default the morphotag pipeline treats those hints as authoritative
POS evidence. It captures them into PosHintEvidence before Stanza can
retokenize the main tier, then applies that typed evidence after Stanza
and L2 processing produce the final %mor. Each hinted word’s POS category
is compared against the transcriber’s CLAN tag, and the %mor POS is
overridden on disagreement. Lemma and morphological features from Stanza
are preserved, only the POS category changes.
Pass --no-pos-hints on any morphotag invocation to suppress the
post-pass and keep Stanza’s POS decisions as-is.
Why the feature exists
Aggregate L2 eval has identified FeaturePosMismatch as the dominant
structural error class, cases where a finite verb in the embedded
language is tagged as NOUN, PROPN, or CCONJ because the primary
model’s deprel constrains the merge away from VERB. A Hindi POC
observed the same pattern on matrix-language Hindi function words
(हाँ tagged pron rather than intj; ना tagged pron rather
than part).
In both regimes, when the transcriber has bothered to annotate a
word’s POS with $n, $v, $adv, etc., they are encoding
linguistic knowledge Stanza lacks, either because the word is
low-resource, domain-mismatched, or embedded in a construction the
UD parser’s deprel constraints don’t cover. Honoring those hints is
a cheap, near-zero-risk correction when hints disagree with Stanza.
Where it sits in the pipeline
The hint post-pass runs after the L2 secondary dispatch and splice,
before post-validation and serialization. This placement is
deliberate: the hint pass works on the final %mor state regardless
of whether the value came from the primary Stanza run, the L2
secondary dispatch, the phrasal-verb Priority 0 merge, or a fallback
to L2|xxx.
flowchart TD
A["Parse CHAT\n(parse_lenient)"] --> B["Extract payloads and typed $POS evidence\n(collect_payloads + collect_pos_hints)"]
B --> C["Stanza primary inference\n(infer_batch, per-language)"]
C --> D["Inject %mor + %gra\n(inject_morphosyntax)"]
D --> E{"L2 @s words present?"}
E -->|yes| F["Dispatch secondary language Stanza\n(dispatch_secondary_l2)"]
E -->|no| G["Skip L2 dispatch"]
F --> H["Merge primary+secondary UD\n(resolve_merged_pos_with_context)"]
H --> I["Splice merged Mor into ChatFile\n(splice_l2_into_chat)"]
G --> J{"--no-pos-hints set?"}
I --> J
J -->|no (default)| K["apply_pos_hint_evidence(&mut ChatFile, evidence, mappings)\nConsumes typed evidence after retokenization"]
J -->|yes| L["Skip hint post-pass"]
K --> M["Validate alignment\n(validate_mor_alignment)"]
L --> M
M --> N["Serialize CHAT\n(to_chat_string)"]
Source verified:
crates/batchalign/src/morphosyntax/mod.rs:72::run_morphosyntax_impl
(orchestration entry) and
crates/batchalign/src/morphosyntax/batch.rs:31::dispatch_secondary_l2
(L2 dispatch);
crates/batchalign-transform/src/morphosyntax/pos_hints.rs
(collect_pos_hints, apply_pos_hint_evidence);
crates/batchalign-transform/src/morphosyntax/l2/splice.rs:405::splice_l2_into_chat.
Per-hint decision flow
For every main-tier word in every utterance, the pass asks four
questions in order: is there a hint? does the CLAN tag map to a UD
UPOS? is there a %mor item to modify? does the UPOS disagree with
Stanza? The flow runs exactly once per word and is
pure, no Stanza re-invocation, no network I/O.
flowchart TD
Start(["For each main-tier word\n(walk_words, TierDomain::Mor)"]) --> H{"Word has $POS?\n(word.part_of_speech)"}
H -->|no| Skip["No record; continue"]
H -->|yes| Lookup{"clan_to_ud_upos(clan_tag)\n(talkbank_model)"}
Lookup -->|None| UnmappedCLAN["record: Unmapped\nleave %mor untouched"]
Lookup -->|"Some(upos_name: &str)"| Enum{"UniversalPos::from_pos_name(upos_name)\n→ Option~UniversalPos~"}
Enum -->|None| UnmappedUPOS["record: Unmapped\n(future-safety: new UPOS)"]
Enum -->|Some| MorCheck{"mor.items.get_mut(word_idx)\nexists?"}
MorCheck -->|no| NoMor["record: NoMorItem\n(count mismatch / skipped utt)"]
MorCheck -->|yes| Compare{"stanza_pos == hinted_upos?\n(UniversalPos equality)"}
Compare -->|equal| Agreement["record: Agreed\n(no change)"]
Compare -->|differ| Override["mor.override_main_pos(hinted.to_chat_pos_name())\n(features, lemma preserved)\nrecord: Overridden"]
Source verified: crates/batchalign-transform/src/morphosyntax/pos_hints.rs
(collect_pos_hints, apply_pos_hint_evidence; the resolve_hint internal
helper uses UniversalPos::from_pos_name and UniversalPos::to_chat_pos_name).
The CLAN → UD UPOS table
The mapping lives in talkbank-model so it is a cross-cutting
artifact useable outside this feature (parity audits, CLAN-vs-UD
reconciliation, future tools):
classDiagram
class CLAN_tag {
+&str clan_tag
+split(":") coarse, refinement
}
class UD_UPOS {
+&str upos_name
}
class clan_to_ud_upos {
+fn(clan_tag: &str) Option~&'static str~
"Special case: n:prop → PROPN"
"Coarse table on head before colon"
"Unknown → None"
}
CLAN_tag --> clan_to_ud_upos : input
clan_to_ud_upos --> UD_UPOS : output (or None)
Source verified: talkbank-tools/../chatter/crates/talkbank-model/src/model/dependent_tier/mor/analysis/clan_ud_mapping.rs.
Coverage (see the #[test] suite in clan_ud_mapping.rs for the
exhaustive list):
| CLAN tag family | UD UPOS | Notes |
|---|---|---|
n | NOUN | |
n:prop | PROPN | refinement crosses UPOS boundary |
n:gerund, n:deverbal, … | NOUN | other n:* refinements stay NOUN |
v | VERB | |
adj, adj:att, … | ADJ | |
adv, adv:temp, … | ADV | |
pro, pro:per, pro:dem, pro:sub, pro:int, pro:rel | PRON | subtype isn’t tracked in UPOS |
det, det:dem, det:poss, det:art | DET | |
prep, post | ADP | postpositions for Hindi/Tamil/etc. |
conj | CCONJ | default coordinating |
comp | SCONJ | complementizer (e.g. कि, “that”) |
part | PART | |
mod, aux | AUX | |
qn | DET | UD has no separate quantifier UPOS |
num | NUM | |
co, int, intj | INTJ | |
sym | SYM | |
punct, cm, end, beg | PUNCT | |
| anything else | None | unmapped, hint ignored |
CLI usage
Morphotag has no --lang flag. Each file’s processing language is read
from its own @Languages: header. The examples below assume the input
file’s header declares the appropriate language (e.g. @Languages: hin
for Hindi).
# Default behavior: hints respected automatically.
batchalign3 morphotag input.cha --output out/
# Opt out for a single job:
batchalign3 morphotag --no-pos-hints input.cha --output out/
# `--no-pos-hints` is orthogonal to --retokenize, --skipmultilang,
# --no-l2-morphotag, etc.
batchalign3 morphotag \
--no-pos-hints \
--no-l2-morphotag \
input/
With hints on (the default), every $POS-carrying word in every
utterance is considered. The pass is idempotent, running twice on
the same input produces the same output, because the second run sees
every hint as an Agreement.
What gets preserved
| Field | Preserved? |
|---|---|
Main tier (word order, @s tags, $POS suffixes, markup) | Unchanged by the hint pass; --retokenize may independently rewrite it |
%mor lemma (MorStem) | ✓ Stanza value kept |
%mor features (tense, case, number, gender, …) | ✓ Stanza value kept |
%mor POS category | overwritten on disagreement |
%gra relations | ✓ unchanged |
| Post-clitics (`~aux | beafterpron |
%xmor, %xgra, %com, %eng and other user tiers | ✓ unchanged |
The hint application never adds, removes, or reorders words or tiers. It only
mutates the single PosCategory string on %mor items whose captured evidence
contains a disagreeing $POS. Capturing evidence before retokenization prevents
the main-tier rewrite from silently disabling the default-on policy. When an
earlier word expands into multiple Stanza tokens, the injection trace maps later
hints to their new %mor positions. If the hinted word itself expands, its hint
applies to the first mapped token. Applying the evidence consumes it, preventing
an orchestration path from accidentally applying the same hint set twice.
Known limitations
- Only applies to utterances with a
%mortier. If Stanza skipped an utterance due to MOR-vs-main count mismatch (MWT, comma-handling, etc.), no%morexists, so no hint can apply. The hint pass records these asNoMorItembut takes no action. On the Hindi POC 36% of utterances fell into this category, a bigger quality issue than the hint feature addresses. - Unknown CLAN tags are silent. The mapping is intentionally
conservative: unknown tags return
None, the record is logged asUnmappedCLAN, and Stanza’s POS is kept. Widening the mapping is a matter of adding entries toclan_ud_mapping.rsand the corresponding unit tests. - Refinements don’t become UD features.
$pro:demcould plausibly propagatePronType=Demto%morfeatures, but today only POS category is overridden. A future revision could handle refinements → features. - Transcriber errors propagate. If the transcriber wrote
$von a word Stanza taggedDETwith full determiner features, the hint wins and producesverb|the…Det-features. A cross-check warning (feature vs POS consistency) is a candidate followup. - No
%gradeprel upgrade. Changing a word’s POS can make its%gradeprel inconsistent (e.g.,NOUN→VERBon an item with deprelOBJ). Today we leave the deprel as-is; the cross-check is deferred.
Current state
The hint pass is default on, with --no-pos-hints available as
the per-invocation opt-out. A future phase may remove the flag
entirely after wide corpus observation without regression reports.
The hint pass is narrow (POS-only overrides, Stanza features and
lemma preserved) and idempotent. If the default-on behavior produces
regressions in practice, --no-pos-hints provides immediate
per-invocation relief while a fix is prepared.
Related documentation
- L2 Morphotag design, the feature the hint pass
augments;
$POShints are a merge-algorithm-adjacent signal, not an L2-specific one. - L2 Morphotag Status, L2 feature overview.
talkbank-tools/../chatter/crates/talkbank-model/src/model/dependent_tier/mor/analysis/clan_ud_mapping.rs, the mapping source of truth.crates/batchalign-transform/src/morphosyntax/pos_hints.rs, the applicator source.
Reproducing the POC evidence
The Hindi POC used a twin morphotag run (stock vs prototype) on a
100-utterance sample of Devanagari-converted classroom speech.
Reproduce by routing language per-file from the @Languages: header
(morphotag has no --lang flag, per
crates/batchalign/src/cli/args/commands.rs:365-370):
# 1. Stock run (hints disabled, the old pre-default behavior).
# The sample-100-devanagari.cha @Languages: header drives routing.
batchalign3 morphotag --no-pos-hints sample-100-devanagari.cha \
--output stock/ --sequential --workers 1
# 2. Hint-respecting run (current default)
batchalign3 morphotag sample-100-devanagari.cha \
--output proto/ --sequential --workers 1
# 3. Diff the two outputs at the %mor tier level using diff/grep on
# the .cha files, or write a small comparator against the
# `chatter to-json` output.
On that sample: 5 POS overrides out of 26 hints applied; 3 of 5 unambiguously correct; 2 defensible; zero regressions.
This page last changed: 2026-09-05 (commit 73f146d4). The whole book last changed: 2026-09-16 (commit 34d249d8).
Literature Review: Code-Switching Morphosyntactic Analysis
Status: Reference Last updated: 2026-04-21 09:01 EDT
This review surveys computational approaches to morphosyntactic analysis of code-switched text, with particular attention to techniques relevant to batchalign3’s L2 morphotag feature (default-on since 2026-04-15). The goal is to situate our approach within the existing literature and identify whether we are reinventing known techniques or contributing something novel.
Background: The Problem
Code-switching (CS) is the alternation between two or more languages
within a single conversation, utterance, or even word. In TalkBank’s
CHAT format, word-level code-switching is marked with @s:
*EVA: ich möchte film@s studies@s machen .
The challenge: morphosyntactic analysis (POS tagging, lemmatization, dependency parsing) requires language-specific models, but code-switched text mixes languages at the word level. Monolingual NLP models degrade at switch points because they encounter out-of-vocabulary words from the other language.
Prior Art
Solorio & Liu (2008): Two Monolingual Taggers with Supervised Merger
The foundational work on POS tagging for code-switched text. Solorio and Liu ran separate English and Spanish POS taggers on English-Spanish code-switched data, then trained an SVM classifier to combine their outputs. Key finding: using the output of both monolingual taggers as features gave the best results, outperforming either tagger alone.
Relevance to our work: This is the closest antecedent to our two-model approach. The difference: they trained a supervised classifier to merge tagger outputs (requiring annotated code-switching training data), while our structural merge uses a rule-based deprel constraint that requires no code-switching training data at all.
Citation: Solorio, T. & Liu, Y. (2008). Part-of-Speech Tagging for English-Spanish Code-Switched Text. Proceedings of EMNLP 2008.
Bhat et al. (2018): Neural Stacking for Code-Switching Dependency Parsing
Built the first Hindi-English code-switching Universal Dependencies treebank and proposed “neural stacking”, base monolingual parsers (Hindi and English) whose hidden representations are fed to a stacking parser trained on code-switched data. Achieved 90.5% POS accuracy and 71.0% LAS on code-switched dependency parsing.
Relevance to our work: The neural stacking approach is architecturally related to our structural merge, both use monolingual models as a foundation and combine their outputs. The critical difference: Bhat et al. require a code-switching treebank to train the stacking layer. Our approach requires only monolingual Stanza models and a hand-crafted deprel-to-POS constraint table.
Citation: Bhat, I.A., Bhat, R.A., Shrivastava, M. & Sharma, D. (2018). Universal Dependency Parsing for Hindi-English Code-switching. Proceedings of NAACL-HLT 2018.
Soto & Hirschberg (2018): Joint POS and Language ID Tagging
Demonstrated that jointly predicting language ID and POS improves both tasks for code-switched text. Adding language ID as a feature to the POS tagger yields significant accuracy gains, because knowing the language constrains the set of valid POS tags.
Relevance to our work: We effectively have perfect language ID
(the @s marker in CHAT), which is a stronger signal than predicted
LID. Our approach exploits this by routing @s words to the correct
language-specific model. Soto & Hirschberg’s finding validates that
language-aware POS tagging outperforms language-agnostic approaches.
Citation: Soto, V. & Hirschberg, J. (2018). Joint Part-of-Speech and Language ID Tagging for Code-Switched Data. Proceedings of the Third Workshop on Computational Approaches to Code-Switching.
LinCE Benchmark (Aguilar et al., 2020)
A centralized benchmark for code-switching NLP evaluation, covering four language pairs (Spanish-English, Nepali-English, Hindi-English, MSA-Egyptian Arabic) and four tasks (language identification, NER, POS tagging, sentiment analysis). Established that multilingual BERT underperforms specialized approaches on code-switching tasks.
Relevance to our work: LinCE confirms that code-switching POS tagging is a recognized benchmark task with established baselines. Our approach could be evaluated against LinCE’s Spanish-English POS dataset for quantitative comparison, though our focus is on CHAT transcript morphology rather than social media text.
Citation: Aguilar, G., Kar, S., & Solorio, T. (2020). LinCE: A Centralized Benchmark for Linguistic Code-switching Evaluation. Proceedings of LREC 2020.
CS-ELMo (Winata et al., 2020): Transfer Learning with Morphological Clues
Extended ELMo with a position-aware attention mechanism that enhances morphological clues from character n-grams. The bottom layers of the ELMo architecture learn morphological patterns that transfer across languages, establishing state of the art on code-switching NER and POS tasks.
Relevance to our work: CS-ELMo uses subword/character-level features to capture cross-language morphological patterns. Our approach operates at the word level and doesn’t exploit subword features, this is a potential improvement direction.
Citation: Winata, G.I., Cahyawijaya, S., Lin, Z., Liu, Z., & Fung, P. (2020). From English to Code-Switching: Transfer Learning with Strong Morphological Clues. Proceedings of ACL 2020.
“Parsing the Switch”: BiLingua Parser (2025)
Very recent work using GPT-4 with linguistically-informed prompting to produce UD annotations for code-switched text. The BiLingua Parser combines few-shot LLM prompting with expert review. LLM-based annotations outperform conventional parsers in syntactic accuracy, particularly at switch points where monolingual models typically fail.
Relevance to our work: Confirms that monolingual parsers fail at switch points, exactly the problem our secondary dispatch addresses. Their LLM-based approach is more powerful but requires API access to proprietary models, while our approach uses open Stanza models locally.
Citation: (2025). Parsing the Switch: LLM-Based UD Annotation for Complex Code-Switched and Low-Resource Languages. Findings of EMNLP 2025.
CHILDES MOR Program (Sagae et al., 2010; MacWhinney, 2012)
The direct predecessor to our work within TalkBank. The MOR program provides automatic morphological analysis for CHILDES transcripts using hand-crafted rule-based grammars. For bilingual corpora, separate MOR grammars for each language are applied. MOR achieves 98% accuracy on adult English corpora and 97% on child language.
Relevance to our work: Our L2 morphotag feature is the neural
successor to MOR’s bilingual capability. MOR uses hand-crafted lexicons
(limited vocabulary, requires manual maintenance); we use Stanza’s
neural models (open vocabulary, no manual lexicon needed). The CHAT
@s marker convention that we exploit was designed for MOR’s bilingual
processing.
Citations:
- Sagae, K., Davis, E., Lavie, A., MacWhinney, B. & Wintner, S. (2010). Morphosyntactic annotation of CHILDES transcripts. J. Child Language.
- MacWhinney, B. (2012). Morphosyntactic Analysis of the CHILDES and TalkBank Corpora. Proceedings of LREC 2012.
Code-Switching UD Treebanks
Several code-switching treebanks exist in the Universal Dependencies framework, providing gold-standard annotations for evaluation:
- UD Hindi-English HIENCS (Bhat et al., 2018), Hindi-English code-switching tweets
- UD Turkish-German SAGT (Cetinoglu, 2022), Turkish-German conversational code-switching
These could serve as evaluation resources if we wanted to quantitatively validate our approach against gold-standard code-switching annotations.
Matrix Language Frame Model (Myers-Scotton, 1993)
The dominant linguistic theory of code-switching. Defines the “matrix language” (the language providing the grammatical frame) and the “embedded language” (the language contributing inserted elements). The Morpheme Order Principle and System Morpheme Principle govern how elements from the two languages combine.
Relevance to our work: Our architecture aligns naturally with the
MLF model. The primary language in batchalign3 IS the matrix language
(providing the syntactic frame via dependency parsing). The @s words
ARE embedded language elements. Our merge algorithm respects the matrix
language’s structural frame (deprel, head) while filling in the embedded
language’s morphology (lemma, features), this is essentially a
computational implementation of the MLF’s Morpheme Order Principle.
Situating Our Approach
What We Share with Prior Work
| Technique | Source | Our implementation |
|---|---|---|
| Two monolingual models combined | Solorio 2008 | Primary (matrix lang) + secondary (embedded lang) Stanza |
| Language ID as POS constraint | Soto 2018 | @s marker provides perfect LID |
| Monolingual models as foundation | Bhat 2018 | Stanza models for each language |
| UD framework for output | Bhat 2018, BiLingua 2025 | Full UD-to-CHAT mapping pipeline |
What Appears Novel
-
Deprel as cross-linguistic POS constraint. Using the primary model’s UD dependency relation to constrain the secondary model’s POS tag, without any code-switching training data. The UD deprel is cross-linguistically valid by design (a word with deprel
advmodmust be an adverb in any language), but this property has not been exploited for code-switching POS tagging in the literature. -
Closed-class function word override. When the secondary model returns a closed-class POS (DET, ADP, SCONJ, CCONJ, AUX, PART, PRON), trusting it over the structural constraint. This heuristic addresses the specific failure mode where the primary model mislabels foreign function words (e.g., parsing Spanish
losas English proper noun “Los”). Not described in prior work. -
GRA deprel correction from resolved POS. Upgrading the primary model’s structural parse (e.g.,
FLATtoDET,OBLtoDET) based on the resolved POS. Prior work on code-switching dependency parsing focuses on building dedicated CS parsers; we post-correct the monolingual parser’s output. -
Zero code-switching training data requirement. Most approaches (Solorio 2008, Bhat 2018, CS-ELMo 2020) require annotated code-switching data for training or fine-tuning. Our approach needs only standard monolingual Stanza models plus a hand-crafted deprel-to-POS mapping table (a pure function with ~30 lines).
-
Integration with CHAT/TalkBank ecosystem. The
@smarker provides reliable word-level language identification without any LID model. This is a unique advantage of the CHAT annotation framework that prior computational work on code-switching does not have access to (they must predict LID from text alone).
Known Limitations Relative to Prior Art
-
No subword features. CS-ELMo (2020) shows that character-level morphological features transfer across languages and improve CS POS tagging. Our approach operates purely at the word level.
-
No joint training. Approaches like neural stacking (Bhat 2018) learn to handle switch-point phenomena from code-switching data. Our rule-based merge cannot learn from data.
-
Primary model’s structural parse may be unreliable. The 2025 BiLingua paper confirms that monolingual parsers degrade at switch points. Our function-word override addresses the most common failure mode, but content words at switch boundaries may still get wrong structural analyses.
-
No evaluation on standard CS benchmarks. We have evaluated on TalkBank bilingual data but not on LinCE or other standard code-switching benchmarks. Quantitative comparison with prior work would require evaluation on shared datasets.
Recommendations
-
Our approach is viable and reasonably novel. We are not reinventing the wheel, the two-model combination idea is well established (Solorio 2008). But our specific implementation (deprel constraint + closed-class override + zero CS training data) is a new combination of known principles.
-
Cite Solorio 2008 and Bhat 2018 as the key antecedents. Our approach can be described as “a rule-based structural merge variant of the two-model pipeline approach (Solorio 2008), exploiting UD’s cross-linguistic deprel semantics for zero-shot code-switching morphosyntax.”
-
Consider evaluation on HIENCS (Hindi-English CS UD treebank) to quantitatively validate our structural merge against gold-standard code-switching annotations.
-
The Matrix Language Frame alignment should be mentioned in any publication, our architecture is a natural computational realization of the MLF model’s matrix/embedded language distinction.
Related
- Experimental L2 Morphotag, design document for the feature
- L2 & Language Switching, current behavior reference
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
NLP Engine Text Input Expectations
Status: Current Last updated: 2026-09-15 20:20 EDT
Comprehensive reference for what text format each NLP engine/tool in batchalign3 expects as input, what preprocessing is applied, and what would break if raw CHAT structural markers leaked through.
Architecture Summary
Batchalign3 enforces a strict ownership boundary:
- Rust owns all CHAT parsing, text extraction, normalization, cache keying, and result injection.
- Python is a stateless ML inference server – it receives structured data (word lists, audio paths, joined text) and returns raw model output.
NLP engines never see raw CHAT text. Every engine receives text that has
already passed through Rust’s Word::cleaned_text() extraction, which strips
all CHAT markers at parse time.
The cleaned_text() Contract
Word::cleaned_text() (defined in ../chatter/crates/talkbank-model/src/model/content/word/word_type.rs:190)
concatenates only two WordContent variants:
WordContent::Text– base graphemesWordContent::Shortening– elided material restored (e.g.,(be)causebecomesbecause)
All other variants are excluded:
| Excluded variant | CHAT surface form | Example |
|---|---|---|
OverlapPoint | overlap markers | word (with embedded overlap points removed) |
CAElement | prosodic markers | removed |
CADelimiter | paired prosodic markers | removed |
StressMarker | primary/secondary stress | removed |
Lengthening | syllable lengthening : | removed |
SyllablePause | syllable pause ^ | removed |
UnderlineBegin/End | underline control chars | removed |
CompoundMarker | compound + | removed |
Additionally, the extraction layer (extract.rs) uses ChatCleanedText – a
provenance newtype wrapping the output of cleaned_text(). The parallel
ChatRawText newtype preserves the original CHAT surface form for roundtrip
serialization but is never sent to NLP engines.
CHAT category prefixes (&-, &~, &+, 0) and form-type markers (@c,
@b, @s, etc.) are also stripped by the parser before cleaned_text()
returns.
Engine-by-Engine Reference
1. Whisper (OpenAI) – ASR
Task: Automatic speech recognition (audio to text).
Input: Audio waveform (numpy array or file path). No text input.
Output: Raw text chunks with timestamps. Whisper produces its own text – batchalign3 does not send any text to Whisper for ASR.
Preprocessing by Rust: None for ASR input (audio only). The language code
is converted from ISO 639-3 to a Whisper language name via
iso3_to_language_name() in asr.py (e.g., eng -> english, yue ->
Cantonese).
Post-processing by Rust (asr_postprocess/): The raw Whisper output goes
through a multi-stage Rust pipeline before becoming CHAT:
- Compound merging (3,584 known compound pairs after dedup; see
crates/batchalign-transform/src/asr_postprocess/compounds.rs) - Cantonese normalization (simplified to traditional + domain replacements,
lang=yueonly), once per monologue, before any splitting - Multi-word splitting (space-separated tokens get timestamp interpolation)
- Number expansion via
crates/batchalign-transform/data/num2lang.json(46 languages today) plusnum2chinese.rsfor CJK - Long turn splitting (>300 words)
- Retokenization (split into utterances by punctuation)
- Disfluency replacement (
um->&-um,'cause->(be)cause) - N-gram retrace detection (repeated n-grams wrapped in
<...> [/])
CHAT markers in output: Not applicable – Whisper produces raw natural language text. CHAT structural notation is synthesized by Rust during CHAT assembly.
Unicode: Whisper handles Unicode natively (multilingual model). Output contains Unicode text for all supported languages.
Source files:
batchalign/inference/asr.py(Python:infer_whisper_prepared_audio(),_infer_whisper())crates/batchalign/src/runner/dispatch/transcribe_pipeline.rs(Rust: orchestration)crates/batchalign-transform/src/asr_postprocess/(Rust: post-processing pipeline)
2. Rev.AI – ASR
Task: Cloud ASR (audio to text with speaker diarization).
Input: Audio file uploaded via HTTP multipart. No text input.
Output: JSON transcript with speaker-attributed monologues containing timed word elements.
Preprocessing by Rust: The Rust batchalign::revai module
(crates/batchalign/src/revai/) handles:
- Byte-owned multipart upload from a verified
PreparedRevProviderMediaartifact, with retry logic - Language specification (ISO 639-1 code or
"auto"for auto-detection) - Speaker count hint for diarization
skip_postprocessingoption
Post-processing by Rust: Rev.AI transcript words go through
extract_timed_words() in client.rs which:
- Sorts elements by start timestamp
- Trims whitespace from word values
- Filters empty values
- Converts seconds to milliseconds
The resulting timed words then enter the same Rust ASR post-processing pipeline as Whisper output (compound merging, number expansion, etc.).
CHAT markers in output: Not applicable – Rev.AI produces raw natural language text.
Unicode: Rev.AI handles Unicode natively.
Source files:
crates/batchalign/src/revai/client.rs(Rust: HTTP client, transcript extraction)crates/batchalign/src/revai/types.rs(Rust: API types)crates/batchalign/src/transcribe/infer.rs(Rust: dispatch)
3. Stanza (Stanford NLP) – Morphosyntax
Task: POS tagging, lemmatization, dependency parsing.
Input: Space-joined cleaned words followed by the utterance terminator,
one utterance per “sentence”, multiple sentences separated by \n\n.
What Stanza receives:
cleaned_word1 cleaned_word2 cleaned_word3 .\n\ncleaned_word4 cleaned_word5 ?
The terminator is evidence for the model, never data: StanzaInput in
batchalign/inference/morphosyntax.py appends it to the text and to the
realigner’s boundaries and strips it by position on the way back, so it never
becomes a %mor item. It is sent for every language. Italian needs it
(dammela does not MWT-expand without a period; see stanza-limitations.md),
and English was measured on 2026-09-10 (100 CHILDES files, 14,347 utterances,
same input and Stanza 1.14.0 with and without it): withholding it changed
6.9% of %mor lines and lost more structural analyses (copulas,
demonstratives, verb/gerund) than it gained, so it stays. The lexical
misreadings it causes on closed-class words (whoops as a plural noun,
byebye as a noun, ssh as a verb) are corrected by the lexicon constraint,
Defect 9; doggy as an adjective is not, because MOR itself could derive that
reading (dog + -y). A tag or response the lexicon leaves ambiguous
(... (.) okay ?) is settled by evidence Stanza never receives at all: the
CHAT pauses and separators, read off the AST per utterance and applied
after the parse (Defect 11). CHAT contractions the MWT expander does not
know (hafta, gimme, dunno) are sent as written and expanded after the
parse into the shape Stanza gives the expanded words (Defect 10).
This is constructed in batchalign/inference/morphosyntax.py:273:
text = " ".join(words).strip()
Then multiple utterances are joined:
combined = "\n\n".join(texts)
Preprocessing pipeline (Rust -> Python):
- Rust parses CHAT into AST via
parse_lenient() - Rust extracts words via
collect_utterance_content()usingTierDomain::Mor - Each word’s
cleaned_text()is collected – this strips all CHAT markers - Word texts are sent as
Vec<String>in theMorphosyntaxBatchItem - Python joins words with spaces:
" ".join(words).strip()
Note: The morphosyntax/preprocess.rs file defines prepare_text() which
also strips parentheses, but this is used only in certain Rust-side paths.
The Python morphosyntax.py explicitly avoids stripping parentheses (line
268-271 comment: “Do NOT strip parentheses here – Rust cleaned_text() already
handles CHAT notation”).
Stanza pipeline configuration (batchalign/worker/_stanza_loading.py):
Per-language MWT eligibility is capability-driven via
should_request_mwt(alpha2, get_cached_capability_table()) at
_stanza_loading.py:40; languages where the cached catalog reports
has_mwt=True get the postprocessor and tokenize_no_ssplit=True,
others fall back to tokenize_pretokenized=True. The earlier
hardcoded MWT_LANGS allowlist was deleted.
| Language class | tokenize_pretokenized | tokenize_no_ssplit | tokenize_postprocessor | MWT |
|---|---|---|---|---|
should_request_mwt() == False (e.g. Japanese, Chinese, Korean, Swedish today) | True | True | None | No |
should_request_mwt() == True (e.g. English, French, German, Italian, Hebrew, Greek) | (model decides) | True | Custom realigner | Yes (English uses gum) |
Key Stanza behaviors:
-
tokenize_pretokenized=True: Stanza preserves the input word boundaries exactly. “ice-cream” stays as one token. “don’t” stays as one token. Stanza does not re-tokenize. -
tokenize_no_ssplit=True: Stanza treats\n\nas sentence boundaries (matching batchalign’s one-utterance-per-sentence convention) but does not attempt automatic sentence splitting within a sentence. -
MWT languages with
tokenize_postprocessor: Stanza’s neural tokenizer runs but a custom realignment callback (_tokenizer_realign.py) merges any spurious splits back to match the original CHAT words. English contractions (e.g., “don’t”) are flagged as MWT candidates so Stanza’s MWT processor can expand them (do + n’t). Words like “o’clock” are suppressed from MWT expansion. -
Mandarin retokenize mode (
--retokenizewithcmn/zho): A separate Stanza pipeline withtokenize_pretokenized=Falseis loaded. Words are joined without spaces ("".join(words)) and Stanza’s neural tokenizer segments them. -
Cantonese retokenize mode (
--retokenizewithyue): PyCantonese’ssegment()function re-segments per-character tokens before Stanza processes them. Only runs when all CJK tokens are single characters (per-character ASR output).
What would break with CHAT markers:
- Stanza’s tokenizer would split on structural markers (e.g.,
wordcontaining embedded overlap points, or form-type suffixes like@c), producing incorrect token counts that would cause word-count mismatches in the injection layer. - POS tagging would be severely degraded – unknown tokens containing
@,+, Unicode prosodic markers would all getX(unknown) POS tags. - Lemmatization would fail to find dictionary entries.
- Dependency parsing would produce garbage structures.
Unicode: Stanza handles Unicode correctly. All text is already Unicode (CHAT is UTF-8).
Source files:
batchalign/inference/morphosyntax.py(Python:batch_infer_morphosyntax())batchalign/inference/_tokenizer_realign.py(Python: MWT realignment)batchalign/worker/_stanza_loading.py(Python: pipeline configuration)crates/batchalign-transform/src/morphosyntax/payload.rs(Rust: payload collection)../chatter/crates/talkbank-transform/src/extract.rs(Rust: word extraction)
4. Stanza – Utterance Segmentation (Constituency Parsing)
Task: Constituency parsing to determine utterance boundaries.
Input: Space-joined cleaned words, one utterance per call.
What Stanza receives:
doc = nlp(" ".join(item.words))
(call site in batchalign/inference/utseg.py::batch_infer_utseg)
Preprocessing: Same as morphosyntax – words arrive as Vec<String> of
cleaned text from Rust extraction, joined with spaces in Python.
Stanza pipeline: Configured with tokenize_pretokenized=True (preserves
word boundaries) and constituency parsing enabled. The pipeline is constructed
per-request via build_stanza_config_from_langs() in _stanza_loading.py.
Output: Constituency tree bracket notation strings. Rust parses the tree strings and computes word-to-utterance assignments.
Source files:
batchalign/inference/utseg.py(Python:batch_infer_utseg())batchalign/worker/_stanza_loading.py(Python:load_utseg_builder())crates/batchalign/src/utseg.rs(Rust: assignment computation)
5. Stanza – Coreference Resolution
Task: Coreference chain detection (English only).
Input: Pre-tokenized sentences (list of word lists) joined as:
text = "\n\n".join(" ".join(s) for s in item.sentences)
(call site in batchalign/inference/coref.py::batch_infer_coref)
Stanza pipeline: Configured with tokenize_pretokenized=True and
processors "tokenize, coref" with the
ontonotes-singletons_roberta-large-lora package.
Preprocessing: Words arrive from Rust as list[list[str]] (sentences of
cleaned words). Python joins them and Stanza preserves the tokenization.
Output: Per-word coreference chain annotations with chain IDs and
start/end flags. Rust builds CHAT %xcoref bracket notation from these.
Source files:
batchalign/inference/coref.py(Python:batch_infer_coref())crates/batchalign/src/coref.rs(Rust: injection)
6. Whisper / Wave2Vec – Forced Alignment
Task: Align known words to audio to produce word-level timestamps.
Input (Whisper FA): Audio chunk (tensor) + space-joined words as a single string:
detokenized = " ".join(item.words)
detokenized = detokenized.replace("_", " ").strip()
(from batchalign/inference/fa.py:370-371)
Whisper FA uses handle.processor() which tokenizes the text internally. The
text is fed through as a “forced” transcription target.
Input (Wave2Vec FA): Audio chunk (tensor) + word list. Wave2Vec operates at the character level:
wildcard = dictionary["*"]
# ...
token = dictionary.get(char, wildcard)
(from batchalign/inference/fa.py:259 for the wildcard binding and
:267 for the per-character lookup)
Each word is lowercased and decomposed into individual characters for CTC
forced alignment. Unknown characters map to the * wildcard token.
Preprocessing by Rust: Words are extracted from the CHAT AST using
TierDomain::Wor (the %wor alignment domain). cleaned_text() is used.
Rust groups words by audio time windows before sending to Python.
What would break with CHAT markers:
- Whisper FA: The text would not match the audio content, producing garbage alignments or alignment failures.
- Wave2Vec FA: CHAT markers like
@,+, Unicode prosodic markers would map to*(wildcard), corrupting the character-level CTC alignment.
Source files:
batchalign/inference/fa.py(Python:infer_whisper_fa(),infer_wave2vec_fa())crates/batchalign/src/chat_ops/fa/extraction.rs(Rust: word extraction for FA)crates/batchalign/src/chat_ops/fa/(Rust: grouping, injection, postprocess)
7. Cantonese FA (Wave2Vec + PyCantonese Jyutping)
Task: Forced alignment for Cantonese, with hanzi-to-jyutping preprocessing.
Input: Audio chunk + word list. Before alignment, hanzi characters are
converted to Jyutping romanization (tone-stripped, syllables joined with
apostrophes) via PyCantonese’s characters_to_jyutping(). Non-Cantonese
words pass through unchanged.
Preprocessing: Same Rust extraction as standard FA (TierDomain::Wor,
cleaned_text()), then Python-side jyutping conversion.
Source files:
batchalign/inference/languages/cantonese/_cantonese_fa.py(Python)
8. Tencent Cloud ASR – Cantonese/Mandarin ASR
Task: Cloud ASR for Cantonese and Mandarin.
Input: Audio file path. No text input – this is pure ASR.
Output: Speaker-attributed monologues with timed word elements. The
TencentRecognizer uploads audio to Tencent COS, submits an ASR task,
polls for completion, and returns raw results.
Post-processing: Raw Tencent output is returned as MonologueAsrResponse
to Rust. Rust then applies the standard ASR post-processing pipeline
(compound merging, number expansion, Cantonese normalization via ferrous-opencc, etc.).
Cantonese normalization: Tencent returns simplified Chinese characters.
They cross the provider bridge unchanged; the server’s post-processing
normalizes the whole monologue once, in cantonese.rs:
ferrous-opencccrate: simplified to traditional Chinese conversion- 31-entry Aho-Corasick domain replacement table for Cantonese-specific character corrections
Source files:
batchalign/inference/languages/cantonese/_tencent_asr.py(Python: transport)batchalign/inference/languages/cantonese/_tencent_api.py(Python: Tencent SDK wrapper)crates/batchalign-transform/src/asr_postprocess/cantonese.rs(Rust: normalization)
9. Aliyun NLS – Cantonese ASR
Task: Cloud ASR for Cantonese via Aliyun’s streaming websocket API.
Input: WAV audio streamed over websocket. No text input.
Output: Sentence-level results with per-word timestamps. The websocket
SentenceEnd callbacks return AliyunSentenceResult objects containing
words and sentence text.
Post-processing: Raw Aliyun sentences are projected to the standard
monologue format via batchalign_core.aliyun_sentences_to_asr() (a Rust
function exposed to Python). Rust owns the sentence-only fallback
tokenization plus the monologue/timed-word projection. Standard ASR
post-processing (including Cantonese normalization) follows.
Source files:
batchalign/inference/languages/cantonese/_aliyun_asr.py(Python: websocket transport)
10. FunASR (SenseVoice) – Cantonese/Multilingual ASR
Task: Local ASR using the FunASR/SenseVoice model.
Input: Audio file path. No text input.
Output: Speaker-attributed monologues with timed elements, returned as
MonologueAsrResponse. The FunAudioRecognizer wraps the FunASR AutoModel
with VAD (voice activity detection) and timestamp output.
Post-processing: Same as other ASR engines – Rust applies compound merging, number expansion, Cantonese normalization, etc.
Source files:
batchalign/inference/languages/cantonese/_funaudio_asr.py(Python: provider)batchalign/inference/languages/cantonese/_funaudio_common.py(Python: FunASR model wrapper)
11. PyCantonese – Cantonese Word Segmentation and POS
Task: (a) CJK word segmentation for Cantonese; (b) POS tag override for Cantonese.
Input (segmentation): Joined string of per-character tokens:
text = "".join(words)
return pycantonese.segment(text)
(from morphosyntax.py line 119)
Only runs when --retokenize is requested for lang=yue AND all CJK tokens
are single characters (indicating per-character ASR output). Multi-character
CJK tokens are preserved as-is.
Input (POS override): List of word texts extracted from Stanza’s UD output:
texts = [w.get("text", "") for w in ud_words]
tagged = pycantonese.pos_tag(texts)
(from morphosyntax.py lines 136-140)
This runs for ALL Cantonese morphotag operations (not just retokenize),
replacing Stanza’s upos tags with PyCantonese’s POS tags. Stanza’s
Mandarin-trained model scores ~50% on Cantonese vocabulary; PyCantonese
scores ~94%.
What would break with CHAT markers: PyCantonese’s segmenter would produce
incorrect word boundaries if CHAT markers were embedded in the input string.
The POS tagger would assign incorrect tags to tokens containing @, +, etc.
Source files:
batchalign/inference/morphosyntax.py(Python:_segment_cantonese(),_override_pos_with_pycantonese())
12. Google Translate / Seamless M4T – Translation
Task: Translate utterance text to English.
Input: what the speaker produced, as a single string:
translated = _translate(item.text, src_lang)
(from translate.py line 72)
The text field is rendered by Rust and arrives ready to send. Rust collects a
typed TranslationSource per utterance (collect_translate_payloads() in
batchalign-transform/src/translate.rs): every word the speaker produced, in
transcript order, with retraced words and filled pauses included and each word
as its cleaned text, followed by the utterance’s terminator.
TranslationSource::render() is the one place that text is built. It joins
words with spaces and attaches punctuation, except in Han script (zho, cmn,
yue, wuu, nan, hak), where words are joined without spaces and a period
is written as the ideographic full stop. Words that were not produced are left
out: 0-prefixed omissions, &~ nonwords, &+ fragments and the
untranscribed markers xxx / yyy / www. So is CHAT-only notation: only the
comma travels among separators, and only a period, question mark or exclamation
mark among terminators (+..., +/. and the other CHAT tokens send nothing).
What would break with CHAT markers: Translation quality would degrade –
the model would attempt to translate CHAT notation as natural language. CHAT
markers like &-um, (be)cause, @s:eng would produce nonsense translations,
which is why a filled pause arrives as um rather than &-um.
Source files:
batchalign/inference/translate.py(Python:batch_infer_translate())crates/batchalign/src/translate.rs(Rust: payload collection and injection)
13. Pyannote / NeMo – Speaker Diarization
Task: Identify speaker turns from audio.
Input: Audio waveform (numpy array) + sample rate. No text input.
Output: Timestamped speaker segments with speaker IDs.
Source files:
batchalign/inference/speaker.py(Python:infer_speaker_prepared_audio())
Summary Table
| Engine | Task | Input Type | Text Format | Pre-tokenized? | CHAT Markers Safe? |
|---|---|---|---|---|---|
| Whisper ASR | ASR | Audio only | N/A | N/A | N/A |
| Rev.AI | ASR | Audio only | N/A | N/A | N/A |
| Stanza (morphosyntax) | POS/lemma/dep | Space-joined words | cleaned_text() | Yes (most langs) | No |
| Stanza (utseg) | Constituency | Space-joined words | cleaned_text() | Yes | No |
| Stanza (coref) | Coreference | Space-joined sentences | cleaned_text() | Yes | No |
| Whisper FA | Alignment | Space-joined words + audio | cleaned_text() | N/A (decoder input) | No |
| Wave2Vec FA | Alignment | Word list + audio | cleaned_text(), lowercased | N/A (char-level CTC) | No |
| Cantonese FA | Alignment | Word list + audio | cleaned_text(), jyutping | N/A | No |
| Tencent ASR | ASR | Audio only | N/A | N/A | N/A |
| Aliyun NLS | ASR | Audio only | N/A | N/A | N/A |
| FunASR | ASR | Audio only | N/A | N/A | N/A |
| PyCantonese (seg) | Segmentation | Joined chars | cleaned_text(), joined | Own tokenizer | No |
| PyCantonese (POS) | POS tagging | Word list | Stanza output text | N/A | No |
| Google Translate | Translation | Joined text | cleaned_text() | N/A | No |
| Pyannote/NeMo | Diarization | Audio only | N/A | N/A | N/A |
CHAT Structural Markers That Are Stripped
For reference, these are the CHAT markers that cleaned_text() and the
extraction pipeline remove before any text reaches an NLP engine:
| Marker | Example | Meaning |
|---|---|---|
| Category prefixes | &-um, &~well, &+oh, 0word | Filled pause, filler, phonological fragment, omitted word |
| Form-type suffixes | word@c, word@b, word@s:eng | Child form, babbling, second language |
| Overlap points | (Unicode markers within words) | Conversational analysis overlap |
| CA prosodic markers | (Unicode markers within words) | Pitch, stress, prosody |
| Lengthening | wo:rd | Lengthened syllable |
| Syllable pause | wo^rd | Pause within word |
| Compound marker | ice+cream | Compound word boundary |
| Stress markers | (Unicode markers) | Primary/secondary stress |
| Shortening parens | (be)cause | Omitted sound (restored to because) |
| Timing bullets | [bullet ranges] | Word-level timestamps |
| Scoped annotations | [= text], [: text], [!], etc. | Error coding, replacement, emphasis |
All of these are parsed into structured AST nodes by the CHAT parser and
are excluded from cleaned_text(), which concatenates only WordContent::Text
and WordContent::Shortening elements.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Workspace Crate Dependency Contract
Status: Current Last updated: 2026-05-20 01:20 EDT
Overview
The batchalign source lives inside the talkbank-tools Cargo
workspace as sibling crates under crates/. The standalone
batchalign3 repo was decommissioned on 2026-04-28 and folded in;
there is no longer a cross-repo path-dependency relationship to
maintain. This page summarises the resulting dependency contract
and points readers at the broader release plan.
Current Dependency Wiring
Inside crates/batchalign/Cargo.toml, the runtime crate consumes
the shared talkbank-* crates as workspace dependencies:
# crates/batchalign/Cargo.toml
talkbank-model = { workspace = true }
talkbank-parser = { workspace = true }
talkbank-transform = { workspace = true }
batchalign-types = { workspace = true }
The PyO3 worker-runtime crate at crates/batchalign-pyo3/Cargo.toml
has a narrower set of workspace deps, it does not consume the
runtime batchalign crate:
# crates/batchalign-pyo3/Cargo.toml
batchalign-types = { workspace = true }
talkbank-transform = { workspace = true }
All path resolution is handled by the workspace Cargo.toml at the
repo root; there are no path = "../../../..." fragments anywhere in
the tree.
Consumed Crate Surface
| Crate | Consumed by | Purpose |
|---|---|---|
talkbank-model | batchalign (runtime) | CHAT data model, validation, alignment types |
talkbank-parser | batchalign (runtime) | CHAT parsing via tree-sitter |
talkbank-transform | batchalign, batchalign-pyo3 | Pipelines, CHAT↔JSON, alignment, morphosyntax, Cantonese normalisation, ASR post-processing, tokenizer realignment |
batchalign-types | batchalign, batchalign-pyo3 | Shared domain newtypes + V2 worker IPC contracts |
Compatibility Rules
- One workspace, one CI gate. Changes that cross talkbank-* and batchalign- crate boundaries land in the same PR; CI runs the whole workspace.
- Single-source build commands. PyO3 rebuilds go through
uv run maturin develop -m crates/batchalign-pyo3/Cargo.toml -F pyo3/extension-moduleor themake batchalign-build-wheel→make batchalign-python-preparechain. The standalone Rust CLI iscargo build -p batchalign(ormake buildfor the dashboard-embedded release path). - Cross-language IPC drift is gated by tests, not docs. The
IPC contract is enforced by
crates/batchalign/tests/worker_protocol_v2_compat.rson the Rust side andbatchalign/tests/test_worker_protocol_v2_types.pyon the Python side, plus thescripts/check_ipc_type_drift.shCI gate.
Release Boundary
The talkbank-* crates target eventual publication to crates.io with stable versioned APIs. Until that happens, the workspace deps shown above are the single source of truth. See Release Contract for the longer-term plan.
Release Manifest
Each batchalign3 release records:
- The single git SHA (the workspace HEAD) used for the build, since there is no longer a second repo to pin separately.
- The build date and CI run URL.
- License metadata: BSD-3-Clause for everything in this workspace
(see
LICENSEandpyproject.toml).
The release workflow generates the manifest automatically and attaches it to the GitHub Release notes.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Platform Support Matrix
Status: Current Last updated: 2026-09-06
This is the canonical Batchalign platform-support matrix. Chatter owns the
CHAT core, CLI, LSP and grammar in its own repository, with independent
platform guarantees. The repository’s docs/PLATFORM-SUPPORT.md points here
instead of maintaining a second matrix.
CLI + Server (batchalign3)
| Platform | Tier | CI | Wheel | Notes |
|---|---|---|---|---|
| Linux x86_64 | A | Full CI + clean-wheel CLI/server smoke | Yes | Primary CI platform |
| Linux ARM64 | B | Native release build | Yes | Built on a native ARM64 runner; no execution smoke |
| macOS ARM (Apple Silicon) | B | Clean-wheel CLI/server smoke | Yes | Release-smoke platform |
| macOS x86_64 (Intel) | B | Native release build | Yes | No execution smoke |
| Windows x86_64 | B | Clean-wheel CLI smoke | Yes | Process lifecycle uses Unix APIs (pre_exec, setsid, killpg); server/worker mode is not supported |
Dashboard (React)
| Platform | Tier | Notes |
|---|---|---|
| All (web browser) | B | Production bundle built in CI and embedded in release wheels; browser behavior is exercised by the dashboard test suite |
Batchalign Desktop (experimental)
| Platform | Tier | Notes |
|---|---|---|
| macOS / Windows | C (Experimental) | In-repo Tauri shell only; not a supported public release surface |
| Linux | C (Experimental) | No supported public desktop distribution |
This section is about Batchalign Desktop in apps/dashboard-desktop/.
Tier Definitions
- Tier A: Fully CI-gated. Tests run on every PR. Regressions block merge.
- Tier B: Release artifacts built. Smoke-tested where possible. Not full CI coverage.
- Tier C: Experimental. May build, may not. No guarantees.
Known Platform Limitations
- Worker process management uses Unix-specific syscalls (
pre_exec,setsid,killpg). Windows alternatives needed for full Tier A support. - Some contributor tooling remains shell-based; the public Windows installer is PowerShell and the Windows wheel receives a clean CLI smoke.
pyproject.tomlclassifiers list macOS and Linux only (Windows build-only, not supported for server mode).
Goal
Promote macOS ARM to Tier A by adding platform-specific CI test jobs. Windows server mode requires porting Unix process lifecycle APIs before Tier A is feasible.
This page last changed: 2026-09-06 (commit 99de52e4). The whole book last changed: 2026-09-16 (commit 34d249d8).
Building & Development
Status: Current Last updated: 2026-09-07 18:37 EDT
Development is supported on Windows, macOS, and Linux. The instructions below use Unix shell syntax; on Windows, use PowerShell or Git Bash equivalently.
Prerequisites
- uv – Python package manager (all platforms). Used for all dependency management and running commands.
- Rust (stable) via rustup (all platforms) – needed for the Rust CLI and PyO3 extension.
- Node.js + npm – needed for
make buildandmake build-dashboard, which rebuild the embedded dashboard bundled into the Rust binary. - maturin – Required only if you modify the Rust
batchalign_coreextension. - Python 3.13 or 3.14 for development. Installers and deployments default to 3.13, while CI builds and tests both standard interpreter versions. Free-threaded 3.14t is not a supported install or deployment target; see Python Versioning.
- Platform note: On macOS,
pythonandpython3may not exist outside a venv. Always useuv runto execute Python commands, which handles this automatically on all platforms.
Rust compiler policy
Development, CI, and releases use current stable Rust. We do not maintain an
older minimum supported Rust version: update with rustup update stable when
needed, and evaluate dependencies against the compiler our supported builds use.
The former workspace rust-version = "1.89.0" was neither inherited by any
package nor tested by CI. It has been removed. An older-compiler commitment
would require a concrete consumer need and a CI job proving that commitment.
Development Install
Batchalign source lives as the batchalign-* sibling crates inside
this talkbank-tools repo (the standalone batchalign3 repo was
decommissioned 2026-04-28; there are no longer two siblings to clone).
A development checkout is one repo:
git clone https://github.com/FranklinChen/talkbank-tools.git
cd talkbank-tools
make build
make build rebuilds the embedded dashboard, then runs cargo build --workspace --release, which compiles every Rust crate (including the
PyO3 bridge batchalign-pyo3). For PyO3-specific work, the dedicated
target is:
make batchalign-build-wheel # build the maturin wheel
make batchalign-python-prepare # build + install the wheel into the dev env
uv run batchalign3 then uses the installed wheel. Most contributors
skip the wheel step and rely on the dev fallback in
batchalign/_cli.py, which execs target/{debug,release}/batchalign3
when no packaged binary is present.
Never use pip install directly; uv manages the .venv and every
Python dependency.
Running the CLI
In a source checkout, uv run batchalign3 is the normal way to invoke
the installed console script. When no packaged binary is present
(batchalign/_bin/batchalign3), batchalign/_cli.py falls back to
target/{debug,release}/batchalign3 and then to cargo run -p batchalign as a last resort, so a single cargo build -p batchalign
up front gives you a fast iteration loop:
cargo build -p batchalign
uv run batchalign3 --help # uses the debug target via the wrapper fallback
Reserve uv run for Python tools (pytest, mypy, maturin) when you are
not invoking the CLI.
make build
./target/debug/batchalign3 --help
./target/debug/batchalign3 transcribe input_dir -o output_dir --lang eng
./target/debug/batchalign3 morphotag input_dir -o output_dir
./target/debug/batchalign3 align input_dir -o output_dir
# Or let Cargo rebuild the Rust binary incrementally for you:
cargo run -p batchalign -- transcribe input_dir -o output_dir --lang eng
What to Rebuild After Changes
Use the repo-native build targets so the Rust CLI, the shared
batchalign crate, and the batchalign_core PyO3 extension stay in
sync:
| What changed | What to rebuild |
|---|---|
Python code only (batchalign/) | Nothing; the next worker process picks up the change |
Rust CLI / server (crates/batchalign/) | cargo build -p batchalign |
Shared chat logic (any crates/) or PyO3 bridge (crates/batchalign-pyo3/) | make batchalign-python-prepare (rebuilds the maturin wheel and reinstalls it into the dev env). For the fastest CLI loop, also build the CLI once (cargo build -p batchalign) so the wrapper can fall back to target/debug/batchalign3. |
Command/orchestrator changes (crates/batchalign/src/commands/, compare.rs, benchmark.rs, transcribe/, fa/, morphosyntax/, command_family.rs, text_batch.rs) | cargo build -p batchalign, and make batchalign-python-prepare if the PyO3 bridge surface changed |
| Cross-cutting or dashboard changes | make build (requires Node.js + npm because it rebuilds the embedded dashboard, then runs cargo build --workspace --release) |
Rebuilding the Rust Extension
The batchalign_core Python package is a PyO3 Rust extension built by
maturin. The repo-native rebuild path is:
make batchalign-build-wheel # build the maturin wheel
make batchalign-python-prepare # depends on batchalign-build-wheel; reinstalls into the dev env
The PyO3 crate (crates/batchalign-pyo3/) has no feature gates beyond
extension-module: no heavy CLI or Rev.AI dependencies. In a source
checkout, batchalign/_cli.py falls back to
target/{debug,release}/batchalign3 when the packaged binary isn’t
present, so most contributors do not need to install the wheel
during iteration.
To exercise the installed-package experience locally, build the CLI
once (cargo build -p batchalign) and copy it into
batchalign/_bin/batchalign3 before running make batchalign-python-prepare; the maturin include directive in
pyproject.toml will then bundle it into the wheel.
CLI Binary Packaging (batchalign/_bin/)
batchalign3 ships two native artifacts in its wheel:
batchalign_core.so: the PyO3 extension (gives Python access to Rust CHAT parsing, alignment, etc.)batchalign/_bin/batchalign3: the standalone Rust CLI binary (the server, job runner, and all commands)
The Python entry point (batchalign/_cli.py) locates and execs the native CLI
binary. It searches three locations in order:
- Packaged binary at
batchalign/_bin/batchalign3: this is what GitHub Release installers and downloaded wheels use. The binary is bundled inside the wheel. - Dev checkout at
target/{debug,release}/batchalign3: for developers who built the CLI withcargo build. - Cargo fallback: execs
cargo run -p batchalignto compile on the fly.
Why _bin/ is gitignored
The binary is a 50+ MB platform-specific build artifact, it must not be tracked in git. Instead:
- Locally: copy
target/release/batchalign3(ortarget/debug/batchalign3) intobatchalign/_bin/before runningmake batchalign-python-prepareif you need the installed-package experience. Most developers skip this and rely on the dev-checkout fallback (target/debug/batchalign3). - CI: A dedicated
build-clijob compiles a development-profile CLI once and uploads it as an artifact. One development ABI3 wheel packages that binary, and the Python-version matrix installs the same wheel. Dashboard and server smoke jobs consume that CLI artifact; they need no Rust compiler or target cache. Dashboard schema generation still runs against the candidate binary throughBATCHALIGN_BIN. Every consuming job fetches it through the.github/actions/cli-binarycomposite action; see below for why downloading it directly does not work. - Release: The release workflow builds platform-specific CLI binaries (macOS ARM + Intel, Linux x86 + ARM, Windows x86) and packages each into the corresponding wheel.
Fetching the CLI artifact in a new job
Uploading an artifact zips its files and drops the POSIX mode, so a job that
downloads cli-binary gets the compiler’s output at mode 644 and cannot run
it. The executable bit has to be restored by whoever downloads it, and the
failure when it is not is a permission error deep inside whatever script tried
to run the binary, several steps after the omission.
So a job does not download the artifact. It calls the action that owns both halves:
- uses: ./.github/actions/cli-binary
with:
path: target/debug
Two paths are in use: target/debug, where the smoke scripts and the
BATCHALIGN_BIN environment variable expect a locally built binary, and
batchalign/_bin, where the wheel build expects a pre-staged one. The action
needs actions/checkout to have run first, since it lives in the repository.
This is checked rather than asked for. cargo run -q -p xtask -- lint-ci-hygiene, which runs inside make batchalign-ci-rust and therefore on
every push, refuses any job that fetches the binary itself or restores the
executable bit by hand.
Two things about how it decides, because both were holes in its first version.
It works on the workflow that PUBLISHES the artifact, and only that one, since
an artifact belongs to a single workflow run and no other file can reach it.
That scoping is what lets it be strict: inside that workflow a download is
refused whether it names the artifact, matches it with a pattern:, names
nothing at all, or asks for something interpolated that nobody can resolve
here, and the executable bit counts as restored by hand at any mode, not just
chmod +x. Elsewhere those same shapes are ordinary and are left alone, which
is why the release workflow’s pattern-matched downloads do not trip it.
The action’s own definition is read as a definition rather than searched as
text, so deleting its chmod step while leaving a sentence about one in the
description does not satisfy it. Publishing is untouched: only downloads are
judged, so the producing job stays legal without sitting on an allowlist. If
the artifact is ever renamed or its producer removed, the check says that
rather than quietly examining nothing.
CI cache ownership
Only jobs that compile Rust restore target caches. Artifact-only smoke jobs restore their npm or Python dependencies instead.
Each compiling job keeps its OWN cache key, and the temptation to share one is
worth resisting for a specific reason. build-cli runs cargo build -p batchalign, which resolves no dev-dependencies and builds no test targets,
while the Rust workflow’s gate job runs four test invocations. The cache action
saves on post-job and skips the second save of an identical key, so a shared
key would let whichever job finishes first decide what the cache holds, and the
shorter one would leave the longer one recompiling every dev-dependency on
every run. Dependency audits cache
Cargo downloads with cache-targets: false; restoring another job’s compiled
targets provides no audit coverage. Coverage keeps a separate instrumented
target directory and cache, and release builds keep target-specific caches.
Check cache restore messages and compilation time before blaming test count. GitHub evicts caches when repository storage is full, so redundant target archives can cause useful compiler caches to disappear. See GitHub cache limits and eviction and rust-cache inputs. Cache hits and job duration on the next comparable run establish the effect; removing an unused cache does not by itself prove a speedup.
Maturin include directive
pyproject.toml tells maturin to include the binary in the wheel:
[tool.maturin]
include = [
{ path = "batchalign/_bin/batchalign3", format = "wheel" },
{ path = "batchalign/_bin/batchalign3.exe", format = "wheel" },
]
If the binary doesn’t exist at build time, maturin silently skips it, the
wheel still builds but batchalign3 --help will fail at runtime with
“CLI binary not found.” This is why CI must build and copy the binary
before running the maturin wheel build.
Where Command Logic Should Live
If you are changing command behavior, the first stop should be the owning
command module in crates/batchalign/src/commands/ and then the module
that actually owns the algorithmic or orchestration semantics (compare.rs,
benchmark.rs, transcribe/, fa/, morphosyntax/, etc.).
crates/batchalign/src/commands/owns released-command identity, specs, and the top-level contributor-facing entrypoints.crates/batchalign/src/command_family.rskeeps the small command-shape enum used by command metadata.crates/batchalign/src/text_batch.rskeeps reusable text-batch helper types for commands such asutseg,translate, andcoref.crates/batchalign/src/runner/owns job lifecycle, queueing, and shared dispatch machinery.crates/batchalign/src/runner/dispatch/(benchmark_pipeline.rs, fa_pipeline.rs, transcribe_pipeline.rs, infer_batched.rs, audio_task.rs, asr_media.rs, media_analysis_v2.rs, options.rs, plan.rs, utr.rs) should stay thin and focus on argument parsing, capability gating, and whether a command runs locally or through the server.crates/batchalign-pyo3/should stay a thin bridge, not the place where new command logic is invented.
Run the Rust test suite to verify your changes:
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
Type Checking
Run the current mypy gate before every commit:
uv run mypy # mypy only
make batchalign-typecheck-python # mypy under the batchalign- target group used by CI
make lint-affected # affected-Rust clippy + affected Python mypy
Strictness lives in mypy.ini, and CI runs the same repo-native
command shape. The per-module exemptions there are enumerated rather than
wildcards, so a module nobody listed is checked by default; adding a new
module needs no entry, and removing an existing entry, with its recorded error
count, is the unit of work.
Do not commit with mypy errors. Use # type: ignore[<code>] only when
necessary, and always include the specific error code.
Type Annotation Rules
All new and modified code must include type annotations:
- Annotate all function parameters and return types.
- Use modern syntax:
list[str]notList[str],str | NonenotOptional[str]. Anyandobjectare banned as type annotations. Use specific types. For ML library types that are expensive to import, useTYPE_CHECKINGguards with the real type.- Use
from __future__ import annotationsfor forward references where needed. - Prefer
TYPE_CHECKINGimports for heavy dependencies used only in annotations.
The CHAT Format Rule
All CHAT parsing and serialization must go through principled AST manipulation in Rust. Python never touches CHAT text directly.
Do not:
- Use regex or string splitting to extract or modify CHAT content from Python.
- Process CHAT line-by-line in Python.
- Manipulate CHAT header metadata with ad-hoc text code.
Instead:
- From Python, shell out to the
batchalign3CLI (validate,to-json, command-specific subcommands) and consume its structured output, or raise/catchbatchalign_core.CHATValidationExceptionat the parser boundary (the typed exception that the PyO3 layer surfaces). - All CHAT AST manipulation lives in the Rust crates (
talkbank-parser,talkbank-model,talkbank-transform,batchalign). When new AST-level behaviour is needed, add it on the Rust side and expose it through the CLI; do not invent a new Python-facing parsing surface.
CHAT has complex escaping, continuation lines, and encoding rules that ad-hoc text manipulation will get wrong. The Rust AST handles all of this correctly; the 2026-03-21 PyO3 slimdown deliberately retired the older user-facing PyO3 parse / build / add-morphosyntax bindings in favour of this CLI-and-typed-exception boundary.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Testing
Status: Current Last updated: 2026-09-15 16:18 EDT
Philosophy
The test suite is split into tiers by design. The following diagram shows the tiers, their resource requirements, and how to invoke each.
The manually dispatched Coverage job runs Rust coverage once over the workspace,
including batchalign-pyo3, and excludes the experimental desktop shell:
cargo llvm-cov --no-fail-fast --workspace --exclude batchalign-dashboard-desktop --locked --lcov --output-path lcov-rust-workspace.info
The desktop shell needs GTK/WebKit on Linux and is outside the headless CI
package set. This exclusion does not remove PyO3 coverage; the former separate
PyO3 step repeated tests that the workspace command already selects. Coverage
remains an explicit measurement job, not an inner-loop test command.
Its native compile restores the dashboard artifact produced by the same
workflow before compiling the embedded server assets. It installs and probes
protoc, ffmpeg and ffprobe before the instrumented build, because coverage
also exercises real media boundaries. PyO3 uses the same virtual-environment
interpreter as the installed wheel.
The instrumented Rust coverage job sets RUST_TEST_THREADS=1 on its small
runner. Independent CLI tests otherwise reserve host memory as separate
servers: a 16 GB runner reached 8.25 GB of pending reservations with 7 GB
available, blocking another worker for 120 seconds and timing out other jobs.
This is an execution budget for coverage, not a disabled memory guard or a
skipped test. Explicit concurrency inside a test still runs. Ordinary local
and push tests retain their existing scheduling. Reproduce the affected suite
with cargo test -p batchalign --test cli_integration_suite --locked -- --test-threads=1.
Coverage uses --no-fail-fast to collect failures across test executables in
one run. The ordinary Rust gate runs the entire fast contract_suite once,
replacing two filtered invocations that omitted most contracts. Reference-corpus
parity locates fixtures through the pinned Chatter test-support dependency, so
it checks the same release as the linked parser without a sibling checkout.
Directory and file read failures are fatal; only parser-rejected fixtures are
excluded from this valid-content parity measurement.
Coverage caches target/llvm-cov-target under its own shared key and saves
compiled dependencies after failed tests. The ordinary build’s full cache hit
cannot stand in for those instrumented artifacts; sharing that immutable key
previously forced the dependency graph to rebuild on each coverage retry.
The memory-tier architecture test scans Rust files in process and parses the
embedded runtime TOML. It requires no rg subprocess or workspace-root search;
unreadable source files fail the check. This keeps ordinary test and coverage
environments equivalent without adding a search executable to every runner.
Processing-command subprocess tests own a CliHarness, which seeds an isolated
HOME with setup configuration. They must not inherit the developer’s setup:
coverage runners correctly start without it. The detached-server worker test
checks the running child’s actual --workers arguments, then checks that a
completed job respects that ceiling. Granted workers may be lower because CPU
and memory admission remain active. The daemon binds an OS-selected port and
publishes its handshake, so a startup failure is a failure rather than a skipped
port-collision test. Its echo daemon owns a separate memory ledger and declares
a minimal positive headroom floor, so unrelated live-model tests do not consume
its synthetic reservations. Zero is not used: YAML maps that legacy value back
to automatic sizing. An owned daemon session stops the process on both normal
completion and unwinding; failures include its log.
Both the Python wheel job and standalone PyO3 build cache the root Cargo
workspace’s target directory. The extension lives at
crates/batchalign-pyo3; the former pyo3 workspace path no longer exists.
The cache action’s default root mapping follows the actual Cargo workspace
without maintaining a second, stale crate layout.
flowchart TD
fast["Tier 1: Fast Tests\n(make test / cargo test)\nUnit + protocol + test-echo integration\nNo models, no GPU\n~5s, safe, fully parallel"]
ml["Tier 2: ML Golden Tests\n(make batchalign-test-ml-golden)\nReal Whisper + Stanza + pyannote\nSkips are FAILURES, not passes\nSerialized (profile test-threads=1)\n~5min, 8-12 GB peak RAM"]
pygolden["Tier 3: Python Golden\n(uv run pytest -m golden)\nbatchalign_core extension\n~10-30s, 1-2 GB"]
fast -->|"routine dev loop\n(every edit)"| safe(["Safe on any machine"])
ml -->|"opt-in only\n(pre-release, inference changes)"| danger(["Serialized, never\nrun with bare cargo test"])
pygolden -->|"opt-in only\n(PyO3 changes)"| safe
-
Fast tests: unit tests, protocol tests, test-echo integration tests. No ML models, no GPU, no multi-GB processes. These run in seconds, fully parallel, on every edit. This is the inner development loop. It must stay fast and safe, a
cargo testshould never crash your machine. -
ML tests: golden snapshots, audio transcription, parity checks, profile verification. These spawn real Python workers that load Whisper, Stanza, pyannote, etc. Each worker consumes 2-5 GB RAM. They are slow, expensive, and dangerous on developer machines.
Every processed output carries a provenance stamp (
@Comment:with[fc-ba3 ...]) naming the models that ran and the wall-clock time they ran. The ML harness treats the two halves differently. Golden snapshots (assert_golden_snapshot!) keep the stamp, so a change in which model or pipeline variant produced the output is a visible snapshot change, but pin its timestamp to<timestamp>so a snapshot can be accepted at all. BA2 parity (assert_ba2_parity) drops stamp lines, because BA2 wrote none and the parity question is about tiers. Both recognize a stamp withprovenance::extract_provenance, the codec the writer uses, never a text pattern, and both fail the test on a stamp that does not parse.
ML tests are excluded by default. You must opt in explicitly, and only when you have a reason: a change to worker dispatch, a new language, an inference module edit, a pre-release check. Never as part of routine edit-compile-test.
This mirrors the Python side, where uv run pytest excludes golden,
slow, and integration markers by default.
Rust test executable topology
The ordinary Rust integration tests are compiled into three shared targets:
contract_suitefor wire formats, manifests, workflow helpers, and other fast contracts;cli_integration_suitefor CLI and server behavior using the shared test server fixture;worker_integration_suitefor worker protocol, routing, lifecycle, and pool behavior.
Four targets keep their own processes because isolation is part of their
contract: relative_output_dir mutates the process working directory,
stress controls concurrent server pressure, turmoil_net owns a simulated
network, and gpu_concurrent_dispatch performs parent-process worker cleanup.
The feature-gated ml_golden target remains a separate, opt-in executable.
This layout was measured on macOS on 2026-09-05. Before consolidation, a warm
cargo test -p batchalign --tests -- --list spent 15.87 seconds merely
starting 34 executables, while a no-run build was already warm in 0.42 seconds.
The consolidated layout starts nine ordinary executables, including the two
unit-test targets, and the same listing probe takes 6.61 seconds. The assertions
were retained; the removed cost was repeated linking and process startup.
make test runs compiled workspace tests with --tests, then workspace doctests
with --doc, each once and with --locked. Bare cargo test --workspace
already runs doctests, so following it with a second doctest command duplicated
their compilation and execution. Scoped commands remain the inner loop.
The workspace continues to use plain cargo test. cargo-nextest is not
installed because it must still enumerate every executable and its earlier
eager process burst repeatedly wedged macOS syspolicyd. Reconsider it only
through an isolated benchmark of this consolidated topology, with constrained
process concurrency and clean plus warm measurements. Runner adoption requires
an improvement beyond the cost already removed at the source.
That benchmark was run with a temporary cargo-nextest 0.9.143 binary and four
test workers. The 52-test contract_suite took 3.40 seconds under nextest,
which launches one process per test, versus 2.56 seconds as one plain-Cargo
test executable. The full default suite has 2,433 runnable tests, so nextest
would exchange nine ordinary process starts for thousands. It remains rejected
for the default developer loop on both performance and macOS process-assessment
grounds.
Waiting in a live test: the deadline owner
Never write a bare Duration in a live test. Two types own every wait, and
both live in crates/batchalign/tests/live_deadline/.
ServerTestDeadline is for anything that polls. It carries two bounds instead
of one: an idle window, the longest it may go without observing any change
in what it is watching, and a ceiling, the longest it may run no matter how
much progress it sees. Observed progress resets the idle window; nothing
resets the ceiling. So a job that keeps completing files is not killed for
being slow on a loaded machine, and a job that is genuinely hung still fails,
inside the idle window, at roughly the time the old fixed deadline would have.
let mut deadline = ServerTestDeadline::new(WaitSubject::job_completion(job_id));
loop {
let info = get_job(&client, &base_url, job_id).await;
if info.status.is_terminal() {
return info;
}
deadline.keep_waiting(ProgressSnapshot::job(&info)).await;
}
keep_waiting is the whole loop body that used to be an assert! on a bare
deadline plus a sleep on a bare interval. Its refusal names what the wait was
for, which of the two bounds was hit, the last progress it observed and how
long ago, and the probe and change counts.
Two rules make this hold rather than merely read well:
- A
WaitSubjectcarries its own budget, andWaitBudgetis private. You pick what you are waiting for, never how many seconds it gets, so a subject and a budget cannot disagree and a call site cannot invent a number. - A
ProgressSnapshotis built only from a real probe result (ProgressSnapshot::job,::http_status,::spawn_admission,::process_alive). There is no constructor from a literal, so “progress” cannot be asserted, only observed. A value that never changes degrades the wait to its idle window; a value that always changes is still stopped by the ceiling.
CliRunBudget and HarnessBudget own the one-shot durations that have no
progress to observe: an assert_cmd subprocess timeout, a session shutdown, a
fixture’s ready_timeout_s. They buy no extension, only a single place where
each budget is written down.
A subprocess that owns its own wait does not get a second number.
CliRunBudget::DaemonStart is not written down at all: it is
cli::daemon::startup_budget() plus the margin the command needs to report its
own failure. serve start spawns a daemon and waits up to that budget (90 s)
for the handshake, so a harness killer set to anything smaller fires first and
converts every outcome into the same one. It did: the spawn ran on
ServerRoundTrip’s 60 s, and on a loaded machine the helper died by SIGKILL at
60 s with an empty server.log and wait status 9, which reads exactly like a
daemon that never started. Across four runs on byte-identical code that went
fail, pass, fail, fail, taking two different tests down at the same helper line
whenever three or four live tests overlapped past 60 s.
The fix is not a bigger number. A bigger number written here could fall under
startup_budget() again the next time that constant moves; deriving it cannot.
A daemon that genuinely never comes up is still refused, by serve start
itself, at 90 s, naming the phase it was stuck in, and the harness kill survives
as the backstop for a CLI that stops answering altogether.
Why this exists. A fixed deadline cannot tell a hung subject from a
contended one. memory_guard holds ONE process-global spawn permit until a
worker reports ready, so a worker spawn queues behind every other test binary’s
cold model load. cli_morphotag_real_server spent its fixed budget in that
queue and failed with 1/1 local startup slots in use, twice in consecutive
change sets, while passing alone in about 12.65 seconds. The bound was not too
small; it was measuring the wrong thing.
Note that ready_timeout_s buys BOTH halves:
memory_guard::acquire_spawn_permit passes it to the host-memory lease wait and
then to the readiness wait. HarnessBudget::FixtureWorkerReady sizes it for
contention plus startup rather than startup alone.
Durations a test configures are not deadlines and do not belong to these
types: test_delay_ms, how long a stub interpreter hangs, and the simulated
network durations in turmoil_net.rs are stimulus, and the assertions that
measure against them (CANCEL_UNWIND_BOUND against WORKER_NATURAL_COMPLETION)
are properties that extending would destroy.
The shared worker pool is bound to its own runtime
A tokio::process::Child registers its pipes with the reactor of the runtime
that created it and stays bound to it for life. #[tokio::test] builds a
runtime per test and drops it at the end of that test, while the shared
fixtures keep one warmed WorkerPool for the whole binary. A pool that spawned
on whatever runtime was current therefore handed later tests workers whose
reactor was gone:
A Tokio 1.x context was found, but it is being shutdown.
WorkerPool now captures the runtime it is CONSTRUCTED on and creates every
child process there, whoever calls it (src/worker/pool/spawn_runtime.rs).
A worker’s reactor is a property of the pool, not of whichever test first
needed a worker, so no caller can bind one to a shorter-lived runtime. Once a
worker exists, dispatching to it from another runtime is fine and stays direct;
that is why the fix works.
Production is unaffected and this is a no-op there: it builds one pool inside
one process-lifetime runtime (prepare_workers), which is the same runtime the
old ambient spawn would have found.
When you add a pool code path that creates an OS resource, route it through
PoolSpawnRuntime (spawn_worker, adopt_shared_gpu_worker,
spawn_detached) rather than calling tokio::spawn or WorkerHandle::spawn
directly. The type hands out no inner handle, so there is no way to borrow your
way back to the ambient runtime.
Fast Contributor Loop
For command-workflow edits, the shortest useful loop is usually:
cargo xtask affected-rust packages
make batchalign-python-prepare
cargo build -p batchalign
cargo test -p batchalign --test contract_suite workflow_helpers::
cargo test -p batchalign --test cli_integration_suite cli::
uv run batchalign3 --help
Use the workflow-layer tests when you are changing:
- command semantics
- compare / benchmark behavior
- materializers and typed intermediate bundles
- workflow-family dispatch or composition
Keep the broader ML tests for runtime changes that actually touch workers, models, or cache behavior.
If you only changed docs or workflow metadata, start with
cargo xtask affected-rust packages and the narrow CLI/help checks before
running anything expensive.
Why this matters
Kernel OOM panics have been caused by ML test binaries spawning
concurrent Whisper workers during cargo test. Each golden test
binary was a separate process that started its own server with its own
worker pool. Running them in parallel exhausted machine memory.
A separate kernel OOM panic was caused by running Python
@pytest.mark.golden tests repeatedly with the default -n 3 from
pytest.ini. Each xdist worker loaded its own Stanza model instance
(~500 MB). Over multiple invocations combined with cargo builds and a
local batchalign daemon, cumulative memory pressure exceeded the
machine’s RAM. This led to the three-layer Python-side OOM guard in
conftest.py.
See docs/postmortems/ for incident details.
Implemented solution: single binary
All ML tests are consolidated into one binary (ml_golden). One binary =
one process = one LazyLock = one PreparedWorkers = one set of loaded
models. Peak memory is ~8-12 GB (one pool) instead of 7x that.
The LiveServerSession fixture within the binary is well-designed:
- One
PreparedWorkersbackend shared across all 70 tests - Fresh HTTP server per session (new port, new jobs dir, new SQLite)
- Semaphore-gated sessions so tests don’t collide on control-plane state
- Warm model cache across tests, only the first test pays cold-start
Defense-in-depth layers
These remain as additional safety nets:
| Layer | What | Catches |
|---|---|---|
ml-golden cargo feature | ml_golden carries required-features, so a plain cargo test cannot build it | Routine dev runs |
make batchalign-test-ml-golden | ML tests serialized via --test-threads=1 | Explicit opt-in |
| Global worker cap | max_total_workers (RAM / 6GB, clamped to [2, 32]) | Multi-key pool explosion |
WorkerPool::Drop | Kills idle workers when pool is dropped | Test cleanup on panic/exit |
| PID file reaper | ~/.batchalign3/worker-pids/ scanned on startup | Orphans from crashed servers |
| pytest OOM guard (configure) | Forces -n 0 when -m golden on < 128 GB machine | Standard golden invocation |
| pytest OOM guard (collection) | Aborts if golden tests collected with -n > 0 on < 128 GB | Overridden addopts |
| pytest OOM guard (fixture) | Per-test _guard_golden_oom autouse fixture fails in xdist workers on < 128 GB | Belt-and-suspenders; cannot be bypassed |
| Claude Code guard hooks | Block workspace cargo test under memory pressure, and any cargo run concurrent with another in the same workspace | AI assistant sessions |
Quick reference
# Fast tests only (default, safe, parallel, no models)
cargo test --workspace
make test
# ML tests only (serialized, one at a time)
make batchalign-test-ml-golden
# Specific ML test (filter by submodule name)
cargo test -p batchalign --features ml-golden --test ml_golden golden:: -- --test-threads=1
# Everything (fast + ML)
make batchalign-test-ml-golden
# Python (fast only by default)
uv run pytest
# Python golden/integration
uv run pytest -m golden
uv run pytest -m integration
Nextest configuration
The ML golden suite is substantially broken (2026-07-28)
First honest run of the whole suite after giving it an entry point: 104 passed, 86 failed, 191 total, 625 s.
None of the 86 is the skip panic described below; every test acquired a live
session and then failed on its own merits. Observed causes, from the run log
(archived at ml-golden-baseline-2026-07-28.log):
- Worker death mid-job:
worker process exited unexpectedly (exit code: None),GPU worker reader loop exited, worker process is dead. - Runtime teardown racing the job:
A Tokio 1.x context was found, but it is being shutdown. FIXED 2026-09-16: the pool now binds worker processes to the runtime it was constructed on, so a pooled worker no longer outlives the reactor of whichever test first spawned it. See “The shared worker pool is bound to its own runtime” above. - Jobs returning
FailedwhereCompletedwas asserted (~30). - HTTP 400 on content-job submission (~10).
- Snapshot drift on the
compareandcorefgoldens. - A rejection-message assertion now stale: the test expects an
unsupported-language message, but morphotag now rejects job-level
--langoutright (the 2026-05-03 incident), so the message no longer matches.
Do not read this as a regression introduced on 2026-07-28. The suite had no
Makefile or CI entry point and was reachable only through nextest’s
--profile ml, retired with nextest itself, so there is no recent green
baseline to regress from. The failures are accumulated rot that nothing was
positioned to notice.
Treat the numbers above as the BASELINE to drive down, not as a gate. Until it
is green, make batchalign-test-ml-golden is a diagnostic, and adding it to
make verify would only train people to ignore a red gate.
ML golden tests fail rather than skip
require_direct_session_warmed in the ml_golden suite panics when a live
session cannot be acquired. It used to return None, and every call site did
else { return; }, so a test that never executed reported ok.
That is not hypothetical. On 2026-07-28 two newly written Italian golden tests
reported ok in 7.23 s having produced no output; only replacing an assertion
with a deliberate lie and watching it still pass would have told them apart.
The suite had also had NO entry point in the Makefile or CI, reachable only
through nextest’s --profile ml, so its silence went unnoticed after nextest
was retired.
Building with --features ml-golden is an explicit request to run these tests.
If the environment cannot host them (no Python worker, no model weights, no
credentials), do not run the suite; the feature gate exists so a plain
cargo test never reaches it.
nextest was removed on 2026-07-27 (it wedged macOS syspolicyd by
exec’ing every test binary up front to enumerate tests). The ML exclusion
that used to live in .config/nextest.toml as a default-filter now lives
in the code as required-features = ["ml-golden"], so correctness no longer
depends on which runner you use.
Default profile: applies a default-filter that excludes all ML test
binaries. cargo test runs only fast tests, because ml_golden requires the
ml-golden feature to build at all. This is the safe
default.
ML profile (--profile ml): the profile’s default-filter selects
only binary(ml_golden), and the profile sets test-threads = 1 so the
suite runs serially, preventing concurrent model loading and the OOMs
that follow.
Override the default filter for one run:
cargo test -p batchalign --features ml-golden --test ml_golden -- --test-threads=1
All ML tests live in one binary (ml_golden) with submodules:
| Submodule | What | Models |
|---|---|---|
golden | Text NLP golden snapshots | Stanza |
golden_audio | Audio transcription/alignment | Whisper, Wave2Vec, pyannote |
golden_parity | Batchalign2 output parity | Stanza |
live_server_fixture | Full server with live workers | Mixed |
profile_verification | Worker pool profile grouping | Wave2Vec, Stanza |
option_receipt | Option propagation differential tests | Stanza, Wave2Vec |
error_paths | Graceful failure under live server | Mixed |
Test categories
| Category | Tool | Command | Models | Runtime | Default |
|---|---|---|---|---|---|
| Rust unit tests | cargo | cargo test --workspace | None | ~5s | Yes |
| PyO3 unit tests | cargo | cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml | None | ~3s | Yes |
| Python unit tests | pytest | uv run pytest | None | ~2s | Yes |
| Worker protocol | cargo | cargo test -p batchalign --test worker_integration_suite worker_protocol_matrix:: | None (test-echo) | ~5s | Yes |
| Server integration | cargo | cargo test -p batchalign --test cli_integration_suite integration:: | None (test-echo) | ~5s | Yes |
| Network fault (turmoil) | cargo | cargo test --test turmoil_net | None | <1s | Yes |
| Workflow helpers | cargo | cargo test -p batchalign --test contract_suite workflow_helpers:: | None | ~2s | Yes |
| JSON compat | cargo | cargo test -p batchalign --test contract_suite json_compat:: | None | ~1s | Yes |
| ML tests (all) | cargo | make batchalign-test-ml-golden | Mixed | ~5min | No |
| Python golden | pytest | uv run pytest -m golden | batchalign_core | ~10s | No |
| Python integration | pytest | uv run pytest -m integration | Worker | ~5s | No |
| Cantonese ASR engines | pytest | uv run pytest batchalign/tests/languages/cantonese/ | FunASR+ | ~2min | No |
When to run ML tests
Run ML tests based on what changed, not as a habit:
| What you changed | Run |
|---|---|
| Rust unit logic (parser, DP, postprocess) | Fast tests only |
| Workflow-family modules or compare/benchmark materializers | workflow_helpers + focused CLI tests |
| Python inference module | --profile ml |
| Worker protocol or IPC types | worker_protocol_matrix (fast) + --profile ml |
| Worker pool, dispatch, or lifecycle | --profile ml |
| FA pipeline or UTR | --profile ml |
| Morphosyntax injection or retokenization | --profile ml |
| Pre-release or large refactor | Full --profile ml |
| Adding a new language | --profile ml |
Python tests
uv run pytest # Fast only
uv run pytest -m golden -v # Golden snapshots
uv run pytest -m integration -v # Integration
uv run pytest -m "golden or integration" -v # Both
uv run pytest batchalign/tests/test_batch_infer_dispatch.py -v # Specific file
If you changed crates/batchalign-pyo3/ or shared Rust crates that feed batchalign_core,
rebuild the extension before running Python tests that import it. The
maturin build backend declared in pyproject.toml (build-backend = "maturin", [tool.maturin] block) means uv run <anything> does an
incremental rebuild on demand. For a clean wheel install of the freshly
built extension into the dev environment:
make batchalign-python-prepare
Test doubles
Prefer explicit fake seams over monkeypatch when touching production code.
If a test needs to replace runtime behavior, the first question should be
whether the production boundary wants a typed injected dependency instead.
Worker protocol V2 drift suite
uv run pytest batchalign/tests/test_worker_protocol_v2_types.py -q
uv run pytest batchalign/tests/test_worker_protocol_v2_artifacts.py -q
uv run pytest batchalign/tests/test_worker_fa_v2.py -q
cargo test -p batchalign --test contract_suite worker_protocol_v2_compat::
cargo test -p batchalign --lib worker::fa_result_v2:: --locked
cargo test -p batchalign --test worker_integration_suite worker_v2_fa_roundtrip::
These tests read fixture files under tests/fixtures/worker_protocol_v2/
so the Rust and Python schema models stay aligned.
Cross-language contract tests
Several test pairs look redundant but exist intentionally: Rust and Python must independently verify the shared wire format. If only one side is tested, a serialization change in the other language could silently break IPC.
| Python test | Rust counterpart | What they verify |
|---|---|---|
test_ipc_type_conformance.py | scripts/check_ipc_type_drift.sh (CI gate) | Schema field parity between Rust and Python models |
test_worker_ipc.py | worker_protocol_v2_compat.rs | JSON roundtrip through both language’s serializers |
test_worker_protocol_v2_types.py | worker_protocol_matrix.rs | V2 protocol envelope parsing on both sides |
Do not consolidate these pairs. A passing Rust test does not prove the Python side deserializes correctly, and vice versa.
Rust tests
# PyO3 extension
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
# Root workspace (fast tests only)
cargo test --workspace
# Workflow layer
cargo test -p batchalign --test contract_suite workflow_helpers::
# Focused modules within the consolidated suites
cargo test -p batchalign --test cli_integration_suite cli::
cargo test -p batchalign --test cli_integration_suite e2e::
cargo test -p batchalign --test cli_integration_suite integration::
cargo test -p batchalign --test contract_suite json_compat::
Profile verification tests
ml_golden/profile_verification.rs exercises the worker profile architecture
under real model inference. Unlike golden tests (which verify output
correctness), these tests verify resource usage:
- GPU profile sharing: multi-file align uses a single
SharedGpuWorker - Stanza profile grouping: morphotag and utseg share one Stanza worker
- Label regression guard: all worker keys use
profile:*prefix
Run with make batchalign-test-ml-golden.
ML test skip behavior
Model-gated tests use require_live_server(InferTask::Xxx, "message"):
- Tries to acquire a
LiveServerSessionwith a warm worker pool - Checks if the required InferTask is available (model installed)
- Returns
None(test silently skips) if models are unavailable
Python uses @pytest.mark.skipif or pytest.skip() for similar gating.
Even under --profile ml, tests skip gracefully if models are not
installed. You won’t get false failures, just silent skips.
Worker process safety
ML tests spawn Python worker subprocesses that load multi-GB models. Several safeguards prevent runaway resource consumption:
Global worker cap: The WorkerPool enforces a hard ceiling on total
workers across all (profile, lang, engine) keys. The production
formula ram_total_mb / 6GB, clamped to [2, 32] (with a fallback
of 4 if sysinfo reports 0, e.g. macOS undercounts), lives in
recommend_max_total_workers() at
crates/batchalign/src/host_facts/recommendations.rs:244. The
runtime value is exposed via EffectiveConfig::max_total_workers
and consumed by PoolConfig (see comments at
crates/batchalign/src/worker/pool/mod.rs:157). Configurable via
max_total_workers in server.yaml.
Pool Drop: WorkerPool implements Drop to kill all idle workers
synchronously, even when tests exit without calling pool.shutdown().
PID file reaper: Each spawned worker writes a PID file to
~/.batchalign3/worker-pids/{pid} recording its parent server PID. On
pool startup, stale files (dead workers) are cleaned up and orphans
(live workers whose parent server is dead) are killed via
SIGTERM → 2s wait → SIGKILL.
Dashboard Playwright tests
cd frontend
npm run e2e:install
npm run test:e2e
If Chromium has not been installed:
cd frontend
npm run test:e2e:setup
Type checking
uv run mypy # mypy only
make batchalign-typecheck-python # mypy under the batchalign-* target group
make lint-affected # affected-Rust clippy + affected Python mypy
The gate is inverted, and that is the property to preserve: mypy.ini checks
every module under batchalign and exempts NAMED modules one at a time, each
carrying the error count that justifies it. A new module is checked because
nobody listed it, rather than exempt because it fell under a wildcard. Removing
an entry is the unit of work: fix the module, confirm it reports zero, delete
its section. Do not silence a module with an inline type: ignore instead.
CI hygiene
Release-facing CI checks cover:
- CLI/package version sync (
make ci-local+ xtasklint-ci-hygiene) - Stale legacy-term detection (xtask
lint-ci-hygiene) - Retired package/path checks (xtask
lint-ci-hygiene) - Command execution path integration coverage (focused tests under
crates/batchalign/tests/)
cargo xtask lint-ci-hygiene
make ci-local
Coverage
There is a coverage workflow in .github/workflows/batchalign-python.yml (manual
workflow_dispatch, not a release gate).
- Python: full inference adapter surface covered
- Remaining low-coverage areas: training, worker bootstrap, test helpers
# Python coverage (non-integration)
uv run --no-sync pytest -n0 --cov=batchalign --cov-report=term \
--disable-pytest-warnings -m 'not integration' -q batchalign/tests
# Rust coverage
cargo llvm-cov --manifest-path crates/batchalign-pyo3/Cargo.toml \
--lcov --output-path lcov-rust.info
cargo llvm-cov --no-fail-fast --workspace \
--lcov --output-path lcov-rust-workspace.info
Structural lints (xtask)
Two lints run as xtask subcommands rather than test binaries to avoid unnecessary integration test binary compilation:
cargo xtask lint-wide-structs # Enforces reviewed field caps on wide structs
cargo xtask lint-ci-hygiene # Version sync, legacy terms, retired packages
Both are included in make ci-local. Thin test proxies in
crates/batchalign/tests/ invoke them so cargo test still catches
regressions.
Deterministic simulation testing (turmoil)
Network fault testing uses turmoil to simulate partitions, message delays, server crashes, and concurrent clients under virtual time. Tests run in <1s with no Python or ML dependencies.
See Deterministic Simulation (turmoil) for architecture, adapter details, and the full test catalog.
cargo test -p batchalign --test turmoil_net
Known gaps
-
No concurrent dispatch stress tests. The worker pool, job registry, and media walker have complex concurrency paths exercised only by
test-echointegration tests. A dedicated stress harness (multiple concurrent jobs with real server lifecycle) would catch race conditions earlier. Shuttle was evaluated but can’t test our Semaphore/broadcast primitives (broadcast is a stub that panics, Semaphore forwards to real tokio with no schedule exploration); the full tool-evaluation note lives outside this public repo. -
No negative-path ML tests. Golden tests verify happy paths. There are no tests for graceful degradation when models are unavailable, corrupt, or return malformed output under real inference.
-
No cross-platform CI. Tests run only on macOS (local) and Linux (CI). Windows is a supported platform but has no automated test coverage.
-
Dashboard Playwright tests are opt-in. The React frontend E2E suite requires manual Chromium setup and is not part of the default CI gate.
Background test runner (make test-bg)
The cost function for test runs is wall-clock time spent waiting,
not just time spent running. scripts/test-bg.sh wraps any command,
runs it detached, writes structured logs, and posts a macOS desktop
notification on completion. The developer keeps working; failures
ping loudly, successes ping quietly (or silently with --quiet).
scripts/test-bg.sh -- cargo test --workspace
scripts/test-bg.sh -- uv run pytest -m 'golden and mwt_probe' -k fra
Log layout per run (under ~/.batchalign3/bg-test/<slug>/):
| File | Meaning |
|---|---|
<ts>.log | Full stdout+stderr. Ends with === TEST-BG COMPLETED: exit=N duration=Ns ===. |
<ts>.status | Exit code. File’s presence is the unambiguous “done” signal. |
<ts>.meta | cmd, pid, ts_start, ts_end, duration_s, exit. |
The COMPLETED sentinel line lets a watcher (tail, Monitor tool,
etc.) detect completion without polling the filesystem. The
.status file is the authoritative done signal.
A Makefile glue layer (make test-bg / test-bg-status /
test-bg-smoke) was discussed but is not landed; scripts/test-bg.sh
is the current entry point.
This page last changed: 2026-09-16 (commit 34d249d8). The whole book last changed: 2026-09-16 (commit 34d249d8).
Deterministic Simulation Testing with turmoil
Status: Current Last updated: 2026-06-30 13:55 EDT
Why we need this
The existing test suite covers worker lifecycle, job dispatch, protocol correctness, and ML golden outputs well. But it has no tests for network-level fault scenarios: what happens when a client disconnects mid-stream? When the network partitions and recovers? When the server crashes and restarts? When multiple clients race against each other?
These gaps are not hypothetical. Several production incidents trace to network-adjacent behavior:
- Experimental orchestration workers that appeared connected but never executed work (a fleet-wide hang)
- Stale batchalign3 processes surviving across deploys because the restart sequence didn’t verify the new process was actually serving
- Dashboard SSE streams silently dropping events under load
turmoil is a deterministic simulation testing framework from the tokio project. It replaces real TCP with a simulated network running in a single thread with virtual time. Tests are fast (~10ms), deterministic (same seed = same result), and can inject network faults declaratively.
What turmoil tests vs. what it does not
turmoil controls the network layer only. It is complementary to the existing test tiers, not a replacement.
flowchart LR
subgraph "turmoil scope"
tcp["TCP connections\n(client ↔ server)"]
partition["Network partitions"]
delay["Message delay\n(hold/release)"]
crash["Host crash/bounce"]
time["Virtual time\n(no wall-clock waits)"]
end
subgraph "Outside turmoil"
workers["Worker subprocesses\n(stdio pipes)"]
sqlite["SQLite persistence"]
memory["Memory pressure\n(sysinfo)"]
signals["OS signals\n(SIGTERM, SIGKILL)"]
end
tcp -.->|"tested by"| turmoil_tests["turmoil_net.rs"]
workers -.->|"tested by"| worker_tests["worker_failure_paths.rs\nworker_routing_and_lifecycle.rs"]
sqlite -.->|"tested by"| integration["integration.rs"]
| What turmoil tests | What it does NOT test |
|---|---|
| HTTP request/response under partition | Worker subprocess crashes (use SIGKILL tests) |
| SSE stream behavior when client disconnects | SQLite WAL corruption or SQLITE_BUSY |
| Server restart and recovery | Memory gate and sysinfo behavior |
| Concurrent client requests | Python model loading failures |
| Message delay and reordering | LaunchAgent restart behavior |
| Health check timeout behavior | Real TCP edge cases (RST, congestion) |
Architecture
The key seam
The existing server architecture already provides a clean separation between router creation and listener binding:
// server.rs: production path
let (router, state) = create_app_with_prepared_workers(config, ...).await?;
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, router.into_make_service_with_connect_info()).await?;
turmoil tests reuse the same Router but bind it to turmoil’s simulated
listener instead. No production code changes are needed.
Adapter components
Three small adapter types bridge turmoil’s simulated network to axum and
hyper. All live in crates/batchalign/tests/turmoil_net.rs:
flowchart LR
subgraph "Server side"
axum["axum::serve()"]
tl["TurmoilListener\n(implements axum::Listener)"]
tl --> axum
end
subgraph "Simulated network"
net["turmoil::net\n(TCP/UDP simulation)"]
end
subgraph "Client side"
hyper["hyper Client"]
tc["TurmoilConnector\n(implements tower::Service)"]
ts["TurmoilStream\n(implements hyper::rt::Read/Write\n+ Connection)"]
tc --> ts --> hyper
end
net <--> tl
net <--> tc
TurmoilListener: wraps turmoil::net::TcpListener, implements
axum::serve::Listener. turmoil’s TcpStream implements tokio’s
AsyncRead + AsyncWrite directly, satisfying axum’s bounds.
TurmoilConnector: implements tower::Service<Uri>, resolves turmoil
hostnames via turmoil::lookup(), connects through the simulated network.
TurmoilStream: newtype around TokioIo<turmoil::net::TcpStream> that
additionally implements hyper_util::client::legacy::connect::Connection
(required by hyper’s legacy client API).
Test structure
Each test creates a turmoil::Sim with named hosts:
#[test]
fn health_check_under_partition() -> turmoil::Result {
let mut sim = turmoil::Builder::new()
.simulation_duration(Duration::from_secs(30))
.build();
// Server host: binds turmoil listener, serves axum router
sim.host("server", || async {
let listener = turmoil::net::TcpListener::bind("0.0.0.0:8001").await?;
let router = health_only_router();
axum::serve(TurmoilListener(listener), router.into_make_service())
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok(())
});
// Client host: makes HTTP requests through turmoil connector
sim.client("client", async {
let client = turmoil_http_client();
// 1. Verify connectivity
let resp = client.request(/* GET /health */).await?;
assert_eq!(resp.status(), 200);
// 2. Partition the network
turmoil::partition("client", "server");
// 3. Request should time out
let result = tokio::time::timeout(
Duration::from_secs(5),
client.request(/* GET /health */),
).await;
assert!(result.is_err());
// 4. Repair and verify recovery
turmoil::repair("client", "server");
let resp = client.request(/* GET /health */).await?;
assert_eq!(resp.status(), 200);
Ok(())
});
sim.run()
}
Virtual time: tokio::time::sleep() advances the simulated clock
instantly. A test that simulates 30 seconds of network behavior runs in
~10ms wall time.
Running the tests
# Run all turmoil tests
cargo test -p batchalign --test turmoil_net
# List available turmoil tests
cargo test -p batchalign --test turmoil_net -- --list
turmoil tests are part of Tier 1 (fast tests): no ML models, no Python,
no GPU. They run in the default cargo test and make test.
Current test scenarios
Infrastructure tests
| Test | What it exercises | Fault injected |
|---|---|---|
health_check_basic | Adapter correctness, server responds over simulated TCP | None (baseline) |
health_check_under_partition | Partition → timeout → repair → recovery | turmoil::partition() / repair() |
health_check_with_message_hold | Delayed response arrives after messages released | turmoil::hold() / release() |
concurrent_clients_health_check | 3 clients hit the server simultaneously | Concurrency (no explicit fault) |
server_crash_and_recovery | Server crash, bounce, new client reconnects | sim.crash() / sim.bounce() |
an operator/a user scenario tests
These model specific production problems the team experienced:
| Test | Real-world scenario | Fault injected |
|---|---|---|
one_way_partition_client_sends_but_no_response | a user submits jobs but never gets progress updates, responses are dropped | turmoil::partition_oneway() |
rapid_reconnection_burst_after_restart | Deploy restarts server, 5 dashboard clients (an operator, a user, fleet monitors) reconnect simultaneously | sim.crash() / sim.bounce() + concurrent clients |
network_flap_rapid_partition_cycles | Fleet machines on unstable WiFi, Tailscale connection flaps repeatedly | 5x partition() / repair() cycles |
slow_response_eventually_arrives | a user’s machine on slow WiFi, responses take 10+ seconds but eventually arrive intact | turmoil::hold() for 10s, then release() |
Real-app tests (require Python test-echo workers)
These use the actual batchalign router with MockConnectInfo and
create_test_app(). They take ~200-300ms each (real subprocess startup).
| Test | Real-world scenario | Fault injected |
|---|---|---|
real_app_submit_and_poll_job | an operator submits a job from the dashboard, polls for completion | None (baseline lifecycle) |
real_app_partition_during_job_processing | an operator submits a job, Tailscale drops, reconnects later, job completed during outage | turmoil::partition() / repair() |
real_app_sse_nonexistent_job_returns_404 | an operator bookmarks a deleted job URL, should get 404, not a hanging stream | None (error path) |
real_app_health_reports_workers | Health endpoint must report actual worker state | None (accuracy) |
turmoil fault injection API
The turmoil API for fault injection is declarative and operates on named host pairs:
// Network partitions (messages dropped silently)
turmoil::partition("client", "server"); // bidirectional
turmoil::partition_oneway("client", "server"); // one direction only
turmoil::repair("client", "server"); // restore connectivity
// Message buffering (simulates latency, enables reordering)
turmoil::hold("client", "server"); // buffer messages, don't deliver
turmoil::release("client", "server"); // deliver all buffered messages
// Host lifecycle
sim.crash("server"); // stop the host (clean teardown, not SIGKILL)
sim.bounce("server"); // restart the host's future from scratch
Partition behavior: under partition, TCP SYN packets are dropped silently.
The client’s connection attempt hangs (not “connection refused”). Use
tokio::time::timeout() to detect this, the virtual clock advances
instantly so the timeout resolves without wall-clock delay.
Known limitations
ConnectInfo solved via MockConnectInfo
axum provides MockConnectInfo<T>: a middleware layer that injects a
default ConnectInfo for all requests. turmoil tests apply this to the
router so submit_job’s ConnectInfo<SocketAddr> extractor works without
into_make_service_with_connect_info:
use axum::extract::connect_info::MockConnectInfo;
let router = router.layer(MockConnectInfo(SocketAddr::from(([10, 0, 0, 1], 0))));
AppState actor runtime parity (solved)
The batchalign AppState spawns background actors (JobRegistry,
RuntimeSupervisor, health monitors) via tokio::spawn during app creation.
turmoil hosts have their own simulated single-threaded runtime. Creating the
app inside turmoil doesn’t work because Python subprocess spawning needs real
wall-clock time.
Solution: create_real_test_app() creates a multi-thread tokio
runtime that hosts the background actors. The Router is extracted and
handed to the turmoil host for simulated network serving. The runtime stays
alive (leaked as &'static) so its worker threads keep polling the actors:
let mut app = create_real_test_app(&python);
let router = app.take_router();
let _app: &'static _ = Box::leak(Box::new(app)); // keep actors alive
sim.host("server", move || {
async move {
let listener = turmoil::net::TcpListener::bind("0.0.0.0:8001").await?;
axum::serve(TurmoilListener(listener), router.into_make_service()).await?;
Ok(())
}
});
tokio’s channels (UnboundedSender, oneshot) are runtime-agnostic, senders
on turmoil’s simulated runtime communicate with receivers on the real runtime’s
worker threads without issues.
Determinism gaps
turmoil intercepts tokio::net and tokio::time but does not intercept:
std::time::Instant::now(): dependencies using this get real wall-clock timegetrandom/HashMaprandomization, ordering may vary across runs- Any C library calls (rusqlite, sysinfo)
For our current tests (simple request/response assertions) this is not a
problem. If we add tests that depend on exact scheduling order, consider
adopting mad-turmoil which adds libc
symbol overrides for clock_gettime and getrandom.
Simplified TCP model
turmoil’s TCP is not RFC-compliant. No congestion control, no segmentation, no RST packets. Bugs that depend on real TCP behavior (half-open connections, congestion backoff) will not be caught. This is acceptable, our HTTP layer sits above TCP and does not interact with transport-level details.
Future work
SSE stream disconnect tests
The SSE endpoint (/jobs/{id}/stream) uses tokio::sync::broadcast to fan
out events. Testing client disconnect behavior requires:
- Submitting a job (needs
ConnectInfoworkaround or separate router) - Connecting to the SSE stream
- Dropping the client connection mid-stream
- Verifying the server doesn’t panic and other clients are unaffected
WebSocket resilience
Similar to SSE but using the WebSocket endpoint (/ws). The broadcast
subscription and tokio::select! loop in handle_ws() should handle
client disconnects gracefully.
Full server with test-echo workers
The current tests use a minimal health-only router. Wiring
create_test_app() into the turmoil simulation would enable testing the
full HTTP API (job submission, result download, SSE streaming) under network
faults. This requires either:
- Working around the
ConnectInfolimitation - Creating a turmoil-specific app factory that omits
ConnectInfo
Shuttle not viable for our concurrency primitives
Shuttle (AWS) was investigated for testing concurrency within the
server, but our highest-value primitives (tokio::sync::broadcast,
tokio::sync::Semaphore) are not modeled by shuttle (broadcast is a
stub that panics, Semaphore forwards to real tokio with no schedule
exploration). The supported primitives (Mutex, oneshot, mpsc) have
straightforward usage patterns with low race-condition risk; the full
tool-evaluation note for shuttle lives outside this public repo in
the maintainers’ tool-evaluations index.
Dependencies
turmoil and its adapter dependencies are dev-dependencies only: they do not appear in the release binary or affect production builds:
# crates/batchalign/Cargo.toml
[dev-dependencies]
turmoil = "0.7"
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] }
http-body-util = "0.1"
tower = { version = "0.5", features = ["util"] }
References
- turmoil repo
- Announcing turmoil (Tokio blog)
- Deterministic simulation testing for async Rust (S2.dev)
Tool evaluation: turmoil: full assessment including comparison to madsim and shuttle
This page last changed: 2026-07-30 (commit 5157a549). The whole book last changed: 2026-09-16 (commit 34d249d8).
Regression Fixtures
Status: Current Last updated: 2026-05-19 23:51 EDT
This page describes the per-command regression-fixture system: how it is laid out, how to add a new fixture when a user reports a bug, and how the runner verifies it. The intent is to monotonically grow batchalign3’s real-world test surface, every bug a user reports becomes a permanent regression that catches future drift.
Why this exists
Batchalign3 has rich unit tests, ML golden tests against fixed audio, and
TUI parity checks against legacy behavior. None of those capture the kind
of bug a real user finds when running the production CLI on a real corpus
file. Alignment quality issues, transcribe glitches, and CJK segmentation
regressions have historically lived in scattered emails and ad-hoc
/tmp/ experiment folders, and the bug came back as soon as the model or
the surrounding code shifted.
The regression-fixture system fixes that. Each reported bug becomes a
small command-shaped fixture directory under
test-fixtures/<command>/regressions/<bug-name>/. A Rust integration
test runs each fixture through the same in-process direct host the
production CLI uses and asserts a structural invariant on the output.
The bug then cannot silently regress: any future change that
re-introduces the failure mode will fail the test in CI.
Note on privacy: real-user bug reports usually involve real-corpus
audio and transcripts that belong to restricted-access corpora and
cannot be committed to this public repository. The runner in this repo
is generic scaffolding; the fixture content lives in a separate
private repository
TalkBank/<private-fixtures>
that maintainers clone locally and expose to the runner via the
BATCHALIGN3_PRIVATE_FIXTURES_DIR environment variable. The in-tree
test-fixtures/<command>/regressions/ directories are gitignored
below the per-command README level specifically so private material
cannot land in this public repo by accident. Contributors without
access to the private fixture repository will see the regression
tests skip gracefully rather than fail.
Directory layout
batchalign3/test-fixtures/
├── README.md # convention overview + JSON schema
├── align/
│ ├── README.md
│ └── regressions/ # fixture subdirs are gitignored
│ └── <bug-name>/ # staged locally from a private mirror
│ ├── README.md
│ ├── input.cha # optional CHAT input for CHAT-first commands
│ ├── input.<ext> # audio input or sidecar audio, depending on command
│ ├── actual.cha # current buggy output (reference)
│ └── source.json # typed manifest
├── transcribe/regressions/ # one per-command root each
├── morphotag/regressions/
├── utseg/regressions/
├── translate/regressions/
└── coref/regressions/
Per-bug directory naming convention: opaque slot names such as
align-regression-004/, transcribe-regression-001/, and so on.
The public test function names and the private fixture directory names
must match so the runner can resolve fixtures without exposing reporter
identity or corpus details in public code. Put the human-readable date,
reporter, and bug context inside the private fixture’s README.md and
source.json, not in the directory name.
The source.json schema
source.json is parsed into the typed
crate::common::regression_manifest::FixtureManifest struct in the test
binary. Every field has a domain newtype where it earns one. Sample:
{
"command": "transcribe",
"language": "eng",
"audio": "input.mp3",
"transcribe": {
"asr_engine": "rev_ai",
"wor": "omit"
},
"source": {
"report": "<opaque ref to private email thread>",
"original_chat": "<opaque ref to private source file>",
"trimmed_utterance_range": [60, 64],
"trimmed_audio_offset_ms": 362695
},
"bug": {
"summary": "<short plain-English description of the failure mode>",
"class": "transcribe_regression_harness",
"affected_main_tier_index": 0
},
"assertions": [
{
"kind": "no_zero_duration_wor_words",
"main_tier_index": 0
}
]
}
input_chat is required for CHAT-first commands and optional for audio-first
commands such as transcribe.
transcribe is optional and currently carries transcribe-local fixture
overrides such as ASR engine choice, %wor policy, and diarize=true.
assertions is a list of typed checks. Adding a new variant requires
defining it in regression_manifest.rs, implementing it in
regression_fixtures::run_one_assertion, and documenting it here. Do
not pre-build assertion variants; add only the ones a real fixture
needs. Utterance-scoped assertions carry main_tier_index; whole-output
assertions operate on the parsed ChatFile directly and do not.
Currently supported assertions
kind | Catches |
|---|---|
no_zero_duration_wor_words | FA emits all words in %wor but with start_ms == end_ms, or omits per-word bullets entirely. |
min_wor_word_duration_ms | DP collapse to end: the tail of the word sequence crammed into 40-100 ms per word. Threshold is per-fixture. |
min_last_wor_word_duration_ms | Last-word cutoff: the closing word of the utterance gets squished into a sliver. Threshold is per-fixture. |
max_wor_word_duration_proportion | First-word dominance: one word eats >N% of the utterance bullet. |
max_main_tier_lead_before_first_wor_ms | Stale utterance start: the main-tier bullet begins far before the first timed %wor word, often because an inherited parent start was preserved. Threshold is per-fixture. |
max_last_wor_overrun_past_main_end_ms | Main-tier cutoff/overrun mismatch: the last timed %wor word ends far past the utterance bullet end. Threshold is per-fixture. |
min_main_tier_utterance_count | Whole-output under-segmentation: transcribe or utseg collapses the clip into too few main-tier utterances. Threshold is per-fixture. |
max_first_main_tier_word_count | Front-loaded segmentation regression: the first emitted utterance grows implausibly large instead of splitting earlier. Threshold is per-fixture. |
no_wor_tiers_present | %wor policy regression: a fixture that intentionally requests wor=Omit still materializes %wor tiers. |
min_distinct_main_tier_speaker_count | Diarization regression: a fixture that requests diarize=true collapses back to too few distinct speaker labels. Threshold is per-fixture. |
media_header_matches_input_basename | Output-contract regression: the serialized @Media header stops preserving the input media basename and leaks a temporary/cached filename instead. |
When you find a bug: the workflow
-
Get a small, reproducible input. For CHAT-first commands, use a structured CHAT/audio trim tool that preserves timing bullets, rewrites the
@Mediaheader, and rebases word timings to the trimmed audio. For audio-first commands such astranscribe, stage the minimal audio clip the bug needs. Do not hand-roll a clip withffmpeg,head,tail, or any other improvised pipeline, the trim helper handles CHAT header preservation, timing-bullet rebasing,@Mediarewriting, and audio re-encoding fallback, and reinventing any of that produces fixtures that look right but silently mis-time alignment. -
Stage the command input into a new directory under
test-fixtures/<command>/regressions/<command>-regression-NNN/in your local checkout. Useinput.chafor CHAT-first fixtures andinput.<ext>for the required audio file. If the command consumes CHAT, rewrite the@Medialine so the staged audio resolves locally. -
Run the command in the production CLI to capture the buggy output as
actual.chafor documentation:# CHAT-first example batchalign3 --no-open-dashboard align input.cha --no-server --workers 1 cp input.cha actual.cha # audio-first example batchalign3 --no-open-dashboard transcribe input.mp3 --no-server -o out/ cp out/input.cha actual.cha -
Identify the bug class from the actual output and pick the assertion that captures it. If none of the existing variants fits, add a new one in
regression_manifest.rsand theregression_fixtures::harnessassertion runner, and document it in the table above. -
Write
source.jsonwith the manifest fields andREADME.mddescribing what is wrong and how to reproduce. Redact anything about the reporter’s identity or the private corpus path: use an opaque reference that the private fixture store can resolve, not a real name or home directory. -
Add a command-local test function to
<command>/regressions.rs:#[tokio::test] async fn transcribe_regression_001() { run_fixture("transcribe", "transcribe-regression-001").await }The helper
run_fixturedoes discovery, staging, dispatch, and assertion checking. If the fixture directory is not present locally the test skips cleanly. -
Run the test, confirm RED:
cargo test -p batchalign --features ml-golden --test ml_golden \ -E 'test(transcribe::regressions::transcribe_regression_001)' -
Commit the test function + the assertion logic to this public repo. Do NOT commit the staged fixture files (
input.cha,input.<ext>,actual.cha,README.md, orsource.json): those stay in the private fixture mirror. The fixture subdir is gitignored precisely so this cannot happen by accident.
Running the regression suite
The regression-fixture tests live in the ml_golden test binary so they
share its warmed worker pool with the other ML golden tests. They are
gated behind the ml-golden cargo feature and will not run on a normal
cargo test or make test invocation.
# Run every align regression fixture
cargo test -p batchalign --features ml-golden --test ml_golden \
-E 'test(align::regressions::)' --no-fail-fast
# Run every transcribe regression fixture
cargo test -p batchalign --features ml-golden --test ml_golden \
-E 'test(transcribe::regressions::)' --no-fail-fast
# Run a single fixture
cargo test -p batchalign --features ml-golden --test ml_golden \
-E 'test(transcribe::regressions::transcribe_regression_001)'
Tests skip cleanly when the corresponding fixture directory is missing locally.
How the runner works
The command-local tests/ml_golden/<command>/regressions.rs modules call
run_fixture, which does this for each fixture:
- Resolves the fixture directory in this order:
a.
$BATCHALIGN3_PRIVATE_FIXTURES_DIR/<command>/regressions/<bug>/, the recommended path. Point this env var at your local clone ofTalkBank/<private-fixtures>. b.<batchalign3-repo>/test-fixtures/<command>/regressions/<bug>/, the in-tree fallback, used only for fixtures whose content is verifiably safe to ship in the public repo. This path is gitignored below the per-command README level so private material cannot land here by accident. - Loads
source.jsoninto the typedFixtureManifest. If neither location has asource.jsonfor the requested(command, bug)pair, the test reportsSKIPrather thanFAIL. - Acquires a
LiveDirectSessionfrom the sharedml_goldenworker pool. Skips cleanly if the relevantInferTaskis unavailable. - Stages the command’s primary input into the session’s state directory:
input.chafor CHAT-first commands, the audio file for audio-first commands liketranscribe. When a staged CHAT also has sidecar audio, the runner copies that alongside it so@Mediaresolves locally. - Runs the command via
submit_paths_and_complete_directwith the manifest’s language and aCommandOptionsconstructed from the command type. - Parses the output CHAT via
talkbank_transform::parse::parse_lenient(at../chatter/crates/talkbank-transform/src/parse.rs:17) into a typedChatFileAST. Asserts no parse errors. - Walks every assertion in the manifest, running each one against the typed AST. Some assertions target one main-tier utterance; others inspect the whole parsed output. Failures are collected and reported together so the human reviewer sees all violations at once, not just the first.
The runner does no string hacking. All assertions operate on the typed
WorTier / Word / Bullet AST exposed by talkbank-model.
This page last changed: 2026-07-30 (commit 5157a549). The whole book last changed: 2026-09-16 (commit 34d249d8).
Investigation Probe Harnesses
Status: Current Last updated: 2026-05-19 22:52 EDT
batchalign3 uses Stanza as an oracle for investigation tests, small, per-case probes that pin Stanza’s current behavior so a future upgrade or regression surfaces as a test failure. This page is the developer reference for the two probe harnesses, when to use each, how cases are organized, how to run them, and how verdicts get locked.
Why probes, not assertions?
Traditional unit tests assert what code should do. Probe tests assert what an external library (Stanza) does, the probe’s job is to bind our pipeline’s expectations to the library’s current behavior. If Stanza changes, probes fail in a way that surfaces the change for re-review.
This pattern matters for batchalign3 because:
- Stanza’s MWT expansion varies per language, per token, per
version. Hardcoding “Stanza will produce 2 UD words for
don't” in a normal test is fragile, and wrong for languages where Stanza’s MWT model doesn’t fire. - Author-written expected POS / count values are biased. The author writes the test mirroring their expectation; the test passes trivially when code matches intent rather than reality.
- Stanza drift is invisible. Without probes, a Stanza model upgrade that changes tokenization on 5% of Italian inputs would be noticed only by users in production, not CI.
See feedback_empirical_before_assertions in memory for the
design principle: run real libraries in isolation before baking
RED expectations.
Two harnesses, two purposes
flowchart TD
Probe["Need to probe Stanza behavior"] --> Q["What question?"]
Q -->|"'Does Stanza split/merge\nthis input token correctly?'"| MWT["MWT Probe Harness\n(_probe_types.py + _cases/)"]
Q -->|"'Does normalization rule X\nchange Stanza's POS output?'"| Decision["Decision Probe Harness\n(_decision_probe_types.py +\n_decision_cases/)"]
MWT --> MWTGrain["Grain: ProbeCase\n- one input word sequence\n- expected_post_mwt_count"]
Decision --> DecGrain["Grain: DecisionProbeCase\n- pre/post word sequences\n- per-side Gold (UPOS+text)\n- n-to-m TokenMapping"]
MWTGrain --> MWTRunner["@pytest.mark.mwt_probe\ntest_stanza_mwt_probe_matrix.py"]
DecGrain --> DecRunner["@pytest.mark.decision_probe\ntest_stanza_decision_probe_matrix.py"]
MWT probe harness
Lives at:
batchalign/tests/investigations/_probe_types.py: types (ProbeCase,Phenomenon,XfailMark).batchalign/tests/investigations/_cases/<lang>.py: per-language case tables.batchalign/tests/investigations/_cases/__init__.py: theLANGUAGE_MATRIXregistry.batchalign/tests/investigations/test_stanza_mwt_probe_matrix.py, runner, paired (free-tokenize, with-postprocessor) per case.
Use when: you need to know “how many UD words does Stanza produce for this input token sequence?” Typical questions:
- Does Stanza MWT-expand
don'tinto two words? - Does our postprocessor suppress the expansion for Catalan
l'home? - Does Stanza’s Italian tokenizer keep
arancioneas one word or mis-split it?
Case shape:
ProbeCase(
label="dont_alone",
words=("don't",),
phenomenon=Phenomenon.CONTRACTION,
expected_post_mwt_count=2, # None = observe-only
xfail=None, # or XfailMark(defect_slug, reason)
)
Decision probe harness
Lives at:
batchalign/tests/investigations/_decision_probe_types.py, types (DecisionProbeCase,Gold,TokenMapping,DecisionOutcome,CandidateClass,StanzaTokenOutput,compare_stanza_outputs).batchalign/tests/investigations/_decision_cases/<lang>.py, per-language case tables (1 file today:english.py).batchalign/tests/investigations/test_stanza_decision_probe_matrix.py, runner.
Use when: you need to compare Stanza’s output on a pre-normalization form against its output on a post-normalization form, to decide whether a proposed rule helps, hurts, or is neutral. Typical questions:
- Does capitalizing bare
i→Ichange Stanza’s POS for that word? (Answer: no, both tag as PRON.) - Does stripping the period from
Dr.→Drproduce a different POS? (Answer: no, both tag as PROPN.) - Does stripping the period from
3.14→3change the text? (Answer: yes, catches the decimal semantic loss.)
Case shape (v2):
DecisionProbeCase(
label="dr_before_name",
utterance_prose="Dr. Matthews is here.",
pre_words=("Dr.", "Matthews", "is", "here"),
post_words=("Dr", "Matthews", "is", "here"),
affected_mappings=(
TokenMapping(
pre_token_indices=(0,),
post_token_indices=(0,),
gold=Gold(pre_upos=("PROPN",), post_upos=("PROPN",)),
),
),
expected_outcome=DecisionOutcome.POST_NEUTRAL,
rationale="Stanza PROPN both sides.",
candidate_class=CandidateClass.TITLE_PERIOD,
)
Lifecycle of a probe case
stateDiagram-v2
[*] --> Seed: Author writes case
Seed --> Observe: Golden run
Observe --> Adjudicate: Linguistic review
Adjudicate --> Locked: expected value set
Adjudicate --> Observe: Back to observation
Locked --> [*]: CI enforces
Locked --> Adjudicate: Library upgrade breaks assertion
Seed: "Seed\n(OBSERVE_ONLY or expected=None)"
Observe: "Observe\n(runner prints Stanza output;\nno assertion)"
Adjudicate: "Adjudicate\n(compare observation with\nexpected linguistic behavior)"
Locked: "Locked\n(expected_post_mwt_count set\nor expected_outcome non-OBSERVE_ONLY)"
Running probes
Probes are all @pytest.mark.golden so they do NOT run in
default CI. Sub-markers allow fine-grained selection:
| Command | Runs |
|---|---|
uv run pytest | Fast tests only, no probes |
uv run pytest -m "golden and mwt_probe" | All MWT probes (~45s on a development machine) |
uv run pytest -m "golden and decision_probe" | All decision probes (~4s) |
uv run pytest -m "golden and mwt_probe" -k "fra or ita" | French + Italian MWT only |
uv run pytest -m golden | All golden tests (includes probe matrices + other ML goldens) |
Use -n0 -s to serialize and show print output for observation.
Adding a new language to the MWT matrix
Five mechanical steps:
-
Add
LanguageKeyin_cases/__init__.py.CAT = LanguageKey(alpha2="ca", alpha3="cat") -
Add pipeline fixtures in
conftest.py. Pattern:@pytest.fixture(scope="module") def catalan_pipeline_with_postprocessor(): return _pipeline_with_postprocessor("ca", "ca") @pytest.fixture(scope="module") def catalan_pipeline_free_tokenize(): return _pipeline_free("ca")_pipeline_with_postprocessor/_pipeline_freequery Stanza’s runtime resources via_processors_for(lang)and includemwtonly if the language has a model. No need to hardcode. -
Wire the language key into both resolvers (
post_pipeline_forandfree_pipeline_for). Add an entry to each resolver’s fixture-name dict. -
Write
_cases/<lang>.pywith a tuple ofProbeCase. Start with observe-only (noexpected_post_mwt_count), the golden run tells you what Stanza produces; lock afterwards. -
Register the language in
LANGUAGE_MATRIXin_cases/__init__.py:LANGUAGE_MATRIX = { ..., CAT: catalan.CASES, }
Run uv run pytest -m "golden and mwt_probe" -k <alpha3> to
observe Stanza’s behavior and lock the expected counts.
Harness architecture
flowchart LR
subgraph Case["Case table\n_cases/<lang>.py"]
ProbeCase["ProbeCase\n(label, words, phenomenon,\nexpected_post_mwt_count,\nxfail)"]
end
subgraph Registry["Registry\n_cases/__init__.py"]
Matrix["LANGUAGE_MATRIX:\n{LanguageKey: tuple[ProbeCase]}"]
Flatten["all_cases() → [(LanguageKey, ProbeCase)]"]
end
subgraph Runner["Runner\ntest_stanza_mwt_probe_matrix.py"]
R1["test_stanza_mwt_probe_free_tokenize\n@pytest.mark.mwt_probe"]
R2["test_stanza_mwt_probe_with_postprocessor\n@pytest.mark.mwt_probe"]
end
subgraph Fixtures["Fixtures\nconftest.py"]
ResPost["post_pipeline_for(lang_key)"]
ResFree["free_pipeline_for(lang_key)"]
Proc["_processors_for(lang) →\ntokenize,pos,lemma,depparse[,mwt]"]
end
ProbeCase --> Matrix --> Flatten --> R1
Flatten --> R2
R2 -->|"lang_key"| ResPost
R1 -->|"lang_key"| ResFree
ResPost --> Proc
ResFree --> Proc
Proc -->|"queries Stanza\nresources.json"| Stanza["Stanza pipeline"]
Parity-audit pattern
The probe harness has a second lifecycle: comparing BA3 output against a reference system (e.g., BA2-jan9) to establish parity. Pattern:
- Enumerate every rule in the reference system.
- For each, identify a probe in BA3 that exercises the same behavior.
- If no probe exists, add one.
- Run the golden matrix. Observe per-rule outcomes.
- Classify each rule: full parity, parity via alternative mechanism, rule retired with evidence (reference rule is obsolete, new system handles natively), or active gap (reference handled; new system doesn’t).
The retokenization parity audit applies this pattern across the morphology tables, tokenization rule families, and native-MWT drift sentinels. All probed rules achieve parity except one active gap (Italian Defect 6 family), documented separately.
Use this pattern whenever you need to convince yourself that a rewrite preserves semantics, don’t rely on reading both codebases side-by-side; probe them both and compare.
Relation to production
Probe harnesses test raw Stanza output: what Stanza’s Python
pipeline produces when called directly. Production batchalign3
has additional layers downstream (Rust-side Range reassembly,
map_ud_sentence merging MWT components into single %mor
entries, etc.) that shape the final CHAT output.
This means:
- Probe
expected_post_mwt_count=2fordon'tasserts Stanza emits 2 UD words, not that the final CHAT %mor has 2 entries. (Final CHAT has 1 entry:verb|do~part|not.) - Probe-level regression does not necessarily mean user-visible regression, the downstream layers may absorb the change. But probe-level regression signals that something at the Stanza boundary shifted, which warrants investigation.
For end-to-end production behavior, see the %mor integration
tests under crates/batchalign-transform/src/morphosyntax/tests.rs
and the ML golden tests under batchalign/tests/golden/. Those
exercise the full pipeline including reassembly.
The probe-to-ship feedback loop
Probes are not an end in themselves; they feed a closed loop that turns empirical Stanza behavior into shipped Rust rules with regression coverage at every layer. The English transcribe corrections and the Italian Defect 6/7/8 reconciler both travelled this loop:
flowchart TD
Seed["Seed probe cases\n_decision_cases/<lang>.py"]
Run["Run probe matrix\npytest -m decision_probe"]
Classify["Classify verdicts\nPOST_NEUTRAL / POST_STRICTLY_BETTER /\nPOST_STRICTLY_WORSE / MIXED"]
Adjudicate{"Verdict?"}
Ship["Implement narrow rule\n(asr_postprocess/cleanup.rs,\nnlp/lang_<lang>.rs)"]
Lock["Lock verdict in case table\n(verdict= annotation)"]
Unit["Unit tests\n(allowlist + rule behavior)"]
Int["Pipeline integration tests\n(stage-ordering constraints)"]
E2E["End-to-end golden tests\n(AsrOutput → CHAT, or\nCHAT → %mor)"]
Defer["Defer / surface for review\n(xfail or adjudication queue)"]
Seed --> Run --> Classify --> Adjudicate
Adjudicate -->|"NEUTRAL / BETTER"| Ship
Adjudicate -->|"WORSE / MIXED"| Defer
Ship --> Lock
Ship --> Unit --> Int --> E2E
E2E -.->|"future Stanza upgrade drift"| Run
Two concrete instances of this loop:
- English transcribe rules. 29 English
decision-probe cases covered TITLE, PLACE, TIME, INITIALISM,
DEGREE, TECHNICAL, PRONOUN_I, I_CONTRACTION, UTTERANCE_INITIAL,
SENTENCE_PERIOD, DECIMAL_CONTROL families. 22 locked
POST_NEUTRAL, 2 POST_STRICTLY_WORSE (the DECIMAL_CONTROL / period
exclusions that prove the allowlist design), and the
etc./eg/ie/M.D.family was Q-B-adjudicated (Stanza POS preferred over hand-gold). The rules ship inasr_postprocess/cleanup.rs; verdicts stay locked in_decision_cases/english.py. - Italian Defect 6/7/8. Probe matrix
surfaced
parla → par + la,arancione → arancio + ne, and the compound-imperative family (dammela,prendilo, …). The adjudication routed to a Rust-side reconciler incrates/batchalign-transform/src/morphosyntax/lang_it.rs(two allowlists +map_ud_sentenceplumbing), with synthetic-UD tests incrates/batchalign/src/chat_ops/nlp/mapping/mod.rsand end-to-end golden coverage inbatchalign/tests/pipelines/morphosyntax/test_italian_defect6_end_to_end.py.
The feedback direction matters: probes lead, code follows. We do not write a rule and then probe to “confirm” it; we probe first, classify, then ship only when the verdict warrants it. A future Stanza upgrade that invalidates a locked verdict will surface as a probe diff, which re-enters the loop at the Classify node.
Related docs
reference/stanza-limitations.md: pinned Stanza defects (Defect 6, 7, etc.) cross-referenced from probe xfails.reference/retokenization-overview.md: per-language retokenization behavior summary; probe findings drive this doc.reference/languages/<lang>.md: per-language special treatment, with probe citations.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Model Downloads and Caching (Developer Reference)
Status: Current Last updated: 2026-09-16 09:28 EDT
This page documents how batchalign3 downloads, caches, and verifies ML models, the contributor-facing complement to the user-facing chapter. It is the authoritative inventory of every model load site, every cache location, and every download mechanism BA3 currently uses.
The on-demand download contract
batchalign3 has one rule for ML models: download on demand, transparently, or surface a real error. Concretely:
- Every model family auto-downloads on first use through the upstream
library’s standard mechanism (Stanza’s
DownloadMethod.REUSE_RESOURCES, HuggingFace’sfrom_pretrained(), torchaudio’spipelines.MMS_FA.get_model()). - No code in BA3 may opt out of these defaults. Specifically banned:
local_files_only=True,HF_HUB_OFFLINE/TRANSFORMERS_OFFLINEforced in BA3-controlled environment,DownloadMethod.NONE, or pre-flight existence checks that reject before the library would download. (One regression-gate test enforces this; see below.) - Any download that would block the worker for more than a second emits a
progress_v2event with user-facing wording, propagated to every UI surface. See the time transparency principle. - A real failure (network, disk, auth) surfaces as a typed error the orchestrator can classify and the user can act on. Silent return-None on failure is the bug pattern this contract was written to prevent.
This contract was made explicit on 2026-05-06 after a fresh-install code
path silently failed: BA3 swallowed Stanza’s ResourcesFileNotFoundError,
returned None from get_cached_capability_table(), and the Stanza
pre-flight gate translated the silent-None into “language not supported”
, misleading for a user whose Stanza catalog had simply never been
seeded. A single-host instance of that loop (orchestrator retry × worker
exit-1 × full Python traceback) generated multi-GB of server.log spam
per day.
Inventory: every model load site
Source verified by reading code on 2026-05-06.
| # | Family | Load site | Library | Cache root |
|---|---|---|---|---|
| 1 | Stanza morphosyntax | batchalign/worker/_stanza_loading.py:99 load_stanza_models | stanza.Pipeline(download_method=REUSE_RESOURCES) | Stanza DEFAULT_MODEL_DIR |
| 2 | Stanza utseg | _stanza_loading.py:280 load_utseg_builder | (same) | (same) |
| 3 | Stanza Chinese retok | _stanza_loading.py:235 load_stanza_retokenize_model | (same) | (same) |
| 4 | Stanza coref (lazy) | batchalign/inference/coref.py:66-68 | stanza.Pipeline(...) | (same) |
| 5 | Whisper ASR | batchalign/inference/asr.py:119 load_whisper_asr | transformers.pipeline + WhisperProcessor.from_pretrained | HF |
| 6 | Whisper FA | batchalign/inference/fa.py:114 load_whisper_fa | WhisperForConditionalGeneration.from_pretrained + WhisperProcessor.from_pretrained | HF |
| 7 | Wave2Vec FA | batchalign/inference/fa.py:198 load_wave2vec_fa | torchaudio.pipelines.MMS_FA.get_model() | torchaudio hub |
| 8 | Cantonese FA | batchalign/inference/languages/cantonese/_cantonese_fa.py load_cantonese_fa | Wav2Vec2ForCTC.from_pretrained | HF |
| 9 | SeamlessM4T translation | batchalign/worker/_model_loading/translation.py::_load_seamless_translate | AutoProcessor.from_pretrained + SeamlessM4TModel.from_pretrained | HF |
| 9b | NLLB-200 translation | batchalign/worker/_model_loading/translation.py::_load_nllb_translate | AutoTokenizer.from_pretrained + AutoModelForSeq2SeqLM.from_pretrained (facebook/nllb-200-distilled-1.3B, ~5 GB) | HF |
| 10 | pyannote diarization | batchalign/inference/speaker.py:350 | Pipeline.from_pretrained("talkbank/dia-fork") | HF |
| 10b | Pyannote speaker embedding | batchalign/inference/speaker_embedding.py::load_speaker_embedding_model | PretrainedSpeakerEmbedding(<pinned local ONNX path>) | HF |
| 11 | NeMo speaker (fallback) | batchalign/inference/speaker.py (NeMo branch) | EncDecSpeakerLabelModel.from_pretrained(...) | NeMo cache |
| 12 | BERT utterance (boundary model) | batchalign/worker/_model_loading/utterance.py::load_utterance_model resolves the pinned snapshot; batchalign/models/utterance/infer.py::BertUtteranceModel loads it | resolve_pinned_snapshot, then AutoTokenizer.from_pretrained + BertForTokenClassification.from_pretrained on the resolved LOCAL PATH | HF |
| 13 | PyCantonese | (bundled) | , | (none, wheel) |
| 14 | Model manifest | crates/batchalign/src/model_manifest.rs | None: it loads nothing. It is the one place naming which models each ASR engine and each utterance-segmentation language loads, and at which revision | (none) |
| 15 | Pinned Hugging Face snapshot | batchalign/worker/_model_loading/pinned_hub.py::resolve_pinned_snapshot | huggingface_hub.snapshot_download(model_id, revision=…), then the commit is read off the resolved directory | HF |
Cache roots resolve to OS-specific paths via each library’s own logic. See the user-facing chapter for the table of OS-resolved paths.
Rows 14 and 15: the pinned path
Row 14 is a manifest, not a loader. It exists so a model’s identity is known
BEFORE dispatch: it is what the request carries, what the worker’s report is
checked against, and what a cache namespace is built from. Hugging Face
entries pin a commit, native Whisper weights pin the lfs.oid of the blob
(which is its SHA-256), and ModelScope entries pin a TAG, because ModelScope
exposes no commit behind one.
It covers the ASR engines and the utterance-boundary models. The two are in one
file because they have one problem and one answer, not because they are one
subsystem; a second manifest beside it would recreate the mirrored-table defect
that file exists to remove, so a new pinned model belongs in a table there. The
boundary models additionally carry the language-to-model map that used to live
in _RESOLVER["utterance"] on the Python side, because an id must be known
before a load in order to pin its revision. utseg_route reads availability
from the same table, so a language BA3 claims to segment is by construction a
language it can name a model for.
The boundary model travels to the worker under PINNED_UTSEG_MODEL_KEY
(utseg_pinned_model), injected at the one place a spawn argv is built, exactly
as PINNED_ASR_MODELS_KEY is. Its revision is consequently required rather than
optional: UtsegBoundaryModelEvidenceV2.model_revision is a HubCommitV2, and
provenance always writes <model id>@<revision>. Before the pin, the worker
loaded the model by NAME and scraped config._commit_hash afterwards, which can
simply be absent, so the field had to be optional and the stamp had an id-only
form.
Row 15 is why the pin holds. FunASR’s Hugging Face branch IGNORES the
revision it is given (get_or_download_model_dir_hf calls
snapshot_download(model) with no revision), so passing model_revision
there pins nothing; the only way to hold a loader to a revision is to resolve
the snapshot first and hand it a local path. The commit is then read off the
resolved directory, because the hub cache stores a revision at
<cache>/models--<org>--<name>/snapshots/<commit>/, so the leaf directory IS
the commit and describes the bytes about to be read. Two refusals follow from
that, both PinnedSnapshotError: a leaf that is not a 40-character
hexadecimal commit (the layout is not what the function understands, so the
worker cannot say which revision it loaded), and an observed commit that
disagrees with the one the plan asked for. A commit of None means the plan
deliberately left the model unpinned; the hub default is resolved and the
commit it landed on is reported.
A drift checker against the upstreams is deliberately NOT in the server binary.
The comment in model_manifest.rs records why: it needs the network, so it
belongs beside the other drift checks in scripts/. It lives at
scripts/check_model_pin_drift.py, described next.
Checking the pins against upstream
Pinning is not a freeze. A pin stops output changing for reasons nobody chose; it is not a decision to stay on a revision forever. The drift checker is the other half of that bargain, and it is the mechanism by which a pinned model gets upgraded DELIBERATELY. It turns “upstream moved” into a reviewed decision with a date and a reviewer, following the pattern this project already uses for its morphosyntax engine: find a defect, report it upstream, upgrade once it is fixed, then rerun the affected material surgically. Without it, a pin freezes the project quietly, which inverts the intent of pinning.
python3 scripts/check_model_pin_drift.py
python3 scripts/check_model_pin_drift.py --manifest <path> # check a variant
It reads the pins from crates/batchalign/src/model_manifest.rs rather than
carrying a second copy of them: a second list would recreate the mirrored-table
defect that file exists to delete, and it would be the copy that goes stale. The
read is a narrow parse of the Rust source, guarded by a cross-check. If the
parse does not recover exactly as many entries as the file holds entry literals,
it refuses to report anything at all rather than return a clean result for a
model it never looked at.
It queries PUBLIC APIs only: no credentials, no writes, no model downloads. It never consults a local model cache, because a cache would only say what one machine happens to hold, and the question here is what UPSTREAM holds.
| Exit | Meaning |
|---|---|
| 0 | Every checkable pin still matches upstream. |
| 1 | Some upstream has MOVED away from its pin; the report names both revisions. |
| 2 | Some check COULD NOT RUN, so the answer is unknown. |
2 outranks 1 deliberately: a run that both moved and failed exits 2, because an unknown is worse than a known difference.
Each pin gets one of three outcomes and never two: it matches, it moved, or the check could not run and says why. Conflating “no drift” with “could not check” is the failure this shape exists to prevent, so a network failure, a rate limit, a withdrawn repository and a private repository are each reported by their own reason rather than collapsed together.
What the checker cannot see
Two verdicts are NOT matches. The report keeps them separate and counts them separately, so a green exit is never read as “every model was confirmed unchanged”:
TAG-ONLY: ModelScope publishes tags but exposes no commit behind a tag, so a tag pin can be checked only for tag EXISTENCE. The threeiic/...FunASR models are in this class. A tag that upstream re-points at new bytes is invisible here. A tag that disappears is caught, and is reported as drift.NOT-OBSERVABLE: cloud providers (aliyun-nls,revai) expose no revision at all, so drift there cannot be observed from outside. Tencent’s engine model type is in the same class; it is derived per language rather than stored as a manifest entry, so it does not appear as its own row.
Neither raises the exit code, because both are permanent properties of the source rather than failures of a run.
One further limit is worth stating plainly: a Hugging Face lookup for a
repository that has been renamed, withdrawn, or made private answers 401 in
every one of those cases. The checker therefore reports all three possibilities
instead of guessing one. Telling them apart would need credentials, which this
check deliberately does not use.
Stanza DEFAULT_MODEL_DIR (1.11+)
Resolves via os.getenv('STANZA_RESOURCES_DIR', os.path.join(USER_CACHE_DIR, 'resources'))
in stanza/resources/common.py:38-41. USER_CACHE_DIR is the platform
cache plus a versioned subdirectory:
- macOS:
~/Library/Caches/stanza/<resver>/resources/ - Linux:
~/.cache/stanza/<resver>/resources/ - Windows:
%LocalAppData%\stanza\<resver>\resources\
The historical ~/stanza_resources/ from older Stanza versions is no
longer used and any references to it in BA3 docs are bugs to fix.
<resver> is the resource-format version (e.g., 1.11.0), independent of
the package version (e.g., 1.11.1).
HuggingFace cache resolution (current)
Order: HF_HUB_CACHE env > HF_HOME env > default
(~/.cache/huggingface/hub on Unix, %LocalAppData%\huggingface\hub on
Windows). The legacy TRANSFORMERS_CACHE is no longer consulted by
current huggingface_hub; do not reintroduce it.
Catalog bootstrap (Stanza-specific)
Stanza ships its package code without resources.json. The catalog must be
downloaded once before any language pack can be resolved. BA3 does this
automatically:
_stanza_capabilities.py:get_cached_capability_table()callsbuild_stanza_capability_table(), which callsstanza.resources.common.load_resources_json().- On
ResourcesFileNotFoundError(a subclass ofFileNotFoundError),_bootstrap_and_retry()callsstanza.resources.common.download_resources_json(), emits start/completeprogress_v2events, and rebuilds the table. - A real download failure raises typed
StanzaCatalogDownloadError; the orchestrator should classify this as non-retryable at the worker- bootstrap layer (filed separately). ImportErroronimport stanzais the one legitimate silent-None path: it means the worker venv lacks the package, which is a deploy-config error, not a recoverable miss.
The pre-flight capability table itself remains the right thing for
rejecting languages Stanza does not actually have processors for (e.g.
que). It MUST NOT block on missing-but-downloadable resources. That
distinction is what the catalog bootstrap exists to enforce.
User-visible download notifications
Every download site emits a progress_v2 event so the user sees what’s
happening. The shared helper lives at
batchalign/worker/_progress.py:
emit_download_event(stage, user_message, request_id=None, size_bytes_estimate=None), generic, used for non-HF downloads (Stanza catalog, Stanza language packs, torchaudio bundles).emit_hf_download_if_missing(model_id, kind, request_id=None): probes the HuggingFace cache viatry_to_load_from_cache; emits only when the model is genuinely about to download. Wraps everyfrom_pretrained()call.
Sample: every HF load site looks like
from batchalign.worker._progress import emit_hf_download_if_missing
emit_hf_download_if_missing("openai/whisper-large-v3", kind="ASR")
pipe = pipeline("automatic-speech-recognition", model="openai/whisper-large-v3", ...)
The wrapping is cheap (one cache probe), idempotent for cached models (probe returns hit, no event emitted), and safe under failure (probe exceptions log debug-level and emit anyway, a false-positive notification is a much smaller UX cost than a silent multi-minute wait).
User-message wording must convey four things: what’s downloading, the
approximate size, that it’s a one-time cost, and that future runs will be
instant. Size hints for the largest models are tabulated in
_progress.py _HF_SIZE_HINTS_GB; expand the table when adding new
families.
Audit gates (regression prevention)
A static check in batchalign/tests/test_progress_audit.py (planned)
asserts that no new code reintroduces opt-outs. Specifically, it greps for:
local_files_only=Truein anyfrom_pretrained()callHF_HUB_OFFLINEorTRANSFORMERS_OFFLINEset inside BA3-controlled environment construction (test environments may set them externally, which is fine)DownloadMethod.NONEin any StanzaPipeline()call- pre-flight existence checks that raise before the library would download
If any future PR needs an exception (e.g., an offline-test fixture), it must be opt-in via a code-path-specific flag, not a default.
Pipeline-result caching (orthogonal to model caching)
batchalign3 caches audio-task evidence in a tiered cache so repeated
align, selected transcribe stages, and standalone diarize do not repeat
expensive inference.
This is unrelated to ML-model caching: result evidence has semantic keys and
revision identities of its own.
| Layer | Storage | TTL | Location |
|---|---|---|---|
| Hot | moka in-memory | Per-process lifetime | RAM |
| Cold | SQLite | Persistent | ~/.cache/batchalign3/cache.db (Linux), ~/Library/Caches/batchalign3/cache.db (macOS), %LocalAppData%\batchalign3\cache.db (Windows) |
Cached task kinds are enumerated in
crates/batchalign/src/chat_ops/cache_key.rs::CacheTaskName: forced-alignment
projection, raw forced-alignment worker evidence, normalized UTR ASR, raw Rev
transcript evidence, raw speaker evidence, and derived speaker segments. Cache
keys include the task’s relevant combination of:
- Evidence schema and algorithm/preparation revision
- Language code
- Engine/model revision
- Relevant per-task inputs
Engine identity comes from the selected worker
There is no pipeline-wide engine version. PipelineServices
(crates/batchalign/src/pipeline/mod.rs) carries only the worker pool and the
cache, and each stage names its own engines: morphosyntax, utterance
segmentation, translation and coreference from the results they apply, and UTR
ASR through its engine’s own cache namespace. Forced alignment is the only
stage that reads an engine identity from a worker’s capability report, because
its cache rows are namespaced by that engine before inference runs.
That identity is resolved from the exact typed worker route, not from whichever
worker first populated the pool’s availability snapshot. WorkerKey owns
target, language, and engine recipe. Command dispatch obtains that worker, and
WorkerPool::ensure_command_capabilities completes any lazy ensure_task for
FA and takes the worker’s report after that load (LoadedCapabilities). Only
then does FaCacheNamespace::from_loaded construct the FA identity, which
forced alignment carries in FaServices and uses for its cache rows and
evidence envelopes. A worker that still names no FA engine after loading is
refused.
Lazy-profile keys retain engine selection. This is required for correctness,
not only cache hygiene: a task-only key could load Wave2Vec once, report
already_loaded to a later Whisper request, and let concurrent requests change
process-global model state. Engine-specific keys make those states
unrepresentable; host worker permits and idle eviction still bound memory.
Raw evidence remains payload-validated on replay. That is defense in depth,
not a substitute for an honest namespace. The SQLite migration
20260831000000_fa_raw_evidence_engine_namespace.sql repairs this historical
boundary once when a cache is opened. A schema-2 payload owns the exact
selected-worker version and can therefore repair a contradictory database
label. A schema-1 payload owns only the requested engine family; when that
family contradicts the stored version family, the migration copies the exact
row into cache_quarantine with a stable reason and removes it from live
lookup instead of inventing producer provenance. Correctly labelled schema-1
rows remain replayable under their explicit legacy_cache_namespace origin.
The cache-stats command reports quarantined counts by reason without counting
them as live hits.
Row 10b downloads NOTHING new. It resolves the embedding node of the same
manifest row 10 uses, by the same exact Hub commit, through the same
pinned-artifact loader. The difference is that it constructs the embedding
model STANDALONE instead of as part of a SpeakerDiarization pipeline. That
matters operationally rather than only architecturally: the diarization
pipeline class unconditionally loads a PLDA calibration artifact from a gated
repository (see the diarize page), so
diarization needs a Hugging Face token where embedding does not. A machine with
no Hugging Face account can embed and cannot diarize.
Local Pyannote has an additional cross-language identity rule. The JSON
manifest at batchalign/inference/local_pyannote_model.json is the single
owner of the exact pipeline, segmentation, and embedding Hub commits and
artifact SHA-256 digests. Python downloads the pinned revisions, copies each
artifact into a private directory while hashing its bytes, and refuses any
digest mismatch before loading the pipeline. Segmentation and embedding loaders
receive these verified local paths; the worker retains the snapshots for lazy
backend reads. This admits the packaged trusted model bytes, not arbitrary
checkpoint files, and does not make general pickle loading safe. Rust
hashes the identical packaged bytes into SpeakerEvidenceModelRevision.
Changing any graph node therefore invalidates raw speaker evidence without a
second hand-maintained version constant or a drift-detection test.
Text NLP tasks (morphotag, utseg, translate, coref) are NOT
cached, running them twice runs the model twice.
Raw versus derived revisions
Raw paid-service evidence and locally derived projections have different
identities. Changing a speaker normalization algorithm bumps only
SpeakerNormalizationRevision; the retained provider response remains usable.
Changing the provider request, prepared media bytes, backend/model revision, or
raw schema changes the raw key. Rev evidence follows the same rule: preserve
the provider-shaped transcript, then rerun local conversion independently.
Rev’s provider-media boundary is explicitly typed. Production constructs
PreparedRevProviderMedia from source bytes, recording their BLAKE3 digest,
the revisioned preparation recipe, and a normalized upload filename. The raw
request key also includes the multipart MIME and request-policy revision. On a
typed cache miss, RevAsrInferenceAuthorization is consumed into exactly one
AuthorizedRevEvidenceRun and one private commit permit. Immediately before
upload, the run rereads the file and refuses ProviderMediaDrift if its bytes
no longer match the keyed digest. Both language identification and
transcription receive the same owned verified byte buffer and presentation.
For auto language, those are two intentional Rev requests inside one typed
evidence run; explicit-language transcription needs only the latter.
Request-identity revision 2 changes the earlier key because revision 1 did not
fully identify the provider-visible multipart presentation. Storage schema 3
is separate: the HTTP boundary reads transcript application-body bytes,
requires strict UTF-8 JSON, and retains that exact sequence including unknown
provider fields without changing a revision-2 request key. It never assigns
exact fidelity after lossy or normalizing text decoding. Existing schema-2
envelopes therefore remain replayable as legacy_typed_projection; new
responses are exact_provider_json. CachePolicy::RequireCache fails closed
on a true request miss and never turns a storage migration into a service call.
The present recipe is SourceBytesLegacyAudioMpegV1: source bytes, normalized
provider-media.<extension> filename, digest-derived metadata, and the
historical audio/mpeg multipart MIME. Do not interpret that name as a quality
endorsement. A future WAV, FLAC, MIME-correct, padding-retained, or other recipe
must be a distinct enum variant with deterministic tests and a distinct key.
RevAsrEvidenceResolution::trace() uses a private trace seed captured by the
resolver from the exact request. Callers therefore cannot pair one request’s
media and cache identity with another resolution’s cache outcome. The method
adds the explicit downstream projection revision and produces schema-2
RevAsrEvidenceTrace. DebugDumper::dump_rev_evidence() atomically persists
it for Rev transcribe and Rev-backed UTR runs when --debug-dir is enabled,
using the collision-resistant full-input identity. Serialization or
durable-write failure is a file error, not a best-effort log message.
The transcribe pipeline receives the Rev inference boundary as a
RevAsrEvidenceInference capability. This is dependency injection without an
authorization escape hatch: the trait method accepts only the consuming
AuthorizedRevEvidenceRun created behind resolve_rev_asr_evidence(). A
pipeline-level counting fake can consequently prove cold-versus-replay
behavior through CHAT construction, while production still cannot submit a
request directly from an untyped media path or Boolean cache miss.
RevAsrEvidenceTrace distinguishes causal origin from semantic projection.
Cold and replayed runs have different cache outcomes by design. Their typed
semantic projection consists of the complete request trace seed, retained
transcript fidelity, and named downstream projection revision. Tests compare
that projection instead of deleting arbitrary JSON fields or incorrectly
requiring the two causal records to be byte-identical.
Transcribe names the ASR projection revision
rev-transcript-to-asr-response-v1; UTR names its narrower timed-word
projection rev-transcript-to-utr-asr-response-v1. Partial UTR windows use a
stable raw-cache-key suffix so multiple evidence records cannot overwrite one
another.
--require-media-cache is the experiment guard. It selects
CachePolicy::RequireCache; raw Rev/speaker misses fail before inference
authorization, and unresolved FA groups cannot construct
FaInferenceAuthorization. --override-media-cache is the opposite policy:
it intentionally refreshes and replaces evidence. The CLI and HTTP admission
reject a job that asks for both.
Test strategy
Unit tests (no network, no models)
Bootstrap behavior under mocked filesystem and Stanza APIs lives in
batchalign/tests/test_stanza_capabilities.py. The three load-bearing
cases:
test_bootstrap_downloads_catalog_when_missing:resources.jsonabsent + download succeeds → populated table returned.test_bootstrap_raises_typed_error_on_download_failure: absent + download fails →StanzaCatalogDownloadError.test_stanza_not_installed_returns_none:ImportError→None(unchanged silent-None path, the only legitimate one).
These run in the default pytest profile (no -m golden needed); they
mock all I/O.
Golden tests (real models, network on first run)
Tests that load real ML models are marked @pytest.mark.golden and
excluded from the default pytest run:
uv run pytest -m golden # Python golden tests
cargo test -p batchalign --features ml-golden --test ml_golden # Rust ML golden tests
Models download automatically on first run. Subsequent runs use the cache. First-run download is slow (minutes for Stanza, longer for Whisper).
Fresh-install integration test
batchalign/tests/test_fresh_install_stanza_bootstrap.py nukes the
Stanza cache, walks the bootstrap path, and asserts the catalog
auto-downloads. This is the canonical regression gate for the
on-demand contract: if it fails, BA3 has reintroduced a download
opt-out somewhere.
OOM protection in golden tests
On machines with < 128 GB RAM, the conftest.py guard forces golden tests
to run sequentially (-n 0) even if the default pytest.ini specifies
parallel workers. Each Stanza model instance uses 2-5 GB, parallel
workers on a 64 GB machine OOM-crash. The guard cannot be bypassed; it
fires per-test inside xdist workers via an autouse fixture.
PyCantonese tests run in the default suite because PyCantonese is bundled (no download needed) and fast (~3s for all segmentation tests).
What to expect on first run
| Test suite | First-run download | Subsequent runs |
|---|---|---|
uv run pytest (default) | PyCantonese: 0s (bundled) | < 1s |
uv run pytest -m golden | Stanza English: ~2 min, Stanza Chinese: ~2 min | < 30 s |
cargo test -p batchalign --features ml-golden --test ml_golden | Stanza + Whisper: ~5-10 min | < 2 min |
Adding a new model load site
- Identify the upstream library’s auto-download API (
from_pretrained,Pipeline,get_model, etc.). Use it as-is. Do not pre-flight-check. - If the model is one a stage loads BY DEFAULT rather than only under an
explicit override, add a manifest pin in
crates/batchalign/src/model_manifest.rs(a commit for Hugging Face, a tag where the hub exposes no commit) and load it throughresolve_pinned_snapshot, pointing the library at the resolved LOCAL PATH rather than at the model name. A default model with no pin has no identity before it loads, which means no honest provenance stamp and no cache namespace; and a model loaded by name cannot report a revision it was never asked for, which is what forces a revision field to be optional. - Add a
progress_v2emit immediately before the load:- HuggingFace:
emit_hf_download_if_missing(model_id, kind=...). - Stanza language pack: extend the helper in
_stanza_loading.py(or copy its shape). - Other libraries: use
emit_download_event(stage, user_message).
- HuggingFace:
- Add a size-hint entry to
_HF_SIZE_HINTS_GBif the model is > 100 MB, so the user sees a useful estimate. - Update the user-facing chapter table with the new family + size + first-run wait estimate.
- Update this page’s inventory table.
- Add a golden-marked test that exercises a fresh download path.
Related references
- User-facing model-downloads chapter.
- Time transparency principle.
- The contract enforcement code:
batchalign/worker/_stanza_capabilities.py,batchalign/worker/_progress.py,batchalign/worker/_protocol.py. - Bootstrap regression tests:
batchalign/tests/test_stanza_capabilities.py.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
API Stability
Status: Current Last updated: 2026-04-30 23:00 EDT
The CLI is the only public surface
batchalign3 is a CLI-first tool. The compatibility surface that
public consumers may depend on is:
- the
batchalign3command-line interface (subcommands, options, output formats, exit codes) - the typed CHAT files written to disk by
batchalign3commands - the OpenAPI schema exported by
batchalign3 openapifor the HTTP server, if you run a server
That is the entire compatibility surface. The Python runtime, the
PyO3 extension module, the worker IPC payloads, and every internal
module under batchalign.* are implementation details. They are not
covered by any compatibility guarantee and may change without notice.
Python is internal
There is no public Python API. The Python code in this package exists to host worker-side ML inference (Stanza, Whisper backends, Cantonese ASR engines, etc.) on behalf of the Rust runtime. It is not a library you can import against.
This includes:
- everything under
batchalign.worker.* - everything under
batchalign.inference.* - the
batchalign.providersre-export module - the
batchalign_corePyO3 extension module - every previously-documented Python facade (
pipeline_api,compat,BatchalignPipeline,WhisperEngine,CHATFile,Document,ParsedChat,run_pipeline(), etc.)
If you have BA2-era Python integration code, port it to subprocess
calls into batchalign3. The CLI’s flags and outputs are the
long-term stable contract.
The intended long-term direction is to remove Python entirely as Rust gains coverage of the remaining ML pieces. The current Python surface is therefore a deliberately shrinking layer, not a shape to preserve.
Beta caveat
The project is in beta. The CLI surface, output formats, and configuration files are not yet frozen, breaking changes may land during the pre-1.0 stabilization period. Breaking changes will be documented but may land without a deprecation period while the project remains in beta. The public API surface will be formally frozen at the 1.0 release.
See also
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Adding Inference Providers
Status: Current Last updated: 2026-09-16 03:36 EDT
Batchalign3 no longer has a public entry-point plugin system. New engines are added in-tree as built-in worker capabilities.
This page covers the current extension path.
If you are adding a new command, declare it as one CatalogEntry in
crates/batchalign/src/recipe_runner/catalog.rs (choosing its CommandFamily
there) with its stage recipe in recipe_runner/recipes.rs, and keep any
algorithmic or orchestration logic in the owning Rust module. Engine work should
support that Rust-owned command flow; it should not define the command shape on
its own. See Adding a New Command.
Choose the layer first
There are two different things you might be adding:
- A new worker-side inference backend such as a new ASR or FA engine.
- A new server command that needs Rust-side orchestration plus, optionally, a new worker inference task.
Most engine work starts in Python and only touches Rust for typed IPC contracts, command registration, and server orchestration.
Adding a worker-side inference backend
1. Add the inference module
Create a built-in module under batchalign/inference/ that exposes a pure
inference helper consumed by a typed V2 worker host:
from __future__ import annotations
from batchalign.worker._types_v2 import MyTaskItemV2, MyTaskResultItemV2
def infer_my_task(items: list[MyTaskItemV2]) -> list[MyTaskResultItemV2]:
results: list[MyTaskResultItemV2] = []
for item in items:
results.append(MyTaskResultItemV2(ok=True))
return results
Keep these modules CHAT-free. Python workers should accept structured payloads and return structured results only.
2. Add or reuse the task identifier
If this is a new live infer task, add it in the V2 IPC type definitions:
batchalign/worker/_types_v2.pycrates/batchalign-types/src/worker_v2/(re-exported bycrates/batchalign/src/types/worker_v2.rs)
If you are only adding a new engine behind an existing task such as ASR or FA, reuse the existing task and add only the new engine selector/state.
3. Load model state in the worker
Update batchalign/worker/_model_loading/ so load_worker_task() can
initialize the new engine for the relevant infer task. This is where task-level
engine overrides are resolved and worker state is populated.
For existing command families, you usually update one of:
load_asr_engine()load_fa_engine()load_translation_engine()load_stanza_models()inworker/_stanza_loading.py
4. Wire dispatch and capability advertisement
Update:
batchalign/worker/_execute_v2.pyto route the task or enginebatchalign/worker/_text_v2.pyif the task belongs to the shared batched text hostbatchalign/worker/_handlers.pyto advertiseinfer_tasks, and, for forced alignment only, to name the engine inengine_versions
A capability report carries task support plus the FA identity only.
_reported_engine() names FA’s engine and returns None for every other
task; Rust admission refuses a report that names an engine for any other task
(EngineNamedForNonFaTask). If the new engine is an FA variant, keep the task
stable and make _reported_engine() name the FA engine that actually loaded.
Report None until the process can name it (FA reports None until an FA
engine has loaded); never a guessed name or "unknown". Set an identity where
the loader loads the model, as the translation loaders build one
LoadedTranslation record (backend and engine together) on _state. A name
must be a valid ReportedEngineName, a wrapper over StampSafeText:
non-blank, no surrounding whitespace (the Unicode White_Space set, written
out as _STAMP_WHITESPACE in batchalign/worker/_types.py), and none of |,
;, ] or a line break.
Where the engine is named depends on when the server needs it:
- On results (translate, coref, morphosyntax). Each result item names the
engine or model that produced it:
translatedandresolveditems carryengine, andanalyzeditems carrymodelwith the pipeline variant. Provenance is built from the results a file applied, so these commands never read the capability report for identity and never fail a job for aNonereport. A new engine for a text task should name itself the same way. - Before dispatch (forced alignment only). FA cache rows are read under
the FA engine’s namespace before any worker runs, so FA’s identity comes
from the capability report. Two different facts are read at two moments.
Whether the worker SUPPORTS a command’s primary infer task is
command_supportedincapability.rs, which decides what/healthadvertises and which jobs are accepted. Whether the FA engine is NAMED is read at dispatch:WorkerPool::ensure_command_capabilitiesloads the task on the selected worker and returnsLoadedCapabilities(the task it loaded and the report taken after that load), and the forced-alignment arm inrunner/routing.rsreads the engine withFaCacheNamespace::from_loaded(crates/batchalign/src/engine_reports.rs). That constructor refuses a report taken after another task loaded, an engine still unnamed after the load, and a worker that does not support FA. So a lazily loading worker, or a registry daemon probed before it loaded FA, still advertises and acceptsalign; dispatch loads FA and reads the engine then.FaCacheNamespaceis a plain newtype overReportedEngineName, and that dispatch arm is the only place a pre-dispatch identity is required; no catalog field declares one.
Capability gate (critical): The _capabilities() function in _handlers.py
uses import probes to decide which infer tasks to advertise. If you add a new
InferTask, you must add it to the _INFER_TASK_PROBES dict with the tuple of
Python modules that must be importable, and give it an arm in the exhaustive
_reported_engine() match:
_INFER_TASK_PROBES: dict[InferTask, tuple[str, ...]] = {
...
InferTask.MY_TASK: ("my_library",),
}
Capabilities are detected lazily from the first real worker spawn, there is no
dedicated probe worker at startup. The capability check uses import probes, not
loaded model state. This means capability advertisement must be based on import
availability, never on _state.my_model is not None. If you gate on loaded
model state, your task will not be advertised and the server will silently
exclude the command. The FA engine NAME in engine_versions is the opposite:
it reflects what has actually loaded, and is None before that.
The Rust server cross-checks: commands whose primary InferTask is not in the
worker’s infer_tasks list are excluded from the server’s advertised
capabilities; engine names are not consulted. A report whose
engine_versions holds an invalid name or a key that is not a task fails to
deserialize, and one that lacks an entry for an advertised task, names a task
that was not advertised, or names an engine for a task other than FA is
refused where the pool admits it (WorkerPool::record_capabilities). See Capability Discovery
for the full flow.
5. Register dependencies
Add the engine’s Python dependencies to the appropriate section in
pyproject.toml:
-
Core engines (expected to work out of the box): add to
dependencies. All standard commands (align, transcribe, translate, morphotag, etc.) have their dependencies independenciesso that a standard install gives users everything. -
Built-in engines with extra runtime dependencies: add them to
dependenciesif they are part of the supported built-in engine surface. Credential-gated or region-specific does not imply a separate install tier.Users then install
batchalign3[my-engine].
Cross-cutting Rust edits for a new ASR engine variant
For an ASR engine specifically, a variant lives in three Rust enums
that must stay in sync: a mismatch in any one silently mis-routes
dispatch. The following tables enumerate every file/identifier you
must update when adding a new variant. Use the whisper_hub addition
(2026-04-22) as a worked example of every line item.
The three-enum synchronization:
| Enum | File | Role |
|---|---|---|
AsrEngineName | crates/batchalign/src/types/engines.rs | User-facing type. Wire name, parsing, dispatch-key lookup. |
AsrBackendV2 | crates/batchalign-types/src/worker_v2/requests.rs | IPC contract with Python workers. Regenerate the schema after editing via bash scripts/generate_ipc_types.sh; the conformance test then checks the hand-written Python model against it. |
AsrWorkerMode | crates/batchalign/src/transcribe/types.rs | Server-side dispatch selector that bridges the other two. |
Helpers each variant must appear in:
| Function | File | Purpose |
|---|---|---|
AsrEngineName::wire_name() | types/engines.rs | Rust→string for JSON/SQLite. |
AsrEngineName::try_from_wire_name() | types/engines.rs | String→Rust at boundaries. |
AsrEngineName::dispatch_override_name() | types/engines.rs | Pool key (must equal wire_name or None). |
AsrWorkerMode::from_engine_name() | transcribe/types.rs | Wire-string → worker-mode variant. |
AsrWorkerMode::as_v2_backend() | transcribe/types.rs | Worker-mode → IPC backend. |
AsrBackend::provenance_name() | transcribe/types.rs | Canonical engine identity for production transcript provenance and warnings. |
asr_backend_engine() | crates/batchalign/src/worker/pool/execute_v2.rs | Maps the wire backend to an AsrEngineName; the pool-key string then comes from dispatch_override_name(), so there is no second table to keep in step. |
| (input-source routing) | crates/batchalign/src/transcribe/infer.rs | Match on AsrWorkerMode picks PreparedAudio (local model) vs ProviderMedia (external service). |
Model identity obligations. A variant present in all three enums and every helper above is still refused at the bridge without these three:
| Obligation | Where | Why it is not optional |
|---|---|---|
The ASR result carries AsrModelIdentityV2 | your worker-side runner builds it; typed in crates/batchalign-types/src/worker_v2/responses.rs | model is required, not optional, so a result naming no models does not compile. The bridge admits the reported composition against the request’s pin and refuses a disagreement by name; for a provider backend it does so BEFORE calling the provider, so a mismatch costs no paid request. A worker that recorded no identity is refused per engine, never handed one inferred from the request. |
| The composition is per engine, not one model | crates/batchalign/src/model_manifest.rs | An engine that loads more than one model names all of them: Qwen names its ASR model AND its forced aligner, Paraformer names its checkpoint plus the voice-activity and punctuation models it additionally loads. The UTR ASR cache namespace is built from that composition, so an omitted member pools rows produced by different weights. |
A monologue speaker is SpeakerAttributionV2 | responses.rs::AsrMonologueV2::speaker | A bare string cannot tell “this engine separates nobody, so it named nobody” from “it named nobody although separation was requested”. That is why undiarized engines once wrote "0", which became a PAR0 tier indistinguishable from a real first speaker. |
The contract itself is specified twice, and both are worth reading before you
add a variant: the ASR #### Result section of
worker-protocol-v2, and the ASR row of
INTERFACE_MAP.md at the repository root.
Worker-side enum (matches Rust wire name one-to-one):
AsrEngineinbatchalign/worker/_types.py: the Python enum the worker bootstrap stores in_state.asr_engine.
Request validation surface (optional, only for engines with per-engine language constraints like the Cantonese ASR engines):
validate_language_support()incrates/batchalign/src/types/request.rs.
Per-language default model_id resolution. If your engine picks a
model per language (e.g. different HF fine-tunes per language), add
entries to batchalign/models/resolve.py rather than inventing a new
per-engine table. resolve("your_engine", lang_iso3) returns the
model_id or None; raise a typed error on None unless the caller
passed an explicit override, rather than falling back to a generic
default.
HF Whisper fine-tune gotcha. HF community Whisper fine-tunes bake
language and task into their own generation_config. Passing
those again in generate_kwargs produces gibberish. The escape hatch
is the skip_language_force: bool flag on
batchalign/inference/types.py::WhisperASRHandle: when True,
gen_kwargs() returns ONLY {"max_new_tokens": 444} and omits
task, language, generation_config, and repetition_penalty.
See batchalign/inference/whisper_hub.py for the wiring:
pass language="auto" to load_whisper_asr() AND set
handle.skip_language_force = True before returning.
Why the max_new_tokens=444 safety cap. With empty
generate_kwargs, the HuggingFace ASR pipeline can let a fine-tune
fall into a non-converging decoder state where it never predicts an
end-of-utterance token, hanging the worker for tens of minutes. The
cap is a hard upper bound on tokens-per-chunk, not a probability
override, so it is a no-op on successful runs but terminates
runaways. The value 444 is one below Whisper’s legal max:
max_target_positions = 448 includes the 3 special start tokens,
leaving 445 for new tokens, with 444 chosen for one token of margin.
TDD discipline for engine additions
The rest of this section is a TDD checklist derived from actually
shipping (and breaking) whisper_hub. Every item is reactive: it
corresponds to a mistake that was made once and should be prevented
by test structure in the future.
Test at the observable boundary, not at the function you call into
When a loader or constructor populates a stateful intermediate (a handle, a worker state, a registry entry), do not substitute tests that assert “the right thing was passed into the constructor” for tests that assert “the object the constructor returns, when exercised by a downstream caller, produces the right observable behavior.”
Concrete example. The whisper_hub loader was initially tested only
by asserting that load_whisper_asr received language="auto",
a proxy for “fine-tunes won’t get their language re-forced at
generate() time.” That assertion was true but insufficient: the
V2 inference path (infer_whisper_prepared_audio) calls
handle.gen_kwargs(request_lang) and ignores handle.lang
entirely. The fine-tune was receiving task="transcribe", language="malayalam" at every generate() call and would have
produced cross-script gibberish. The unit tests did not fail because
nothing ever exercised the actual runtime path.
The fix was an additional test that constructs a
WhisperASRHandle(skip_language_force=True) directly, calls
gen_kwargs("malayalam") on it, and asserts task and language
are absent from the returned dict. That test exercises the runtime
contract that production depends on.
General rule: for every stateful intermediate in the pipeline, there must be tests on both sides of it. Input-side tests verify the construction call site. Output-side tests verify that downstream callers, given only the constructed object, see the right behavior.
Grep for every method that consumes the state you set
When you set a field on a shared handle or state object, grep for
every call site that reads that field OR reads a seemingly-unrelated
field that could diverge. gen_kwargs(lang) reading
the caller-supplied lang rather than self.lang is exactly that
kind of divergence: two plausibly-interchangeable data sources where
only one was the contract.
# What reads self.lang?
rg 'model\.lang|handle\.lang|\.lang =' batchalign/inference/
# What calls gen_kwargs?
rg 'gen_kwargs\(' batchalign/
If the same conceptual value (the “language to transcribe in”) flows through both paths, your engine-addition test must cover both.
Add a failing runtime-behavior test before writing any loader code
For ASR engine additions, the RED test baseline is:
test_<engine>_wire_roundtrip:AsrEngineName::<Engine>.wire_name()roundtrips throughtry_from_wire_name. Already a common idiom; add the variant to the existing test module.test_<engine>_worker_mode_lowers_correctly:AsrWorkerModevariant lowers to the rightAsrBackendV2and back.test_<engine>_loader_dispatch: worker bootstrap’sload_asr_engine()routesengine_overrides["asr"]=="<engine>"to your new loader function.test_<engine>_handle_gen_kwargs_for_concrete_language, construct a handle the way your loader would, call its generation-kwargs method with a concrete (non-auto) language, and assert the output dict matches what you expectgenerate()to receive. This is the test that catches the fine-tune trap above.test_<engine>_resolves_model_id_for_seeded_language: if your engine uses per-language defaults fromresolve.py, pin the seed entry with a direct assertion onresolve("<engine>", lang).test_<engine>_raises_on_unseeded_language: if your engine raises on a missing default, pin the error type and message fragment. Don’t let the error degrade into a silent stock fallback.test_<engine>_result_carries_model_identity: build the result your runner returns and assert itsmodelnames every model the engine loads, each at the revision it was OBSERVED at rather than the one that was requested.test_<engine>_bridge_refuses_unreported_identity: a worker response recording no identity for your engine must be refused by name, and for a provider backend the refusal must happen before the provider is called.test_<engine>_monologue_speaker_attribution: if your engine produces monologues, assert an undiarized result carries theundiarizedattribution rather than a speaker labelled"0".
Guard-rail tests must accompany any deny-list / recommendation changes, if you redirect users from engine X to engine Y for some language, engine Y must itself pass validation for that language.
Rebuild the PyO3 extension: the Python worker’s dispatch is Rust
AsrBackendV2 exists in two Rust crates (batchalign-types for the
server, and crates/batchalign-pyo3/src/worker_asr_exec.rs via that crate). The PyO3
function batchalign_core.execute_asr_request_v2(request, ...) owns
the runtime dispatch: it pattern-matches on AsrBackendV2 inside the
worker process and routes to the right runner. Adding a new enum
variant means you must:
- Add a match arm inside
crates/batchalign-pyo3/src/worker_asr_exec.rs::run_asrthat routes the new variant. The compiler will catch the missing arm if you let it, but only after you rebuild the PyO3 extension. - Rebuild
batchalign_core:make batchalign-python-prepare(which produces a fresh wheel via the maturin backend declared inpyproject.tomland reinstalls it into the dev environment), or run anyuv run …command to trigger an incremental rebuild against[tool.maturin] profile = "dev". Runningmake buildalone (which doescargo build --workspace --release) compiles the PyO3 crate but does not install the resulting.sointo the Python environment that workers import from. Without the install step, workers silently load the previously installedbatchalign_coreextension, which has no match arm for your new variant and drops the request with no response (the Rust server then sits in its request-timeout wait for ~30 minutes). Neither end logs the Pydantic / serde validation failure that would localize this. - Also update the Python
AsrBackendV2enum inbatchalign/worker/_types_v2.py. It is hand-maintained, and the conformance test (test_ipc_type_conformance.py) is what catches you having forgotten: it compares that enum against the regeneratedipc-schema/, so runbash scripts/generate_ipc_types.shafter the Rust change and let the test tell you what is missing.
Same class of bug as the gen_kwargs trap. Both are “state
crosses a boundary I didn’t search for, so the test suite never
exercises the real runtime path.” The fix is the same grep ritual:
# Before declaring a new ASR variant done:
rg "AsrBackendV2::" crates/
rg 'AsrBackendV2\.' batchalign/
Every match site gets a new arm. If you can’t find the grep result again on first read, the variant has a hole.
Compile-time vs install-time mismatch
batchalign-pyo3 is a member of the root Cargo workspace (see
members in the root Cargo.toml), so cargo check --workspace
and make build do compile the PyO3 bridge and do
exhaustive-match-check AsrBackendV2 arms there. Targeted
commands like cargo check -p batchalign skip it because
batchalign does not depend on batchalign-pyo3.
The failure mode the rebuild ritual prevents is therefore not a
missed compile error: it is an install gap. The freshly compiled
PyO3 .so lives in target/, while the Python worker process
imports batchalign_core from the wheel previously installed into
the active uv environment. Without make batchalign-python-prepare
(or an equivalent reinstall), the worker keeps loading the stale
.so and the new variant produces a silent stall on the worker’s
stdin readline (no response surfaces until the 30-minute
audio-task timeout fires).
The grep-and-rebuild ritual above is the defense; the workspace structure no longer is the gap.
(An out-of-date doc-comment in crates/batchalign-pyo3/Cargo.toml
describes the crate as “outside the root workspace by design”,
that comment predates the move into the workspace and should be
updated when next touched.)
Structural opportunity: gen_kwargs takes a string
WhisperASRHandle.gen_kwargs(lang) dispatches on a string: "auto"
is one special case, "Cantonese" is another, and any other string
means “force this language on generate()”. Plus the new
skip_language_force flag adds a fourth behavior. This is
boolean-blindness dressed in string clothing, four distinct
generation modes hidden in two orthogonal inputs. A future refactor
should replace this with an enum such as WhisperGenMode::{ AutoDetect, FinetunePinnedByConfig, CantoneseSpecialCase, ForceLanguage(Name) }, with each engine’s loader picking the
variant explicitly. Out of scope for a single-engine addition, but
worth doing before the next whisper-family engine (WhisperX,
WhisperOai Hub variant, etc.) repeats the same mistake.
Adding a new server command
If you are adding a new top-level command (not just a new engine for an existing command), see the detailed 8-step checklist in Rust CLI and Server.
In addition to those Rust-side changes, update these Python-side surfaces:
crates/batchalign-types/src/command_spec.rs: Add aCommandSpecentry toCOMMAND_SPECS. Then runcargo xtask gen-runtime-tomlto regeneratebatchalign/runtime_constants.toml(the generated file is the shared Rust/Python source of truth; do not edit it directly).batchalign/worker/_handlers.py: Add theInferTaskto_INFER_TASK_PROBES(at_handlers.py:77) so the worker advertises it. This is the only Python-side probe mechanism; the server cross-checks advertised infer-tasks against required command capabilities. See step 4 above for details.batchalign/worker/_model_loading/: Register the dynamic runtime host for the new task if it depends on loaded model state or engine-specific wiring. Reservebatchalign/worker/_execute_v2.pyfor the small task router that dispatches to those prepared hosts.
Remember: command semantics live in the command-owned Rust layer, not in the worker bootstrap layer. The worker layer should only know how to load engines and execute typed tasks.
No public extension surface
There is no public Python extension surface. New engines are added
in-tree, through the steps above; there is no batchalign.plugins
discovery API, no PluginDescriptor contract, and no supported
external-package path for adding ASR / FA / morphosyntax / etc.
backends without modifying this repo.
The worker-side modules under batchalign.worker.* and the
batchalign.providers re-export module are internal implementation
detail and may change without notice.
If you want to ship an engine without making it a mandatory
dependency of the package, use an [project.optional-dependencies]
extra in pyproject.toml so the dependency installs only when
explicitly requested.
Test expectations
At minimum, add:
- unit tests for the new inference module
- worker dispatch tests covering
_execute_v2()or the relevant task host - bootstrap/handler-registration tests if the task uses dynamic worker runtime
- Rust integration coverage if the new engine changes server orchestration, command routing, or capability gating
- doc updates for install syntax, command options, and migration notes if this replaces a BA2 or pre-release workflow
Relevant existing coverage lives in:
batchalign/tests/(Python-side worker dispatch / handler tests)crates/batchalign/tests/(Rust-side server / IPC / CI hygiene tests)- inline Rust tests under
crates/batchalign-pyo3/src/for the PyO3 bridge
Rule of thumb
If the change affects CHAT structure, it belongs in Rust.
If the change affects model inference only, it usually belongs in Python plus the typed worker contract that Rust consumes.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Adding Support for a New Language
Status: Current Last updated: 2026-05-19 21:19 EDT
This page is the checklist to run through when someone says “let’s add language X.” Skipping any of these checks produces silent quality bugs that surface later as user complaints (Whisper hallucinating, validator rejecting digits, retokenize segfaulting, morphotag injecting wrong counts).
A class of E220 bug happens when a language is declared “supported in transcribe-only mode” without verifying that the number-expansion backend covers it. Whisper emits digits, the validator rejects them, the postprocess pipeline silently passes them through. This page exists to prevent that class of mistake.
Pre-flight: capability matrix
For every new language, fill in this table before writing any code. The answers determine which integrations are wired and which docs need a “not available for X” line.
| Capability | How to check | Affects |
|---|---|---|
| ISO 639-3 code | pycountry, talkbank-types::LanguageCode3 | Everything downstream |
| Stanza pipeline? | python -c "import stanza; print('XXX' in stanza.resources.common.load_resources_json())" AND check the entry has a packages key (not just charlm stubs) | morphotag, utseg, retokenize gating |
num2words backend? (build-time only) | python -c "import num2words; print('XX' in num2words.CONVERTER_CLASSES)" (use ISO 639-1 2-char code). The Rust NUM2LANG table at crates/batchalign-transform/data/num2lang.json is the codegenned output of an offline num2words sweep; runtime uses Rust only. (No in-tree codegen script today: see Number Expansion for the regeneration protocol.) | Number expansion (E220 risk) |
| Rev.AI quality? | Submit a sample to Rev.AI; check for hallucinations, script confusion, repetition. Document result in book/src/batchalign/reference/revai-language-quality-strategy.md | Default ASR engine choice |
| Stock Whisper quality? | Same: run a representative sample, evaluate | Fallback ASR engine choice |
| HuggingFace fine-tune available? | Search HF Hub for whisper-*-{lang} checkpoints | whisper_hub engine routing in crates/batchalign/src/model_manifest.rs::WHISPER_HUB_DEFAULTS |
| CHAT digit-validator allows digits? | rg "{lang}" talkbank-tools/../chatter/crates/talkbank-model/src/validation/word/language/digits.rs | Whether E220 fires on Whisper digit emissions |
| PyCantonese / language-specific tools? | Per-language: relevant for CJK, possibly others | Special-case wiring |
The five integration points
When the matrix is filled in, work through these in order:
1. Stanza wiring
If Stanza ships a real pipeline (the packages key is populated, not
just backward_charlm/forward_charlm stubs):
- Add or verify the language in
batchalign/worker/_stanza_capabilities.py, this is the runtime authority, NOT a hardcoded table. - Confirm MWT, POS, lemma, depparse, constituency availability via the capability table.
- If MWT is present, the Stanza-induced retokenize path
(
crates/batchalign-transform/src/retokenize.rsandcrates/batchalign-transform/src/retokenize/{rebuild,parse_helpers}.rs) automatically applies. - Per-language analysis quirks (clitics, compounds, elision) may need a
crates/batchalign-transform/src/morphosyntax/lang_<code>.rsmodule: see Italian (lang_it.rs) and French (lang_fr.rs) as references.
If Stanza ships only stubs (no packages): the language is
transcribe-only. Document this on the language’s reference page.
Morphotag, utseg, and Stanza-driven retokenize all skip silently
through with_morphosyntax=false / with_utseg=false plan flags
(crates/batchalign/src/pipeline/transcribe.rs).
2. Number expansion
Authoritative reference: the Number Expansion architecture page is the single source of truth for how this works. The summary below is a checklist; the page is the deeper explanation, the per-language coverage matrix, and the maintenance protocol you follow when adding a language. Keep that page updated in the same patch as any code change.
The stage_asr_postprocess stage runs for every language, gated
only by always_enabled in
crates/batchalign/src/pipeline/transcribe.rs. The expansion
pipeline is Rust-only (no Python IPC) and is NOT Stanza-gated.
What determines whether digits get spelled out:
- CJK (
zho/cmn/jpn/yue): handled in Rust bynum2chineseincrates/batchalign-transform/src/asr_postprocess/num2chinese.rs. - English ordinals/years/decades: handled by
crates/batchalign-transform/src/asr_postprocess/ordinal_year_eng.rsvia deterministic composition rules. - All other cases: per-language
NUM2LANGtable atcrates/batchalign-transform/data/num2lang.json. The table is the offline-codegenned output of anum2wordssweep; runtime is Rust-only.
Regenerating the table. The historical codegen script
(scripts/codegen_num2lang.py) is no longer in the tree; the
table is committed as a generated artifact. The maintenance protocol
lives in Number Expansion,
follow that page when adding or refreshing a language entry. When
num2words.CONVERTER_CLASSES does not cover a language (e.g.
Malayalam, Hindi, Tamil, most non-Telugu/Kannada/Bengali Indic
languages), either:
- Add a hand-curated overlay (digits 0-9 and the common compounds you need) following the procedure in the number-expansion page.
- Add the language to the digit-allowed list via
language_allows_numbersin../chatter/crates/talkbank-model/src/validation/context.rs:34(consulted by the validator throughmixed_language_allows_numbersin../chatter/crates/talkbank-model/src/validation/word/language/helpers.rs:57, which gatesdigits.rs). Lossy but unblocks transcribe runs.
Pick option 1 unless the user community explicitly accepts digits in the transcript.
3. ASR engine selection
Order of preference, picking the first that produces usable output on a representative sample:
- Stock Whisper (
--asr-engine whisper): fast, broad coverage, no per-language config. Good baseline. - HuggingFace fine-tune via
whisper_hub: when stock Whisper or Rev.AI underperform on extended recordings. Configure model resolution incrates/batchalign/src/model_manifest.rs::WHISPER_HUB_DEFAULTS, naming the hub commit to pin it to; that is what a planned job resolves from. - Rev.AI (
--asr-engine rev): only if it produces clean output for this language. Many languages return garbage from Rev.AI; seebook/src/batchalign/reference/revai-language-quality-strategy.mdfor the canonical Malayalam-failure case study. - Specialty engines (Tencent, Aliyun, FunASR for Cantonese): only when domain quality demands it.
Document the choice and the evidence behind it on the language’s reference page. Do NOT silently change engine defaults, every change needs a rationale in the docs.
4. CHAT validator allowlist
Several validators have per-language carve-outs. Check at least:
digits.rs(E220): which languages may have Arabic digits- Other validators in
talkbank-tools/../chatter/crates/talkbank-model/src/validation/word/language/
If the language is missing from a relevant allowlist AND the upstream ASR / transcription convention produces output that triggers the validator, decide whether to (a) widen the validator, (b) add a post-processing normalization, or (c) document the constraint and expect transcribers to manually fix it. Option (b) is preferred when the input is deterministic (e.g., digits → spelled words).
5. Reference documentation
Every language with non-trivial special treatment gets a page under
book/src/batchalign/reference/languages/<lang>.md. Even a transcribe-only
language deserves a page so future contributors know where to look.
The page must include:
- ASR engine choice + rationale
- Stanza availability (cite the capability table check)
- Per-stage table (text norm, number expansion, retokenize, morphotag, utseg, FA), each with the actual current behavior not the intended behavior
- Open issues section if any known bugs apply to this language
- Operational notes (chunk size, model parameters, etc.)
Add the language to book/src/batchalign/reference/languages/overview.md
index so it shows up in the SUMMARY.
Verification
After wiring a language, run end-to-end on a small fixture before declaring “supported”:
- ASR: short audio (< 60s), confirm transcribed text matches expected script.
- Number expansion: feed an utterance containing a spoken number (“I have three books”), confirm output is spelled, not digits.
- CHAT validation: run
chatter validate(or the equivalent pipeline gate) on the output; confirm no E220 / E1xx errors that are language-coverage gaps rather than real transcript problems. - Morphotag (if Stanza-supported): confirm
%morand%gratiers inject without count-mismatch errors. - FA (if attempted): confirm word-level timings appear and
%wortier is generated.
Any failure on steps 1-3 means the language is not yet ready for user-visible support, adjust integration before merging.
Related documentation
book/src/batchalign/reference/languages/overview.md: language indexbook/src/batchalign/reference/revai-language-quality-strategy.md: when to switch away from Rev.AIbook/src/batchalign/reference/whisper-hub-asr.md: HuggingFace fine-tune routingcrates/batchalign/CLAUDE.md: batchalign crate map- Number Expansion, protocol
for refreshing
crates/batchalign-transform/data/num2lang.jsonand the hand-curated overlay (the historicalscripts/codegen_num2lang.pyscript is no longer in-tree) ../chatter/crates/talkbank-model/src/validation/word/language/, language-aware validators, including E220 digits
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Adding a New Command
Status: Current Last updated: 2026-07-30 18:21 EDT
This guide walks through adding a new batchalign3 command end-to-end.
Reference implementations by workflow family:
Commands are DECLARED, not authored: there is no per-command module. Each is
one CatalogEntry in recipe_runner/catalog.rs naming a Recipe in
recipe_runner/recipes.rs. Read the nearest existing entry and its recipe.
CommandFamily | Best example | What to read |
|---|---|---|
AudioSequential (one media file at a time) | align | ALIGN_RECIPE, runner/dispatch/fa_pipeline.rs |
BatchedText (pooled batch-infer) | morphotag | MORPHOTAG_RECIPE, runner/dispatch/infer_batched.rs |
ReferenceProjection (compare against gold) | compare | COMPARE_RECIPE, compare.rs |
MediaAnalysis (non-CHAT artifact out) | opensmile | OPENSMILE_RECIPE, runner/dispatch/media_analysis_v2.rs |
Composite (reuses other recipes) | benchmark | BENCHMARK_RECIPE, runner/dispatch/benchmark_pipeline.rs |
Start with align if your command takes CHAT in and produces modified CHAT
out; morphotag if it batches several files through one ML call.
History, so the old shape is not rebuilt by accident. Until 2026-07-28
there was a commands/ module holding one module per command, six
declare_*_command! macros, six marker traits and a CommandDefinition type.
Removing an #![allow(dead_code)] showed the whole layer produced values
nothing read, and it was deleted (b610a885). The remaining catalog of one-line
delegations and the compatibility view types followed on 2026-07-29. Earlier
revisions of this guide walked contributors through that layer; if you find
advice elsewhere in the book telling you to write commands/your_command.rs,
it is stale, and this page is the current procedure.
Quick start
make check # after each file edit (~6s)
make test # verify nothing broke (~6s)
Architecture overview
Every command flows through these layers:
CLI args → CommandOptions → JobSubmission → Runner
→ shared family dispatch / worker pool → output materialization
The key files, in the order you’ll edit them:
| Step | File | What you add |
|---|---|---|
| 1 | batchalign-types/src/domain.rs | ReleasedCommand::YourCommand variant |
| 2 | batchalign/src/recipe_runner/recipes.rs | YOUR_COMMAND_RECIPE: the ordered stages |
| 3 | batchalign/src/recipe_runner/catalog.rs | one CatalogEntry declaring every field |
| 4 | batchalign/src/your_command.rs or shared runner code | Core logic (ML dispatch, post-processing) |
| 5 | batchalign/src/cli/args/commands.rs | CLI arg struct |
| 6 | batchalign/src/cli/args/mod.rs | CommandProfile match arm |
| 7 | batchalign/src/cli/args/options.rs | CommandOptions variant + build_typed_options arm |
Steps 2 and 3 are the whole registration story. Three tests fail until they are
done, each naming what is missing: every_released_command_has_a_spec (no
entry), per_command_metadata_is_stable and declared_output_naming_is_stable
(entry present, metadata not stated).
Step 1: Add the ReleasedCommand variant
#![allow(unused)]
fn main() {
// crates/batchalign-types/src/domain.rs
pub enum ReleasedCommand {
// ... existing ...
YourCommand, // ← add here
}
}
Update the ALL array, as_str(), TryFrom<&str>, and From<ReleasedCommand> for CommandName.
Step 2: Add the stage recipe
A Recipe is the ordered list of stages the runtime executes, each stage naming
its own prerequisites. Add it to crates/batchalign/src/recipe_runner/recipes.rs
next to the existing twelve, then read the nearest one in full: stage ordering is
validated (Recipe::validate), so a missing prerequisite is a failing test
rather than a runtime surprise.
pub(super) const YOUR_COMMAND_RECIPE: Recipe = Recipe {
mode: ExecutionMode::BatchedStage,
stages: &[
RecipeStage::new(
RecipeStageId::PlanWorkUnits,
RecipeStagePresence::Required,
StageExecutionKind::PerWorkUnit,
FileStage::Reading,
&[],
),
// ... your stages, each listing the stages it depends on ...
],
};
mode is the command’s execution mode, declared here and nowhere else. The
catalog entry in step 3 used to repeat it as an execution_mode field with a
test asserting the two agreed; both are gone, and readers ask
entry.recipe.mode.
Step 3: Declare the catalog entry
One CatalogEntry in crates/batchalign/src/recipe_runner/catalog.rs. This is
the ONLY place a released command is registered.
CatalogEntry {
command: ReleasedCommand::YourCommand,
family: CommandFamily::BatchedText,
planner: PlannerKind::TextInputs,
capability_kind: CommandCapabilityKind::DirectInfer,
io_profile: CommandIoProfile::PathsModeText,
runner_dispatch_kind: RunnerDispatchKind::BatchedTextInfer,
capabilities: CapabilityPlan {
primary_infer_task: InferTask::YourTask,
surface: CapabilitySurface::RecipeOwned,
},
output_policy: OutputPolicy {
primary: FileNamingPolicy::PreserveInput,
primary_content_type: ContentType::Chat,
sidecars: NO_SIDECARS,
},
recipe: &YOUR_COMMAND_RECIPE,
},
Every field is stated; none is inferred. Until 2026-07-29 three of them
(capability_kind, io_profile, runner_dispatch_kind) were computed by
matching on the command name with a catch-all _ => default, so a new command
silently inherited answers nobody had chosen: a text io profile and the
batched-text dispatch path, surfacing later as “cannot reach the audio” at run
time. Declaring them means the compiler asks you the question.
family is the one field with reach. It implies eight runtime policies
(scheduling, model sharing, batching, parallelism, resource lane,
constrained-host behaviour and the workflow family) through
const fns on CommandFamily. Pick the family whose implications you want, and
read those methods in recipe_runner/command_spec.rs before choosing; do not
pick by name resemblance.
Position in the table is a user-visible contract. COMMAND_SPECS order is
the order /health advertises capabilities in, and the dashboard renders. Insert
your entry where you want it to appear, next to its family’s siblings.
If none of the five families fits, that is a platform task, not a command task:
extend CommandFamily and its eight derivations once (the compiler will list
every match arm needing an answer), then declare your command against the new
family.
Direct-first development contract
When adding an ordinary command, assume:
- the command should run in direct mode on a laptop first
- the command should not need to know whether a server exists
- the catalog entry is the single source of truth
- server mode may derive a different execution host, but not a different command meaning
If your command truly needs server-specific behavior, make that an explicit opt-in in shared runtime code rather than teaching every command author about server internals.
Step 4: Core logic
Create crates/batchalign/src/your_command.rs with the actual ML dispatch:
pub(crate) async fn run_your_command_impl(
chat_text: &str,
services: PipelineServices<'_>,
params: &YourCommandParams<'_>,
) -> Result<String, ServerError> {
// 1. Parse CHAT text
// 2. Build infer request
// 3. Dispatch to worker pool
// 4. Post-process response
// 5. Return modified CHAT text
}
See crates/batchalign/src/morphosyntax/ (directory module) or crates/batchalign/src/translate.rs (single-file module) for complete examples.
Step 5: CLI args
Add to crates/batchalign/src/cli/args/commands.rs:
#[derive(Args, Debug, Clone)]
pub struct YourCommandArgs {
#[command(flatten)]
pub common: CommonOpts,
#[arg(long, default_value = "eng")]
pub lang: String,
// ... command-specific flags ...
}
Add to the Commands enum:
pub enum Commands {
// ...
YourCommand(YourCommandArgs),
}
Step 6: Command profile
In crates/batchalign/src/cli/args/mod.rs, add a match arm:
Commands::YourCommand(a) => CommandProfile {
command: ReleasedCommand::YourCommand,
lang: &a.lang,
num_speakers: 1,
extensions: &["cha"],
},
Step 7: Typed options
In crates/batchalign/src/types/options.rs, add:
pub enum CommandOptions {
// ...
YourCommand(YourCommandOptions),
}
And in crates/batchalign/src/cli/args/options.rs, add the build_typed_options arm.
Step 8: Verify
cargo test -p batchalign # the whole crate suite
./target/debug/batchalign3 your-command --help # CLI works?
A test count is deliberately not quoted here: the previous revision promised “1,273 tests” long after the real number had moved past 1,750, which teaches a contributor to distrust the page.
Python worker side
If your command needs a new ML model:
- Add an
InferTaskvariant (crates/batchalign-types/src/worker.rs) and its stable snake_case label inworker/target.rs::task_name - Add a
WorkerProfilemapping incrates/batchalign/src/worker/registry.rs - Implement the Python worker handler in
batchalign/worker/
If reusing an existing model (e.g., Stanza for morphosyntax), you only need to wire the Rust side, the worker already knows how to handle the infer task.
Worked example: Compare (ReferenceProjection)
Compare is the most instructive example because it uses the ReferenceProjection
family, the workflow produces typed intermediate artifacts, then a swappable
Materializer turns them into the final output. This is how BA2’s
CompareEngine + CompareAnalysisEngine pair maps to BA3 without falling back
to string-level projection or ad hoc string assembly at the serialization
boundary.
BA2 Python → BA3 Rust mapping
BA2 Python (compare.py) | BA3 Rust | File |
|---|---|---|
_find_best_segment(): bag-of-words window search | talkbank_transform::compare::find_best_segment | crates/batchalign-transform/src/compare/engine.rs:72 |
CompareEngine.process(): local window alignment + token status | talkbank_transform::compare::compare() | crates/batchalign-transform/src/compare/engine.rs:173 |
CompareAnalysisEngine.analyze(): metrics CSV | CompareMetricsCsvTable / CompareMetricsCsvRow | crates/batchalign-transform/src/compare/metrics.rs:8,103 |
| gold document projection | project_gold_structurally() | crates/batchalign-transform/src/compare/materialize.rs:209 |
| compare data model (bundle, utterances, metrics, word matches) | ComparisonBundle / UtteranceComparison / CompareMetrics / GoldWordMatch | crates/batchalign-transform/src/compare/model.rs:75,27,38,92 |
| tier serialization models | XsrepTierContent / XsmorTierContent | crates/batchalign-transform/src/compare/serialize.rs:186,228 |
Document / Utterance / Form model | ChatFile AST + dependent tiers | talkbank-model |
CLI dispatch morphosyntax -> compare -> compare_analysis | build_comparison_artifacts() + released/main-annotated materializers | crates/batchalign/src/compare.rs (orchestrator) |
Architecture sketch
flowchart TD
request["batchalign/src/compare.rs orchestration\nmain_text + gold_text"] --> morph["Morphotag main only\nreuses morphosyntax worker"]
request --> gold["Parse raw gold leniently"]
morph --> main["MorphotaggedMain::from_proof\n(the judged document,\nnot its bytes)"]
main --> bundle["talkbank_transform::compare::compare(&main, &gold)\n→ ComparisonBundle:\nmain_utterances + gold_utterances\n+ gold_word_matches + metrics"]
gold --> bundle
bundle --> tiers["XsrepTierContent / XsmorTierContent\n(talkbank-transform/src/compare/serialize.rs)"]
bundle --> csv["CompareMetricsCsvTable\n(talkbank-transform/src/compare/metrics.rs)"]
tiers --> released["materialize_released()\nreleased output:\nprojected reference CHAT + .compare.csv"]
tiers --> main_view["materialize_main_annotated()\ninternal/benchmark output:\nmain %xsrep/%xsmor + .compare.csv"]
csv --> released
csv --> main_view
released --> safety["exact match -> copy %mor/%gra/%wor\nfull gold coverage -> %mor only\nelse keep gold tiers"]
Key types
// Intermediate artifacts, in compare.rs's private `artifacts` module. Every
// field is private and the ONE constructor is
// `ComparisonArtifacts::build(MorphotaggedMain, ChatFile)`, which RUNS the
// comparison rather than accepting one, so no caller supplies a bundle and
// none can assemble a comparison that began with a String.
struct ComparisonArtifacts { /* main_file, gold_file, bundle: private */ }
// How a materializer takes ownership, by destructuring `into_parts()`.
// Nothing turns these back into a ComparisonArtifacts.
struct ComparisonParts {
main_file: ChatFile, // the judged morphotagged main
gold_file: ChatFile, // the leniently parsed gold companion
bundle: ComparisonBundle, // alignment + metrics from DP
}
struct ComparisonBundle {
main_utterances: Vec<UtteranceComparison>,
gold_utterances: Vec<UtteranceComparison>,
gold_word_matches: Vec<GoldWordMatch>,
metrics: CompareMetrics,
}
struct XsrepTierContent {
items: Vec<CompareTierItem<CompareSurfaceToken>>,
}
struct XsmorTierContent {
items: Vec<CompareTierItem<ComparePosLabel>>,
}
struct CompareMetricsCsvTable {
rows: Vec<CompareMetricsCsvRow>,
}
struct CompareMaterializedOutputs {
chat_output: String,
metrics_csv: String,
}
struct MainAnnotatedCompareOutputs {
annotated_main_chat: String,
metrics_csv: String,
}
How the BA2 _find_best_segment() + local DP maps
BA2’s CompareEngine.process() does everything in one 250-line method:
extract words → conform → find windows → DP align → annotate gold → set timing.
BA3 splits this into layers:
-
talkbank_transform::compare(crates/batchalign-transform/src/compare/): pure functions, no ML, no IO:find_best_segment()(engine.rs:72), same local-window idea as BA2compare(&main, &gold)(engine.rs:173) →ComparisonBundlewith main/gold compare views, structural word matches, and metricsproject_gold_structurally()(materialize.rs:209), AST-first gold projectionXsrepTierContent/XsmorTierContent(serialize.rs:186,228), typed compare-tier models lowered once at theUserDefinedDependentTierboundaryCompareMetricsCsvTable/CompareMetricsCsvRow(metrics.rs:8,103), typed metrics rows serialized through the Rustcsvcrate
-
crates/batchalign/src/compare.rs: orchestration:build_comparison_artifacts(): morphotag main only, parse gold raw, callcompare()materialize_released(): released compare output pathmaterialize_main_annotated(): internal benchmark/main output path
-
execution/: recipe-driven server integration (new model):dispatch_compare_job()builds aJobPlanand runsExecutionKernelCompareStageExecutorhandles recipe stages: plan work units, read inputs, morphosyntax, compare-align, materialize outputs- Resolves gold file from
*.gold.chacompanion via planner
How to extend structural gold projection
The gold materializer is no longer a stub. Extend it by working with typed data:
- Edit
project_gold_structurally()incrates/batchalign-transform/src/compare/materialize.rs:209(the actual implementation;crates/batchalign/src/compare.rs:27,140only re-exports and calls it). - Use
ComparisonBundle.gold_word_matchesand AST accessors, not%xsrep/%xsmorstrings, as the projection source. - Keep the current safety rules explicit: exact matches may copy
%mor/%gra/%wor; full gold-word coverage may project%mor; partial%gra/%worneeds chunk-safe mapping before it is allowed. - Keep gold raw during artifact construction unless the reference file already contains tiers you are intentionally preserving.
Serialization rule
When a workflow emits structured artifacts, add explicit pre-serialization types before you add serializer code.
- New semantic strings must get newtypes.
- CHAT tier content should be written from typed models via
WriteChat. - CSV outputs should be written from typed row/table models via
csv. - Do not drive semantics from
format!,join,split, or regex surgery over already serialized output.
Files to read (in order)
crates/batchalign-transform/src/compare/: compare core (engine.rs, model.rs, materialize.rs, serialize.rs, metrics.rs)crates/batchalign/src/compare.rs: orchestration + materializerscrates/batchalign/src/execution/: recipe-driven dispatch (replaces oldcompare_pipeline.rs)crates/batchalign/src/planning/:build_job_plan()for typed execution plansbook/src/batchalign/migration/ba2-compare-migration.md: BA2-master compare to BA3 map- BA2 reference: archived in the maintainers’ BA2 working copy (see migration docs for location)
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
align: Developer Reference
Status: Current Last updated: 2026-09-15 12:12 EDT
Implementation guide for the align command. For user-facing documentation,
see User Guide: align.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: AlignArgs | UTR/FA engine flags, strategy, fuzzy, buffer params |
| Options builder | crates/batchalign/src/cli/args/options.rs:130-194 (inline dispatch) | Maps AlignArgs → CommandOptions::Align(AlignOptions) |
| Command definition | crates/batchalign/src/commands/align.rs: AlignCommand | CommandDefinition impl, pre-validation gate |
| FA pipeline | crates/batchalign/src/runner/dispatch/fa_pipeline.rs | Per-file FA orchestration: UTR → grouping → FA → injection |
| UTR dispatch | crates/batchalign/src/runner/dispatch/utr.rs | Resolved strategy construction and per-recording grouping context |
| UTR library | crates/batchalign/src/chat_ops/fa/utr.rs | run_utr_pass(), inject_utr_timing(), partial-window logic |
| FA library | crates/batchalign/src/chat_ops/fa/ | Grouping, extraction, DP alignment, injection, postprocessing |
| Worker boundary | batchalign/worker/_fa_v2.py + crates/batchalign-pyo3/src/worker_fa_exec.rs | Rust owns request validation and V2 response shaping; Python hosts model callbacks |
| Model callback | batchalign/inference/fa.py | Whisper token onsets or indexed Wave2Vec word intervals with optional model score |
| Durable evidence | crates/batchalign/src/types/traces.rs, runner/debug_dumper.rs | Versioned, fail-closed FA evidence sidecar when --debug-dir is enabled |
@Options: NoAlign: strict pass-through
Files containing @Options: NoAlign are returned completely unchanged.
The pipeline performs zero modifications: no timestamps are added, removed,
or adjusted, no %wor tier is generated or updated, and no legacy decision
tiers (%xalign, %xrev) are stripped.
The rationale is that a researcher who sets @Options: NoAlign has explicitly
opted this file out of all alignment processing. Batchalign must respect that
decision unconditionally, including for cleanup passes that might seem benign
(such as monotonicity enforcement). Any existing timestamps, even backward
ones from a previous run, are the researcher’s responsibility.
If a file with @Options: NoAlign carries validation errors from a previous
FA run, the correct fix is to repair the file manually or remove the option,
re-run align, and re-add the option if still needed.
Implementation: run_fa_from_ast checks is_no_align(&chat_file) immediately
after parsing (before media resolution, pre-validation, and all FA logic) and
returns FaAdmission::pass_through(...), which is one of exactly two routes to
a returned FA result. The proof it carries is PostValidated::pass_through:
not gated, because gating would re-judge the INPUT against a bar the input never
had to meet, and carrying the input’s OWN BYTES rather than a re-serialization
of the parsed model, so “unchanged” is literally true. It used to be built by
the sibling constructor that serializes the model, which made a parse-and-
serialize round trip of a file align had promised not to touch.
“Zero modifications” includes the [fc-ba3 align ...] provenance comment. Until
this became a transition on the typed proof
(PostValidated::with_provenance_injected), the dispatch seam stamped that
comment onto NoAlign and dummy documents too, so the sentence above was false by
one line. A pass-through is now returned untouched by both the provenance stamp
and the abbreviation merge.
Pre-validation gate
align requires CHAT Level 2 (parseable + headers + valid main tiers) before
running inference. Invalid files are rejected immediately with a typed error
rather than consuming GPU time. See
Command Contracts for the validity
level definitions.
Implemented in crates/batchalign/src/commands/align.rs:
validate_to_level(chat, ValidationLevel::MainTiers)?;
Cache key structure
FA group keys are BLAKE3 hashes over:
- audio identity (resolved path, mtime, and size)
- file-relative audio window (
start_ms,end_ms) - normalized word sequence
- typed FA engine
- response-schema discriminator where required
- for onset-only engines, the text/healing mode that affects parsed timings
The cache backend namespaces that key by task (forced_alignment) and the FA
engine the selected worker reported, admitted at the capability gate and
carried as FaCacheNamespace byte for byte, so evidence cached by earlier
builds stays admissible. A worker that supports FA but names no engine cannot
align: the job fails naming the task rather than inventing a namespace.
Word-interval keys carry
model_score_v1: this intentionally retires historical interval entries that
deserialize correctly but predate score retention. Whisper has no interval
score to recover and keeps its established cache namespace.
UTR ASR results are cached separately per audio segment (file path + start_ms
- end_ms). Segment cache hits avoid re-running ASR on already-processed
windows during the partial-window optimization. Full-file and segment entries
both live under the UTR engine’s own namespace (
UtrAsrCacheNamespace,utr-asr-v1:<engine wire name>:<composition>followed by one|<role>=<id>@<revision>per model), not under the FA engine’s version, so changing the FA model no longer discards UTR ASR results, and changing a recovery model no longer silently reuses rows produced by the previous one.
The [fc-ba3 align | ...] stamp records fa= (the reported FA engine) and,
when a timing-recovery pass actually ran (the pre-pass or the retry fallback),
utr= with the recovery engine’s name (rev, whisper, tencent). A file
whose utterances were all timed, so that no pass ran, records no utr=. The
record is a UtrContribution on AlignAudioTask, updated from each pass’s
UtrResult::ran().
Cache implementation: crates/batchalign/src/cache/ (hot: moka,
cold: SQLite). Bypass with global --override-media-cache.
align has two independently resolved cache tasks. FaParams::cache_policy
governs forced_alignment; FaDispatchPlan::utr_cache_policy governs both
the initial and fallback utr_asr passes. Do not collapse the latter into
FaParams: selective refresh and replay experiments depend on changing one
policy without changing the other. --require-media-cache resolves both to
RequireCache and prevents either unresolved boundary from authorizing
inference.
FaParams::projection_policy() combines the engine-derived WordEndPolicy
with typed ExistingWorBoundaryPolicy and EndOverlapPolicy values. Full,
incremental, all-%wor, and empty-group paths consume that single
FaProjectionPolicy, preventing execution shape from changing the local
interpretation of the same evidence. Both local policies are deliberately
absent from cache_key(): changing either must replay the same evidence, not
create a new inference identity.
The final phase is also typed. Fresh injection produces FaApplied; a
no-injection path can only enter through finalize_without_injection. Both
must produce FaFinalized, which runs BulletRepairPolicy first and
EndOverlapPolicy monotonicity second. Only FaFinalized can enter
FaDecisions. This prevents the former incremental defect where monotonicity
clamped away a small overlap before optional repair could average it, and the
former reuse defect where no-injection paths silently selected the default
overlap policy.
Partial %wor reuse has a load-bearing phase boundary. Before grouping,
refresh_reusable_utterances() always uses compatibility preservation so the
input bullet continues to define the same audio window and raw cache key, and
(2026-09-01 review, item 2) it is now MECHANICAL ONLY: it never writes %wor
itself. It returns the utterances it touched, and run_fa_from_ast folds
them into the SAME FaApplied write phase that this run’s fresh injections
use, via FaApplied::also_touched, so their %wor (when requested) is
written once, after EndOverlapPolicy resolves, never before. The
all-reusable fast path (no grouping, no inference) is the same: it rebuilds
directly from the existing admitted %wor timings via
refresh_reusable_alignment, then reaches the write phase through
projection_without_injection_with_touched rather than a bare
finalize_without_injection with a separate write. The explicit projection
policy applies only after evidence collection. The option does not force a
fully reusable document back through raw-cache replay. A cache-required
development experiment caught and refused an early version that rebuilt before
grouping; that refusal is the executable reason this phase separation must
remain visible in code and diagrams. add_wor_tier itself is pub(crate).
In a PRODUCTION build it has exactly one caller: that one write phase
(FaApplied::then_enforce_monotonicity). The only other callers are test
code: unit tests of %wor generation shape itself, which do not claim the
ordering property, and the refresh_existing_alignment /
refresh_existing_alignment_with_boundary_policy convenience wrappers,
which write %wor directly and are #[cfg(test)] (2026-09-01 review, item
12) precisely because they have no production caller left – the cheap
rerun path that used to call them now goes through
refresh_reusable_alignment and the write phase instead, as this page
already describes below.
flowchart LR
I["Input CHAT + existing %wor"] --> R["Pre-group refresh<br/>always Preserve"]
R --> G["Stable group windows<br/>and raw cache keys"]
G --> E{"Evidence state"}
E -->|raw hit| P["FaProjectionPolicy"]
E -->|wor reuse| P
E -->|required miss| F["EvidenceUnavailable refusal"]
P -->|Preserve prior bounds| C["Compatibility word projection"]
P -->|RebuildFromEvidence| H["Admitted word hull projection"]
C --> PHASE["FaApplied or typed<br/>no-injection projection"]
H --> PHASE
PHASE --> B{"BulletRepairPolicy"}
B -->|Disabled| O{"EndOverlapPolicy"}
B -->|Enabled| RPR["Repair: same three-way resolution\non measured hulls, small overlaps only"] --> O
O -->|"PreserveCrossSpeaker (default)"| X["Same-speaker: 3-way resolution\nfrom measured hulls;<br/>cross-speaker: untouched"]
O -->|ClampAllAdjacent| COMP["3-way resolution for EVERY\nadjacent pair, any speakers"]
X --> WOR["WorPlan::Pending →<br/>write %wor from RESOLVED state"]
COMP --> WOR
Four-state evidence resolution
Each FA group is checked for reusability in priority order before inference:
Tier 1: Reuse from %wor tier
If all utterances in a group have clean %wor timing from a previous run,
those word timings are used directly without re-processing. This is the fastest
path and requires no worker inference.
Tier 2: Raw-evidence replay
If Tier 1 doesn’t apply, prefer the immutable worker-protocol response. BA3 re-admits it against the current request facts, then runs the current Rust projection. This is the research path: local reconciliation can change without running the model again.
Tier 3: Versioned derived-timing fallback
When raw evidence is absent or refused, an admitted derived timing envelope can still satisfy the group. It must prove the requested engine, selected-worker version, semantic key, and word cardinality. Historical bare vectors are refused because they cannot prove direct-versus-fallback provenance, while a new raw entry cannot be masked by an older local projection.
Tier 4: Authorized inference
Only a miss at all three earlier states reaches the worker. RequireCache
cannot construct the authorization value needed by the worker batch. A direct,
version-identified worker response is stored in both raw and derived layers;
fallback output is valid for the live run but deliberately remains uncached.
flowchart TD
G["Current FA group<br/>audio window + words + engine"]
W{"Complete, corroborated<br/>%wor timing?"}
R{"Admitted raw worker<br/>evidence?"}
RP["Replay through current<br/>Rust timing projection"]
D{"Admitted versioned<br/>derived timing envelope?"}
P{"Cache policy permits<br/>inference?"}
A["Typed inference authorization"]
I["Worker inference"]
V{"Direct version-identified<br/>evidence?"}
C["Commit direct raw evidence<br/>and versioned derived timings"]
L["Use fallback/unaligned result<br/>for this run only"]
F["Fail closed:<br/>required evidence missing"]
O["Apply current CHAT/%wor logic"]
G --> W
W -->|yes| O
W -->|no| R
R -->|yes| RP --> O
R -->|absent or refused| D
D -->|yes| O
D -->|no| P
P -->|UseCache or SkipCache| A --> I --> V
V -->|yes| C --> O
V -->|no| L --> O
P -->|RequireCache| F
Implementation: crates/batchalign/src/fa/mod.rs and
crates/batchalign/src/fa/transport.rs.
Worker IPC: FA task (V2 protocol)
Client → Worker: execute_v2 request (abridged)
{
"task": "fa",
"request": {
"backend": "wav2vec" | "whisper" | "wav2vec_canto",
"audio_ref_id": "...",
"payload_ref_id": "...",
"text_mode": "char_joined" | "space_joined" | "char_spaced"
},
"attachments": ["prepared audio", "prepared text payload"]
}
Worker → Client is one of two typed results:
- Wave2Vec/Cantonese: one indexed optional interval per requested word,
{start_ms, end_ms, confidence?}. Rust validates the count and applies the intervals directly; there is no DP remapping. - Whisper: token text plus onset time. Rust uses DP alignment to reconcile those returned tokens with CHAT words and derives word ends because the engine did not measure them.
The Python Wave2Vec callback duration-weights token-span scores into a word
score before crossing the V2 boundary. Rust validates that optional score as a
finite value in 0..=1, stores it quantized to millionths, and keeps it
separate from boundary provenance. The score is not treated as a calibrated
probability.
UTR strategy resolution
ResolvedUtrStrategy::from_options() in
crates/batchalign/src/runner/dispatch/options.rs resolves the submitted policy.
The two-pass variant owns its tuning and travels through both initial and
fallback recovery. resolve_strategy() in runner/dispatch/utr.rs adds the
recording grouping limits without replacing the submitted configuration:
Auto strategy (default): Always returns GlobalUtr regardless of language or overlap markers.
The previous auto-detection logic (which selected TwoPassOverlapUtr for English
files with +< or CA overlap markers) was disabled 2026-03-30 due to:
- Operator-reported alignment regressions on real files
- At the time,
enforce_monotonicity()corrected only start regressions and left end overlap unexamined. Current code clamps adjacent ends, but a clamp that cuts retained word timing is now evidence for review rather than proof that the overlap-aware segmentation was wrong. - Two-pass algorithm was only tuned on 4 corpora, not broadly validated
Explicit overrides:
--utr-strategy global→GlobalUtr(single-pass monotonic recovery)--utr-strategy two-pass→TwoPassOverlapUtr(experimental; overlap-aware, gated until its segmentation and downstream overlap policy are validated)
When both total_audio_ms and max_group_ms are available, a GroupingContext is
passed to TwoPassOverlapUtr so it can detect and avoid the wider-window regression
on non-English files. This is only consulted on explicit --utr-strategy two-pass;
Auto does not reach this code path.
Incremental processing (--before)
When --before PATH is provided, process_fa_incremental() in
fa_pipeline.rs diffs the old and new CHAT files, classifies each utterance
as Added/Removed/Modified/Unchanged, and only runs FA on content that changed.
Stable %wor entries from the old file are copied directly, skipping the FA
worker entirely for unchanged groups.
FA grouping constraints
group_utterances() enforces two independent split constraints. A group is
flushed when either is exceeded by adding the next utterance:
- Time window: not an option at all. It comes from the run’s FA engine
(
FaParams::max_group_ms()readsFaEngineName::max_group_ms(), which is themax_groupfield of that engine’s row inFA_ENGINES), so it differs between engines and no caller can set it independently of the engine it belongs to - Label-byte cap:
MAX_GROUP_LABEL_BYTES = 448(constant ingrouping.rs, counted through theLabelBytesnewtype). Whisper’s CTC FA refuses more than 448 label TOKENS and raises a hard PythonValueError. The budget is counted in UTF-8 BYTES because every token covers at least one byte, so a byte count bounds the token count from above; a character count does not, and would loosen the cap on non-Latin script. The cap applies to every engine’s groups, since grouping is not told which engine will align them. Dense languages (Spanish, any long-word corpus) can hit it inside a normal time window.
The cap is consulted only where two utterances are MERGED, so it bounds merges rather than every group: the flush guard is skipped when the current group is empty, and one utterance whose own labels exceed 448 bytes is sent as its own group, unsplit (fail gracefully rather than drop silently).
See Forced Alignment: FA grouping strategy for the full rationale, flowchart, and edge cases.
Pre-grouping preparation steps
Before FA grouping, the AST undergoes two surgical modifications to prepare utterance bullets for inference:
Narrow bullet rescue (enabled always)
When transcribe writes a bullet that is too narrow to contain its words (e.g., 22
words in 380 ms = 58 wps, physically impossible), the rescue pre-pass detects and
expands that bullet into the trailing inter-utterance gap. This gives FA a wide-enough
audio window to find the actual speech. After FA finishes, update_utterance_bullet
overwrites the rescued range with the FA word span (tighter), so the rescue is
self-healing and auditable.
Implementation: crates/batchalign/src/chat_ops/fa/mod.rs:247-267. Decisions (which utterances
were rescued) are recorded in structured evidence rather than injected into CHAT.
Edge filler expansion (enabled always)
UTR-assigned bullets may be too narrow to include trailing or leading fillers whose
audio lives in inter-utterance gaps. This step expands utterance bullets to cover
those edge fillers, ensuring they are included in the FA group.
Implementation: crates/batchalign/src/chat_ops/fa/mod.rs:269-272.
Compound filler splitting
CHAT underscore-joined fillers (&-you_know, &-sort_of) are split at
underscores before being sent to the FA engine because ASR models return them
as separate words. After alignment, the N timings are merged back into one span.
Only WordCategory::Filler words are split, regular compounds (ice_cream)
are unchanged.
See crates/batchalign/src/chat_ops/fa/COMPOUND_FILLER_ALIGNMENT.md.
Decision evidence and CHAT cleanup
The align pipeline records structural decisions internally. It projects them
into structured evidence and never generates %xalign or %xrev. The legacy
review_level values remain accepted for wire compatibility but do not change
this presentation policy.
Decision sources (in order):
- Narrow bullet rescue: utterances whose bullets were pre-expanded before grouping (see “Pre-grouping preparation steps”)
- FA word timing injection: word boundaries, timing drops, speech gaps
- Experimental bullet repair: only if
--bullet-repairflag is enabled - Monotonicity enforcement: start-time regressions stripped, end-time overlaps clamped
All previous %xalign/%xrev tiers are stripped, including on clean re-runs
with no new decisions.
Implementation: crates/batchalign/src/chat_ops/fa/mod.rs:506-537. The injection layer is in
crates/batchalign-transform/src/decisions/.
Durable alignment evidence
CHAT decision tiers are no longer a projection surface. With --debug-dir,
FaResult::into_timeline_trace produces the authoritative research record in
<stem>_fa_evidence.json through DebugDumper::dump_fa_evidence. The dump is
fail-closed when requested and includes:
- schema version, engine, and worker-advertised engine version;
- group windows, words, and stable word IDs;
- per-group source (
wor_reuse,cache, orinference) and cache key; - pre-injection valid timings, optional model score, and exhaustive origin chains for both boundaries;
- the exact typed decision records retained independently of CHAT output;
dropped_word_timings: every word timing the run discarded outright, one self-describing record each (line, utterance, speaker, tier, word position, measured span, and the bound it exceeded). Derived from the timing decisions at assembly time byFaTimingDecisionTrace::dropped_word_timings, so it cannot drift from them, and always written, empty when nothing was dropped;- fallback events and post-validation violations.
The indexed alignment algorithm temporarily needs separate vectors while
cache hits and worker replies arrive out of order. Before FaResult can exist,
assemble_group_evidence verifies that the group, source, cache-key, and
pre-injection-timing populations have identical cardinality and consumes them
into one FaGroupEvidence value per group. The result type stores only those
paired values. into_timeline_trace may flatten them back into the established
parallel JSON fields, but current BA3 code cannot construct a trace by pairing
one group’s timings with another group’s provenance.
DebugDumper::evidence_stem preserves the plain basename for a bare filename.
For a nested submitted identity it appends twelve hex characters from a BLAKE3
digest of the complete filename. This prevents equal basenames in different
corpus branches from sharing one evidence path.
Serialization completes before the destination is opened. The resulting bytes
are synchronized and atomically replace the destination, followed by a
directory synchronization on Unix. An interrupted write therefore cannot
leave a truncated JSON artifact or follow a pre-existing destination symlink.
Rev-backed UTR calls the same dump_rev_evidence boundary after raw evidence
resolution and before timed-word projection. It selects
RevAsrProjectionRevision::UtrAsrResponseV1; the closed revision type prevents
a caller from inventing a label or attaching transcribe’s ASR revision by
string convention. rev_utr_evidence_identity combines the stable CHAT
filename with the raw evidence-key prefix, preventing full-file and
partial-window calls from overwriting each other while avoiding temporary
segment paths as identities.
Schema version 2 added decisions to retain post-inference clamping, repair,
and timing-removal outcomes. A typestate return from retain_decision_evidence
is consumed into the evidence trace, so the JSON cannot be assembled from a
different record set than the pipeline produced. The complete-%wor fast path
and a grouping-empty path retain any decisions they make as well; zero fresh
inference groups does not erase a monotonicity change or grouping refusal.
Schema version 3 adds stable current and neighbouring utterance ordinals to
every numeric monotonicity effect. The legacy line_idx fields name the input
ChatFile.lines state and are retained for debugging, but they cannot alone
address final CHAT because provenance serialization may insert an @Comment
header. An utterance ordinal is invariant under header-only changes. Research
consumers should corroborate both coordinates against the exact input and
resolve the ordinal against output while checking speaker and spoken-token
identity; they must not index final ChatFile.lines with the legacy value.
post_injection_timings
remains intentionally empty: the
post-processing phase still lowers final WordTiming values into CHAT bullets
before a group-shaped evidence record can retain them, particularly for split
compound fillers. Do not describe any current schema as a complete repair history. A later
future schema must carry a typed identity mapping across that phase rather than
re-reading bullets and falsely labeling them observations.
Post-FA validation
After FA finishes, FaOutput::processed consumes the mutable ChatFile and
calls Chatter’s reconcile_media_timing. The result retains either an untimed
document or a timed document with exactly one usable, linked @Media
declaration. Only that state can reach the serialization boundary in
runner/dispatch/fa_pipeline.rs. Dummy and NoAlign paths use the separate
FaOutput::PassThrough variant, preserving their input without claiming that
timing work occurred. A typed MediaTimingError fails contradictory timed
output before any successful result can be written.
The reconciled CHAT file is then gated at the level its INPUT was admitted at,
MainTierValid (L2), by FaAdmission::finish (output gate equivalent to
Command Contracts: align post-validation).
The gate is fail-closed, and this paragraph used to say the opposite
(“validation errors are warnings only … logged but do not fail the job”).
That was true of the warn!-and-write shape PostValidated replaced: a file
whose %mor had drifted or whose terminator a transform had eaten still landed
on disk and still reported success. A file whose aligned output now fails the
gate fails THAT file with FailureCategory::Validation, and nothing is written.
The level comes off the admission rather than being restated at the gate, so
output cannot be judged at a lower bar than its input was admitted at.
The proof the gate returns is what the writer carries. It reaches
FileOutput::Chat as a PostValidated, not a String, so the bytes written
are the bytes the gate serialized; the writer no longer parses the text and
manufactures a second, weaker proof of its own.
Implementation: crates/batchalign/src/types/results.rs,
crates/batchalign/src/fa/mod.rs, and
crates/batchalign/src/runner/dispatch/fa_pipeline.rs.
Testing
# Fast unit tests (no ML models)
make test
# FA-specific tests with real models (only on Fleet/Large-tier hosts, ≥ 256 GB RAM)
cargo test -p batchalign --features ml-golden --test ml_golden fa::
# Incremental processing tests
cargo test -p batchalign --lib fa::incremental::tests::
Key test locations:
crates/batchalign/src/chat_ops/fa/: unit tests for grouping, injection, UTRcrates/batchalign/tests/: integration tests for the FA pipeline
Related developer documentation
- Command Flowcharts: align, detailed runtime flowchart with 3 diagrams
- Forced Alignment, algorithm design, prerequisites
- Dynamic Programming, Hirschberg aligner
- Incremental Processing,
--beforemechanics - Overlap Encoding,
+<and CA marker handling - Command Contracts, pre/post validation gates
- Adding Commands, use
alignas the reference implementation forPerFileTransform
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
transcribe: Developer Reference
Status: Current Last updated: 2026-09-16 08:18 EDT
Implementation guide for the transcribe command. For user-facing
documentation, see User Guide: transcribe.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: TranscribeArgs | Typed ASR and speaker engines, diarization, lang, num-speakers |
| Options builder | crates/batchalign/src/cli/args/options.rs:195-243 (inline dispatch) | Maps TranscribeArgs → CommandOptions::Transcribe(TranscribeOptions) |
| Catalog entry | crates/batchalign/src/recipe_runner/catalog.rs | the CatalogEntry for transcribe |
| Stage recipe | crates/batchalign/src/recipe_runner/recipes.rs | TRANSCRIBE_RECIPE |
| Pipeline orchestration | crates/batchalign/src/pipeline/transcribe.rs: run_transcribe_pipeline() | ASR, optional dedicated diarization, post-process, speaker projection, pre-CHAT utseg, CHAT assembly, optional morphotag, serialize |
| Per-file dispatch | crates/batchalign/src/runner/dispatch/transcribe_pipeline.rs | Concurrent file orchestration bounded by semaphore |
| ASR post-processing | crates/batchalign-transform/src/asr_postprocess/mod.rs | 8 stages: compound merge, MWT split, number expand, Cantonese norm, long-turn split, retokenization, disfluency, retrace detection |
| Pre-CHAT utterance segmentation | crates/batchalign/src/pipeline/transcribe.rs: process_asr_with_prechat_segmentation() | Runs for eng/cmn/zho/yue when enabled: admitted BERT evidence and selected policy applied to prepared chunks before build_chat |
| CHAT assembly | crates/batchalign-transform/src/build_chat/mod.rs:41: build_chat() | Assembles ChatFile AST from TranscriptDescription (typed bridge) |
| Speaker projection | crates/batchalign/src/chat_ops/speaker.rs: project_speakers_onto_chunks() | Projects raw segments onto timed ASR words and splits prepared chunks before utseg and CHAT assembly |
| Speaker evidence cache | crates/batchalign/src/transcribe/evidence_cache.rs | Separate raw/derived identities and envelopes, per-key lease, typed miss authorization, durable commits, fakeable inference boundary |
| Shared speaker operation | crates/batchalign/src/transcribe/infer.rs: resolve_speaker_evidence_for_audio() | Constructs the exact request/model identity and owns cache-or-infer resolution for both integrated transcribe and standalone diarize |
| Standalone dispatch plan | crates/batchalign/src/runner/dispatch/plan.rs: MediaAnalysisDispatchPlan::Diarize | Carries an already-resolved backend, optional expected count, and cache policy; execution cannot invent or change them |
| Same-job turn retention | crates/batchalign/src/runner/debug_dumper.rs: dump_speaker_turns() | When --debug-dir is set, writes the exact dedicated turns used by transcribe or returns a typed failure |
| Canonical turns schema | crates/batchalign/src/runner/dispatch/diarize_turns.rs | Serializes chatter-compatible turns with backend-derived provenance |
| ASR worker IPC | batchalign/inference/asr.py | Python-hosted ASR engines; Rev is Rust-owned |
| Raw Rev evidence cache | crates/batchalign/src/revai/evidence_cache.rs | Provider-media identity, exact transcript JSON or typed legacy envelope, miss authorization, durable commit, fakeable Rev boundary |
| Speaker worker IPC | batchalign/inference/speaker.py: infer_speaker_prepared_audio() | Exhaustive dispatch over pyannoteAI, local Pyannote, and NeMo, returning a backend-specific evidence variant |
| Local Pyannote graph | batchalign/inference/local_pyannote_model.json + pyannote_local.py | One validated manifest pins the pipeline, segmentation, and embedding commits; Rust hashes the same manifest into the evidence revision |
| pyannoteAI adapter | batchalign/inference/pyannote_ai.py | Typed prepare, upload, submit, complete lifecycle for Precision-2 exclusive diarization |
Current evidence-to-CHAT topology
This diagram is the whole transcribe path after the v0.3 evidence work. It separates evidence acquisition from deterministic local projection and makes the two utterance-segmentation passes visible. Dashed arrows are retained research/debug artifacts, not CHAT dependent tiers.
flowchart LR
MEDIA["Inference media"]
ROUTE{"Execution source"}
REV["Rev request identity<br/>raw cache / authorized provider call"]
OTHER["Non-Rev ASR worker"]
REPLAY["Fingerprint-admitted legacy<br/>projected replay"]
ASR["Typed AsrResponse"]
SPKQ{"Dedicated diarization?"}
SPK["Speaker request identity<br/>raw cache / authorized inference"]
TURNS["Validated exact turns"]
PROJ["Project speakers onto<br/>timed ASR words"]
PREP["Deterministic ASR cleanup<br/>and prepared chunks"]
PREUT["Pre-CHAT boundary model<br/>+ selected policy"]
CHAT["Build CHAT AST"]
POSTUT["Optional post-CHAT utseg<br/>+ independently selected policy"]
MOR["Optional morphosyntax"]
OUT["Validated final CHAT"]
RAWE["Raw Rev causal evidence"]
SPKE["Raw/derived speaker<br/>causal evidence"]
UTE["Pre/post-CHAT utseg<br/>evidence + local receipts"]
RUN["Replay/run receipt"]
MEDIA --> ROUTE
ROUTE -->|"live Rev"| REV --> ASR
ROUTE -->|"live other ASR"| OTHER --> ASR
ROUTE -->|"offline replay"| REPLAY --> ASR
ASR --> SPKQ
SPKQ -->|"yes, live"| SPK --> TURNS
SPKQ -->|"yes, replay"| TURNS
SPKQ -->|"no"| PROJ
TURNS --> PROJ
ASR --> PROJ
PROJ --> PREP --> PREUT --> CHAT --> POSTUT --> MOR --> OUT
REV -.-> RAWE
SPK -.-> SPKE
TURNS -.-> SPKE
PREUT -.-> UTE
POSTUT -.-> UTE
REPLAY -.-> RUN
OUT -.-> RUN
Disabled optional stages are identity edges: without dedicated turns, speaker projection preserves the ASR chunks; without utseg, the prepared chunks go directly to CHAT construction; without morphosyntax, post-CHAT output goes to final validation. A replay state carries no Rev or speaker inference capability, so reaching either paid boundary requires an explicit exhaustive match change.
ASR post-processing chain
All ASR post-processing runs in Rust (crates/batchalign-transform/src/asr_postprocess/). The pipeline is deterministic and language-aware.
8-stage pipeline
-
Compound merging: rejoin compound words split by ASR
- Language-specific: English phrasal verbs, CJK terms, etc.
- Implemented:
compounds::merge_compounds()
-
Cantonese normalization (yue only), simplified→HK traditional + domain replacements
- One pass over the whole monologue, before anything splits it: the engines report one word per Han character, so a two-character replacement has to see both, and each word then gets back exactly its own characters
- Uses the
ferrous-opencccrate + the 31-entry replacement table - Implemented:
cantonese::AlignedNormalization, the only route to normalized Cantonese text, applied byprepare_words_pre_expansion() - A conversion that changed the character count refuses the file rather than re-cutting words away from their timings
-
Multi-word token splitting: split tokens containing spaces, interpolate timestamps
- Normalizes ASR outputs that glue multiple words together
- Distributes timing proportionally by text length
-
Number expansion: convert digit strings to word form
- Cardinals: 47 languages via
NUM2LANGstatic table (data/num2lang.json) - CJK: specialized
num2chinesepath - Ordinals/decades: English-specific
ordinal_year_engcomposer - Currency, percent, dash-ranges: dedicated Rust handlers
- Runtime: Pure Rust table lookup (Python
num2wordsinvolved at build time only for codegen; removed from runtime 2026-04-26)
- Cardinals: 47 languages via
-
Long-turn splitting: chunk monologues >300 words
- Prevents unbounded utterance lengths in downstream processing
-
Retokenization: punctuation-based utterance splitting
- Splits by CHAT-legal sentence terminators (
.?!+...etc.) - Handles long-pause splitting when ASR omits punctuation
- Splits by CHAT-legal sentence terminators (
-
Disfluency replacement: mark filled pauses and orthographic variants
- Filled pauses:
"um"→"&-um","uh"→"&-uh"(per-language wordlists) - Replacements:
"'cause"→"(be)cause","gonna"→"going to"(CJK-aware) - Implemented:
cleanup::apply_disfluency_replacements()
- Filled pauses:
-
N-gram retrace detection: detect repeated n-grams, wrap in
<...> [/]annotation- Identifies speaker self-corrections (rephrasings)
- Implemented:
cleanup::apply_retrace_detection()over sharedanalyze_exact_retraces()evidence
Pre-CHAT utterance segmentation (lang-specific)
For eng, cmn, zho, yue, a BERT-based utterance segmentation model runs after ASR post-processing and dedicated speaker projection but before CHAT assembly:
- Implemented in
crates/batchalign/src/pipeline/transcribe.rs:process_asr_with_prechat_segmentation() - Called only when utterance segmentation is enabled and the resolved
language’s route is
UtsegRoute::BoundaryModel(crates/batchalign/src/utseg_route.rs). That route answers which segmenter a language gets, reading availability fromUTSEG_BOUNDARY_MODELSincrates/batchalign/src/model_manifest.rs, which is the one owner of which languages have a boundary model and of the revision each one is pinned to. It replaced amatches!(lang.as_ref(), "eng" | "cmn" | "zho" | "yue")here that was one of three copies of that set, the others being a list inutseg_route.rsand the key set of_RESOLVER["utterance"]inbatchalign/models/resolve.py, which the worker used to decide whether to refuse and which no longer exists - A language whose route is
StanzaFallbacktakes the punctuation path here: there is no pre-CHAT Stanza segmenter, so an authorized fallback segments only after CHAT is built - A language with no route at all cannot reach this function. For a resolved
--lang,TranscribeDispatchPlan::from_jobrefuses the job before ASR is dispatched (DispatchPlanRefusal::UtsegUnavailable). Under--lang autothe language is unknown until ASR returns, so the same route resolution refuses here instead, which is as early as it can be known - Workflow:
- Prepare ASR chunks (stages 1-8 above)
- If dedicated diarization ran, project its segments onto timed words with
project_speakers_onto_chunks()and split chunks at speaker changes - Call
infer_utseg_predictions_with_policy()to get admitted per-chunk boundaries and retain their typed inference source, model evidence, and any local-policy receipt - Apply
split_prepared_chunk_by_assignments()to split chunks at boundaries - When
--debug-diris enabled, atomically persist the versionedpre_chatevidence before it can be erased to assignments - Convert to final utterances and finalize
- Purpose: Improve sentence boundary detection for languages with ambiguous punctuation
- For all other languages: skip pre-CHAT segmentation; use punctuation-based retokenization only
The production two-pass topology
For a supported language, normal transcribe execution runs the utterance model
twice: once over prepared timed ASR words before CHAT construction, and once
over main-tier words after CHAT construction. TranscribeUtsegExecution owns
this topology as a closed state:
Disabledmakes neither pass reachable.PreChatOnlyis reserved for an explicit offline topology experiment.PreAndPostChat { pre_chat, post_chat }is the production shape and records the decision policy for each pass separately.
Changing the first pass can change the contexts seen by the second. A result from a one-pass replay is therefore not a clean measurement of a decoder policy against production.
flowchart TD
E["Retained ASR + speaker evidence"] --> C["Prepared timed chunks"]
C --> X{"TranscribeUtsegExecution"}
X -->|"Disabled"| H["Build CHAT"]
X -->|"PreChatOnly<br/>offline experiment"| P1["Pre-CHAT model + policy"]
X -->|"PreAndPostChat<br/>production"| P2["Pre-CHAT model + policy"]
P1 --> PE1["Persist pre_chat evidence"]
PE1 --> H
P2 --> PE2["Persist pre_chat evidence"]
PE2 --> H2["Build CHAT"]
H2 --> Q["Post-CHAT model + independently selected policy"]
Q --> PE3["Persist post_chat evidence"]
PE3 --> O["Final CHAT"]
H --> O
batchalign3 eval transcribe-replay run replays retained ASR and speaker
evidence without provider inference. Its --utseg-passes choices are:
| Choice | Meaning |
|---|---|
both | Apply the selected policy on both production passes. |
pre-chat-only | Apply it before CHAT and omit the post-CHAT pass. This changes topology. |
policy-on-pre-chat-only | Keep both passes; apply the selected policy only before CHAT. |
policy-on-post-chat-only | Keep both passes; apply the selected policy only after CHAT. |
The last two choices isolate a policy while holding production topology and
the other pass fixed. Replay receipts record the exact pre- and post-CHAT
policies. The replay-only --no-utseg option disables both passes and cannot
be combined with a policy or pass selection.
Post-CHAT splitting consumes a closed SplitMainTimingEvidence state. A
PartitionedWorTiers value exists only after Chatter proves equal
policy-selected counts and canonical lexical correspondence, so a same-count
edit cannot enter the partition operation. CompletePerChildMainTiming then
exists only when Chatter’s sequence assessment yields one complete positive
word-timing hull for every retained child; the transform assigns those hulls
to the corresponding main tiers. All other shapes become
SplitMainTimingEvidence::ParentOnly, which gives no child a main-tier bullet
at all. The parent bullet measures the whole parent utterance, so it is not any
one child’s span: its start is where the first child began and its end is where
the last one finished, and nothing measured the boundary between them. Carrying
it onto the last child, which is what this did until 2026-09-16, presents an
unmeasured span as a measured one. The single exception is a split that kept one
child, which holds the parent’s whole content and therefore does have the
parent’s span; SoleChildSpan is that span’s only constructor and admits
exactly that case. This all-or-nothing transition prevents stale or partially
timed evidence from presenting a mixture of measured and guessed child spans as
if they had the same status.
Worker IPC: ASR task (V2 protocol)
execute_v2 request:
{
"task": "asr",
"prepared_audio": { path, start_ms, end_ms, sample_rate },
"engine": "whisper" | "whisper_hub" | "tencent" | ...,
"language": "eng",
"num_speakers": 2
}
execute_v2 response:
{
"tokens": [
{ "word": "hello", "start_s": 0.12, "end_s": 0.45,
"speaker": "SPEAKER_00", "confidence": 0.98 },
...
]
}
The speaker field is optional and depends on the worker backend. Rev.AI does
not use this Python worker request: Rust resolves, validates, and durably
caches raw Rev evidence at its own paid-service boundary before projecting an
AsrResponse.
Worker IPC: speaker task (V2 protocol)
When --diarization enabled is set, a second worker call runs after ASR:
execute_v2 request:
{
"task": "speaker",
"prepared_audio": { path, ... },
"backend": "pyannote_ai" | "pyannote" | "nemo",
"num_speakers": 2
}
execute_v2 response:
{
"evidence": {
"kind": "pyannote_ai",
"job_id": "provider-job-id",
"output": { "exclusiveDiarization": [ ... ] },
"warning": null
}
}
Local Pyannote and NeMo responses use the pyannote and nemo evidence
variants, respectively, each with its model-native millisecond segment list.
The FFI rejects a response whose evidence variant does not match the backend
in the request.
The typed CLI selector SpeakerEngineName maps exhaustively to
SpeakerBackendV2. Integrated enabled diarization defaults to PyannoteAi;
standalone diarize deliberately defaults to local Pyannote but can select
PyannoteAi or Nemo explicitly. Both consumers call
resolve_speaker_evidence_for_audio(), so neither can bypass the evidence
cache or construct a different model revision. The cloud adapter uses explicit lifecycle
states: PreparedWav, UploadedMedia, SubmittedDiarizationJob, and
CompletedDiarizationJob. Only a completed job can cross the worker boundary.
It requests exclusive: true; the versioned Rust normalizer prefers
exclusiveDiarization, which is the provider output designed for ASR
reconciliation.
Before that worker call, SpeakerEvidenceRequest::from_audio() hashes the full
inference media source and combines the digest with the preparation revision,
backend, expected speaker count, speaker-model revision, and evidence schema.
The model revision is a dedicated SpeakerEvidenceModelRevision newtype whose
only production constructor, for_backend, derives it from the speaker
backend. The raw speaker-evidence cache task accepts only that namespace type,
so no other identity, the ASR engine’s included, can scope speaker evidence.
resolve_speaker_evidence() owns the production decision:
- Acquire the process-local lease for the semantic cache key.
- Validate and replay derived segments when present.
- On a derived miss, validate retained raw evidence, normalize it under the
current
SpeakerNormalizationRevision, and commit a new derived envelope. - Only when raw evidence is also absent, produce a typed
SpeakerEvidenceMissand consume it intoSpeakerInferenceAuthorization. - Split the authorization into a single-use run and commit permit; reread and
verify the source digest, producing
VerifiedSpeakerEvidenceRun. - Prepare worker PCM from the verified run’s owned bytes and cross the
SpeakerEvidenceInferenceboundary exactly once. - Validate provenance and durably commit raw evidence, then derived segments, before releasing the lease.
SpeakerWorkerInference is the production implementation. Tests use the same
resolver with a call-counting fake, which proves how many times the billable
boundary is crossed. infer_speaker() itself is private behind the adapter.
Concurrent identical requests wait on the same lease and re-check SQLite after
the first request commits.
Cache corruption and cache-write errors fail the file. They never become a
miss, because that would make broken local state authorize a surprise paid
call. --override-media-cache deliberately constructs a forced-refresh miss,
then replaces the entry after successful inference.
--require-media-cache selects CachePolicy::RequireCache. On a raw miss,
speaker and Rev lookup return ServerError::RequiredEvidenceUnavailable
carrying a typed speaker or Rev cache identity rather than
SpeakerEvidenceMiss or RevAsrEvidenceMiss; consequently no
SpeakerInferenceAuthorization or RevAsrInferenceAuthorization can exist.
Warm raw evidence remains replayable, and a derived-speaker miss can still
travel through the raw-hit transition and run the local normalizer.
FA closes the same route at the worker boundary. Cache partitioning produces
raw miss indices, but only plan_fa_inference() can turn them into
FaInferenceAuthorization, which is required by FaWorkerBatch. Required
cache plus any unresolved group returns the same server error with a typed,
non-empty forced-alignment group set instead. This applies to both
full-file and incremental FA; neither can assemble a worker batch directly
from a miss vector.
The normalized UTR cache is derived evidence. A required-cache Rev UTR miss may therefore continue to the raw Rev resolver, where retained provider evidence can be projected again but a raw miss fails before provider inference. For local UTR backends, which have no separate raw-evidence layer, a required normalized-cache miss fails.
The raw envelope stores SpeakerInferenceEvidenceV2; for pyannoteAI this
includes the completed job ID, complete provider output object, and optional
warning. A separate envelope stores normalized SpeakerSegmentV2 values and
is keyed by the raw fingerprint plus SpeakerNormalizationRevision. Changing
only the local projection therefore cannot authorize a new paid call. The
pyannoteAI raw key uses its visible precision-2 alias; the provider does not
expose an immutable backend build hash. That limit is documented rather than
hidden behind an overclaim of perfect invalidation.
Rev.AI transcription follows the same stronger pattern through
resolve_rev_asr_evidence(). Its durable CompletedRevAsrEvidence retains the
resolved language and provider-shaped Transcript before token conversion.
RevAsrService cannot perform provider work without
RevAsrInferenceAuthorization, and generic ASR accepts NonRevAsrBackend, so
RustRevAi cannot bypass the cache gate through that function.
The raw key’s provider-media digest is semantically load-bearing. Controlled MP3-versus-decoded-PCM16 submissions have produced broad lexical, timing, and Rev speaker-boundary differences, so no normalization layer may replace it with a decoded-waveform or perceptual-equivalence key. A future configurable Rev media-preparation policy must identify its exact prepared bytes and recipe; it cannot reuse evidence from another encoding merely because the source recording is the same.
The legacy batch preflight shortcut is disabled for transcribe, benchmark, and align because it submitted before evidence lookup. Cold calls currently use normal per-file concurrency. A future cache-aware parallel preflight must use plan variants that contain either validated evidence or an authorized miss; an optional untyped job ID is not an acceptable replacement.
project_speakers_onto_chunks() treats those segments as authoritative before
utterance segmentation. Each timed ASR word receives the label with the
greatest summed overlap. The operation then splits prepared chunks at label
changes. It reports contested words, unattested words, and inserted boundaries;
untimed tokens inherit adjacent evidence, and timed gaps take the nearest
dedicated segment. Once the dedicated segment set is nonempty, the projection
type cannot emit an ASR-origin label.
DiarizationLabelCoordinates is the sole coordinate map from model-native
labels to anonymous speaker indices. Both CHAT projection and canonical turns
serialization consume this type. This prevents a valid but false state where
PAR0 in CHAT identifies a different voice from PAR0 in the retained turns
artifact.
When --debug-dir is enabled, stage_speaker_diarization() calls
DebugDumper::dump_speaker_evidence() and
DebugDumper::dump_speaker_turns() before moving the segments into pipeline
state. The first persists a resolver-bound causal record: source digest,
request and model semantics, both cache identities, normalization revision,
cache outcome, named segment projection, and a versioned content digest of the
validated timing/label sequence. The second persists the exact normalized
turns consumed downstream. Its result is
SpeakerTurnsDumpOutcome::Disabled or
SpeakerTurnsDumpOutcome::Written(PathBuf). Enabled failures are typed as
SpeakerTurnsDumpError and fail the file. Provenance is derived exhaustively
from SpeakerBackendV2 through SpeakerTurnsSource; callers cannot attach an
arbitrary source string.
The Rev resolver captures a private trace seed from the exact
RevAsrEvidenceRequest and returns it bound to the resolved cache outcome.
The resolved evidence also carries a typed fidelity: strictly decoded,
byte-preserving provider JSON for a new response, or a legacy typed projection
migrated from storage schema 2. The
Rev branch of stage_asr_infer() adds the named ASR projection and calls
DebugDumper::dump_rev_evidence() before discarding raw evidence identity.
The dump is atomic, collision-resistant for nested input identities, and
fail-closed. This is intentionally distinct from _asr_response.json: the
latter contains projected tokens, while the Rev sidecar proves which media,
provider presentation, cache entry, and projection produced them.
Utterance-boundary inference has its own typed admission and evidence path.
Python returns a closed semantic action for each classified word, a fixed-point
sentence-end probability, and both the raw and adjacency-policy-applied action.
Normalization omission and short-input bypass are variants rather than magic
scores. AdmittedUtsegPrediction in Rust refuses ambiguous success payloads,
assignment-length mismatches, evidence-length mismatches, and missing model
identity before an applicable transform response can exist.
UtsegEvidenceTrace retains the exact request words, admitted assignments,
inference-source variant, and model evidence. Transcribe writes distinct
pre_chat and post_chat artifacts through UtsegEvidenceSink; disabled and
enabled sinks are explicit states, and an enabled serialization or durable
write failure fails the file. The ordinary standalone utseg command still
projects admitted predictions to the legacy assignment response deliberately;
it does not claim to have written a transcribe debug artifact.
Language resolution flow
When the user specifies --lang auto, language detection happens in two phases:
Phase 1: ASR-level detection
- ASR worker returns detected
langfield in response (e.g., “spa”, “fra”, “eng”) - This becomes the resolved language for CHAT headers and NLP stages (utseg, morphosyntax)
- Implemented:
resolved_asr_language()incrates/batchalign/src/pipeline/transcribe.rs:362-385
Phase 2: Per-utterance code-switching detection (if lang=auto)
- During
build_chat(), each utterance text is analyzed withlang_detect::detect_utterance_language() - Detected language stored in
Utterance.langfield - Used to emit
[- lang]code-switching precodes in CHAT tier (if different from resolved language) - Implemented:
build_chat()stage lines 629-657
For fixed languages (not auto):
- No per-utterance detection; entire file uses specified language
- No code-switching precodes emitted
Rev.AI skip_postprocessing gate
For lang == eng || lang == fra, Rev.AI is called with
skip_postprocessing=true. This suppresses Rev.AI’s built-in punctuation
so that BA3’s BERT utseg model handles sentence boundary detection. For all
other languages, Rev.AI post-processing is applied. Gate implemented in
batchalign/inference/asr.py: _revai_request().
transcribe_s vs transcribe
transcribe_s is not a separate CLI command. It is an internal command
variant triggered by --diarization enabled. Both share the same
transcribe_pipeline.rs orchestrator; the only difference is whether the
dedicated speaker stage runs.
Testing
# Fast unit tests (no ML models)
make test
# Transcribe golden tests (real ASR models, only on Fleet/Large-tier hosts)
cargo test -p batchalign --features ml-golden --test ml_golden transcribe::
# Python ASR inference tests
uv run pytest batchalign/tests/test_asr.py -m golden
Related developer documentation
- Command Flowcharts: transcribe, detailed runtime flowchart
- ASR Token Pipeline, post-processing details
- Cantonese and CJK, Architecture, Tencent, Aliyun, FunASR engine dispatch
- Number Expansion, per-language Rust expansion
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
morphotag: Developer Reference
Status: Current Last updated: 2026-09-06 03:27 EDT
Implementation guide for the morphotag command. For user-facing
documentation, see User Guide: morphotag.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: MorphotagArgs, MorphotagPolicyArgs | I/O, tokenization, multilingual handling, lexicon, and analysis policy (--no-l2-morphotag, --no-pos-hints, --ca-policy) |
| Options builder | crates/batchalign/src/cli/args/options.rs (inline dispatch) | Maps CLI values to wire-compatible MorphotagOptions |
| Command definition | crates/batchalign/src/commands/morphotag.rs | CommandDefinition impl, pre-validation gate |
| Runtime policy | crates/batchalign/src/types/params.rs: MorphotagExecutionPolicy | Lowers wire booleans into typed L2, $POS, and CA policies |
| CA disposition | crates/batchalign/src/morphosyntax/mod.rs: MorphotagDisposition | Produces Analyze or PassThroughCa once from the parsed header and submitted policy |
| Morphosyntax orchestration | crates/batchalign/src/morphosyntax/ | Cross-file batching, worker dispatch, result injection |
| Batch dispatch | crates/batchalign/src/runner/dispatch/infer_batched.rs | Pools all files into a single ML call |
| Injection | crates/batchalign-transform/src/morphosyntax/injection.rs | inject_results(): writes %mor/%gra from typed UD annotations |
| Retokenization | crates/batchalign/src/retokenize/ | Character-level DP for Stanza word splits/merges |
| Payload collection & injection | crates/batchalign-transform/src/morphosyntax/: collect_payloads(), clear_morphosyntax(), inject_results(), remove_empty_morphosyntax_placeholders() | Cross-crate: domain logic lives in talkbank-transform model layer |
| Worker IPC | batchalign/inference/morphosyntax.py: batch_infer_morphosyntax() | Loads Stanza, returns raw to_dict() UD annotations |
Local submissions (auto-daemon or loopback --server) use paths_mode=true:
the CLI posts source/output path lists instead of CHAT bytes. See
Submission Modes.
Single-file phase ownership
The single-file path in pipeline/morphosyntax.rs uses consuming transitions:
flowchart LR
P["ParsedFile"] --> CA["CA pass-through"]
CA --> CS["Strip legacy decision tiers and serialize"]
P --> A["Analysis of Parsed input"]
A --> V["Admitted main tiers"]
V --> C["Prepared: stale morphology cleared"]
C --> B["Collected payloads and hint evidence"]
B --> I["Inferred: matching responses or NoWork"]
I --> R["Applied morphology"]
R --> K["PostChecked"]
K --> S["Provenance, placeholder cleanup, serialization"]
Analysis<S> owns the CHAT document and its per-file language. Each phase
exposes only the next operation: an unparsed or unadmitted document cannot
reach payload collection, and injection requires an inferred phase carrying
its payloads and matching response count. HintPlan contains captured evidence
when requested; there is no separate flag permitting a missing evidence value.
The shared transform boundary now owns MatchedMorphosyntaxResponses:
construction rejects missing or extra utterance responses, and binding to the
mutable CHAT document checks every destination index before any injection.
The existing inject_results entry point admits through the same type, while
the typed pipeline carries the admitted batch directly into its consuming
inject method. This prevents zip truncation and stale-index panics. Batch
admission is distinct from the existing per-token linguistic mismatch policy,
which still records diagnostics. Equal counts and valid indices do not prove
response ordering or linguistic correctness.
Job-level language is excluded from the immutable run options. The two language representations needed by the worker and model APIs are resolved once.
CA pass-through remains a separate policy outcome. It does not claim analysis
admission, resolve an inference language, or traverse six no-op analysis stages.
Its existing lenient parse/recovery behavior remains unchanged. PostChecked
means the existing non-fatal output checks ran; it does not claim the output
was admitted as valid. Neither branch stores optional final output.
The shared generic observe_stage helper retains the existing start/completion
trace fields and duration measurement for transitions that execute. It accepts
and returns the transition’s concrete types without boxing futures or building
an eight-stage dependency graph for every file. Other command pipelines still
use the dynamic planner and share the same observation helper.
Cross-file batching
morphotag is the canonical CrossFileBatchTransform command. All utterances
from all input files are pooled into a single Stanza inference call. This
eliminates per-file model warm-up overhead.
After the worker responds, results are repartitioned by file and injected per-file.
infer_batched.rs is the shared dispatch helper for all cross-file batch
commands (morphotag, utseg, translate).
Repeated and incremental runs
Morphotag does not use the persistent audio-task cache. A repeated full run
sends every applicable utterance through Stanza again. To preserve existing
%mor and %gra on unchanged utterances, pass the prior CHAT tree with
--before; the incremental path diffs typed utterances and dispatches only
inserted or word-changed material.
Worker IPC: morphosyntax task
batch_infer request:
{
"task": "morphosyntax",
"items": [
{ "words": ["hello", "world"], "lang": "eng",
"terminator": ".", "special_forms": [] },
...
]
}
batch_infer response:
[
[
{ "id": [1], "text": "hello", "upos": "INTJ", "lemma": "hello",
"head": 2, "deprel": "discourse", "feats": {} },
...
],
...
]
The Rust injection layer (inject_results()) maps the UD annotation back to
the CHAT AST word by word, writing %mor (pos|lemma notation) and %gra
(idx head deprel) tiers.
Upstream-defect ingress filter (Python side)
Before the UD annotations cross back from Python to Rust,
batch_infer_morphosyntax runs a known-defect workaround over every
Stanza sentence: any <SOS>, <EOS>, <UNK>, <PAD>, <s>,
</s> (and similar neural-LM control-token) substrings that leaked
into word.text or word.lemma are stripped in place. Every
rewrite emits a tracing.warning naming the language, the leaked
value, and the post-strip replacement so the workaround is visible
in ~/.batchalign3/server.log.
This is Defect 4 in the Stanza Limitations
registry.
The minimum trigger is 3+-word Finnish input containing tollei on
Stanza 1.11.1. chatter validate is the downstream gate if a leak
variant escapes the filter’s vocabulary.
Code + tests:
batchalign/inference/_control_token_filter.py: pure stripper + regexbatchalign/inference/morphosyntax.py: call site insidebatch_infer_morphosyntaxafterdoc.to_dict()batchalign/tests/inference/test_control_token_filter.py, 34 pure-function tests (regex vocabulary, strip contract, MWT safety)batchalign/tests/pipelines/morphosyntax/test_stanza_fi_mwt_sos_leak.py, standalone upstream reproducerbatchalign/tests/pipelines/morphosyntax/test_control_token_leak_propagation.py, integration test throughbatch_infer_morphosyntax
The five-step workflow that produced this filter is the same one any future upstream defect should follow, see Upstream Defect Policy.
Language-group failure propagation (Rust side)
A separate layer at the Rust orchestrator handles pool-level failures:
worker saturation, timeouts, worker crashes, and the now-retired
“would deadlock” bailout (replaced by idle-cross-group
eviction in worker/pool/eviction.rs). The typed
LanguageGroupFailure
aggregator receives per-group outcomes, and
classify_file_for_injection
routes every file whose utterance range intersects a failed group to
TextBatchFileResult::err, skipping inject_results() so no CHAT is
serialized with stripped tiers.
The two layers are complementary:
| Layer | Handles | Response |
|---|---|---|
| Python ingress filter | Upstream library produces bad individual tokens | Strip + log; file lands clean |
| Rust failure propagation | Pool-level failure produced no response at all | Per-file error; file not written |
Code: crates/batchalign/src/morphosyntax/outcomes.rs (pure
aggregator + classifier, 13 tests),
crates/batchalign/src/morphosyntax/dispatcher.rs (trait boundary
for fake-pool tests),
crates/batchalign/src/morphosyntax/saturation_tests.rs (3
end-to-end corruption-regression tests).
See also: Batchalign Workers, Saturation Safeguards.
Pipeline stages: parse → clear → collect → infer → inject → serialize
Morphotag runs a re-entrant pipeline that must preserve CHAT round-trip fidelity. The important stages in order:
flowchart TD
parse["Parse CHAT\n(batchalign, tree-sitter)"]
clear["clear_morphosyntax()\n(morphosyntax/payload.rs)\nreplace Mor/Gra in place with EMPTY\nMorTier::new_mor(Vec::new()) / GraTier::new_gra(Vec::new())"]
collect["collect_payloads() + collect_pos_hints()\nCapture typed $POS evidence before retokenization\nhas_mor requires Mor tier to be NON-EMPTY"]
infer["Batch infer\n(runner/dispatch/infer_batched.rs)"]
inject["inject_results()\n(morphosyntax/injection.rs)\nwrites %mor and %gra into the SAME slots"]
l2["L2 dispatch (if enabled)\n(morphosyntax/l2/*)"]
poshints["Apply captured POS evidence (if enabled)\n(morphosyntax/pos_hints.rs)"]
sweep["remove_empty_morphosyntax_placeholders()\n(morphosyntax/payload.rs)\nserialize-time sweep"]
serialize["Serialize CHAT\n(talkbank-transform)"]
parse --> clear --> collect --> infer --> inject --> l2 --> poshints --> sweep --> serialize
clear -. "preserves tier position in dependent_tiers" .-> inject
collect -. "skips only utterances whose Mor tier is non-empty" .-> infer
collect -. "typed hint evidence survives main-tier retokenization" .-> poshints
Diagram verified against: crates/batchalign/src/morphosyntax/batch.rs (orchestration),
crates/batchalign-transform/src/morphosyntax/injection.rs (inject_results),
crates/batchalign-transform/src/morphosyntax/payload.rs (clear/collect/sweep),
crates/batchalign-transform/src/morphosyntax/pos_hints.rs (capture/apply),
crates/batchalign/src/chat_ops/morphosyntax_ops/tests.rs (tier-order regression tests).
Tier-order preservation
clear_morphosyntax previously removed the %mor and %gra dependent
tiers outright. inject_results then called the old “remove-then-add”
pattern at the end of dependent_tiers, so regenerated tiers were
displaced to the tail of the list. On files whose source layout put
%wor last, very common, the round trip parse → clear → infer → inject → serialize produced a large, spurious tier-order diff.
The current pattern:
clear_morphosyntaxreplaces the Mor/Gra entries in place with emptyMorTier::new_mor(Vec::new())andGraTier::new_gra(Vec::new()). Original tier position is retained.inject_resultswrites into the same slots.remove_empty_morphosyntax_placeholdersis called at serialize time to remove any still-empty placeholders (utterances where no morphosyntax was produced).crates/batchalign/src/chat_ops/fa/mod.rs::add_wor_tier(line 244) usesreplace_or_add_tierinstead of the oldremove_wor_tier + pushsequence, applying the same preservation principle to%wor.
Regression tests (in crates/batchalign/src/chat_ops/morphosyntax_ops/tests.rs):
clear_then_reinject_preserves_tier_order_mor_gra_wor(line 1185)add_wor_tier_preserves_tier_order_wor_mor_gra(line 1258)collect_payloads_treats_empty_mor_placeholder_as_unprocessed(line 1302), tier-order test covering the empty-placeholder sweep
collect_payloads empty-placeholder fix
collect_payloads uses a has_mor check to skip utterances that have
already been annotated. The original check tested only for the presence
of the DependentTier::Mor variant, which meant the empty placeholders
left by clear_morphosyntax looked “already processed” and every
utterance was skipped.
Net effect before the fix: collect_payloads returned zero payloads
after clearing, the worker was never called, and %mor / %gra were
silently stripped from the entire file.
has_mor now requires the Mor tier to be non-empty: it returns
false for an empty placeholder. Regression test:
collect_payloads_treats_empty_mor_placeholder_as_unprocessed.
This is a single-call, four-regression-test cluster (three for tier-order preservation plus this one) that together pin the round-trip contract.
Pre-validation gate
morphotag requires CHAT Level 2 (parseable + headers + valid main tiers) before
batching. Invalid files are rejected immediately. A file with malformed headers
or invalid main tiers would produce mis-keyed cache entries and corrupt downstream
morphosyntax assignments.
Language-group concurrency control
Multilingual CHAT files produce batch items with different per-item languages. Each language group must be dispatched to a worker loaded with the correct Stanza model, sending French text to an English MWT pipeline produces corrupt Range tokens.
Language groups are dispatched concurrently using a semaphore to prevent deadlock:
each language group acquires a semaphore permit before accessing the worker pool.
This ensures that we never try to start more language groups simultaneously than
the worker pool can support (max_total_workers / max_workers_per_key).
When a language group finishes and releases its permit, the next waiting group acquires it and starts, no deadlock, full utilization, all groups eventually complete. This is the same concurrency pattern the FA pipeline uses for per-file parallelism.
Implementation: crates/batchalign/src/morphosyntax/batch.rs:288-312.
Retokenization (--retokenize)
When --retokenize is set, TokenizationMode::StanzaRetokenize is passed to
the worker. Stanza may split or merge words on the main tier to match UD
tokenization. The retokenization is implemented in Rust
(crates/batchalign/src/retokenize/) using a character-level
Hirschberg DP to map Stanza tokens back to original CHAT positions. Existing
%wor timing bullets become stale after retokenization.
Per-utterance language routing
Individual utterances with [- lang] precodes are routed to the Stanza
pipeline for that language, regardless of the file-level @Languages header.
The routing table is built by collect_payloads() in the morphosyntax
orchestration layer.
See Language Routing.
L2 morphotag dispatch (default; opt out via --no-l2-morphotag)
By default the morphotag pipeline defers the legacy L2|xxx
blanking for @s words and instead routes them to a
secondary-language Stanza model, merges the response with the
primary model’s structural analysis, and splices the merged result
back into the CHAT AST. --no-l2-morphotag restores the legacy
blanking behavior.
sequenceDiagram
participant Orch as Orchestrator<br/>(morphosyntax/batch.rs)
participant Primary as Primary Stanza<br/>(e.g. deu)
participant L2 as L2 extractor<br/>(l2/extract.rs)
participant Spans as Span grouper<br/>(l2/spans.rs)
participant Secondary as Secondary Stanza<br/>(e.g. eng)
participant Merge as Merge algorithm<br/>(l2/merge.rs)
participant Splice as Splice<br/>(l2/splice.rs)
Orch->>Primary: Full utterance,<br/>L2 blanking deferred
Primary-->>Orch: UD annotations for all words<br/>including @s words
Orch->>L2: extract_l2_deferred_positions()
L2-->>Orch: Vec<L2DeferredPosition><br/>(@s words + primary UD)
Orch->>Spans: group_deferred_into_dispatch_spans()
Spans-->>Orch: Vec<DispatchSpan><br/>(contiguous same-lang)
loop For each target language
Orch->>Secondary: infer_batch(retokenize=true)<br/>per-span batch items
Secondary-->>Orch: Vec<UdResponse>
loop For each @s word in span
Orch->>Merge: merge_primary_secondary_with_context(<br/>primary, secondary_mor,<br/>secondary_ud_sentence)
Note over Merge: Priority 0: compound:prt?<br/>Priority 1-6: constraint chain
Merge-->>Orch: MergedL2Morphology
end
end
Orch->>Splice: splice_l2_into_chat()
Splice-->>Orch: SpliceOutcome<br/>(spliced / fallback / gra_upgraded)
Module layout (crates/batchalign-transform/src/morphosyntax/l2/):
| Module | Responsibility |
|---|---|
extract.rs | Walks primary UD response, picks out @s-word positions and their primary structural info (deprel, head, UPOS, dependents). |
spans.rs | Groups deferred positions into contiguous same-language spans for per-span Stanza dispatch. |
merge.rs | POS resolution priority chain including Priority 0 (compound:prt phrasal-verb recognition) and Priority 1-6 (constraint-based). |
deprel.rs | UdDeprel newtype, deprel→POS constraint mapping, deprel inference from resolved POS. |
splice.rs | Replaces L2|xxx with the merged MOR + corrected GRA in the CHAT AST. |
crates/batchalign/src/morphosyntax/batch.rs | Thin adapter that submits the planned secondary spans to workers and hands the results back to the transform-layer seam. |
Dispatch wiring: crates/batchalign/src/morphosyntax/batch.rs::dispatch_secondary_l2.
The caller invariant is that map_ud_sentence produces one Mor
per CHAT @s word (MWT Range tokens collapsed into clitics). When
sentence.words.len() == mors.len() the caller threads a
SecondaryUdContext { sentence, word_position } into the merge so
Priority 0 can check compound:prt relations; otherwise it passes
None and the merge falls back to the constraint chain alone.
See L2 Morphotag: Per-Word Code-Switching Analysis for the design rationale and merge algorithm details.
Validation and normalization policy for @s
- E255 is now the hard-stop policy for whole-utterance same-language all-
@spatterns. Morphotag does not auto-normalize those utterances; the transcript must use[- lang]. - E254 is warn-only for explicit
@s:LANGmarkers whoseLANGis absent from@Languages. Dispatch still uses the explicit target language; the warning is about header drift, not routing failure. chatter debug fix-sis the companion repair tool. It rewrites qualifying whole-utterance@sruns to[- lang], appends missing explicit languages to@Languages, and skips files that are already correct.
Transcriber $POS hint post-pass (enabled by default)
After injection completes, if --respect-pos-hints is enabled (default; opt-out
via --no-pos-hints), the morphotag pipeline walks the ChatFile and overrides
%mor POS categories that disagree with transcriber $POS annotations.
The post-pass preserves:
- Lemma (from Stanza)
- Morphological features (from Stanza)
Only the POS category (UPOS → CLAN notation) is checked and potentially overridden. Lemma and features from Stanza remain intact.
The outcome tracks 5 categories:
hints_considered: total$POSannotations foundhints_agreed: transcriber POS matched Stanza output (no change needed)hints_overridden: Stanza POS overridden by transcriber annotationhints_unmapped: transcriber POS code not in the CLAN↔UPOS mapping tablehints_skipped_no_mor: utterance had no%mortier to override
Implementation: crates/batchalign/src/chat_ops/morphosyntax_ops/pos_hints.rs,
batchalign/src/morphosyntax/batch.rs:509-521.
Morphosyntax alignment validation
After injection, the pipeline runs alignment validation to detect %mor/%gra
sync issues. The validator checks:
- Each
%mortier has a corresponding%gratier - Word counts match
- Dependency head indices are in bounds
Validation errors are warnings only (logged but non-fatal). Files are still serialized so invalid files can be inspected for debugging. The validation gate exists to catch corruption from upstream defects (e.g., Stanza control-token leaks) that escape the ingress filter.
Implementation: post-injection alignment validation lives in
crates/batchalign-transform/src/morphosyntax/injection.rs (the module
top comment names the responsibility, and the typed
MisalignmentBug / MisalignmentDiagnostic paths fire at the result
sites in that file). The batchalign-side morphosyntax/worker.rs
calls the injection path and propagates the validation outcomes.
# Unit tests (no ML models)
make test
# Morphotag golden tests (real Stanza models, only on Fleet/Large-tier hosts with the models present)
cargo test -p batchalign --features ml-golden --test ml_golden morphosyntax::
# Retokenization unit tests
cargo test -p batchalign retokenize::
Related developer documentation
- Command Flowcharts: morphotag, detailed runtime flowchart
- Morphosyntax Pipeline, %mor/%gra format
- Stanza Capability Registry
- Incremental Processing,
--beforeflag - Adding Commands, use
morphotagas the reference forCrossFileBatchTransform
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
utseg: Developer Reference
Status: Current Last updated: 2026-09-16 08:18 EDT
Implementation guide for the utseg command. For user-facing documentation,
see User Guide: utseg.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: UtsegArgs | lang, num-speakers |
| Catalog entry | crates/batchalign/src/recipe_runner/catalog.rs | the CatalogEntry for utseg |
| Stage recipe | crates/batchalign/src/recipe_runner/recipes.rs | UTSEG_RECIPE |
| Utseg orchestration | crates/batchalign/src/utseg.rs | The cross-file batch pipeline (standalone jobs) and the per-file pipeline transcribe reaches through process_utseg_with_evidence; typed worker-result admission and provenance |
| Worker IPC | batchalign/inference/utseg.py | Returns direct model assignments with evidence, or Stanza trees |
| Python model evidence | batchalign/models/utterance/evidence.py | Closed actions, fixed-point probability, omission/bypass states |
| Canonical IPC evidence | crates/batchalign-types/src/worker_v2/utseg_evidence.rs | Rust wire enums and validated probability newtype |
| Evidence artifacts | crates/batchalign/src/utseg_evidence.rs | Versioned pre/post-CHAT transcribe traces, atomic sink, and the admission that reads one back |
| Evidence replay | crates/batchalign/src/cli/eval_cmd/utseg_replay.rs | eval utseg-replay: reapplies a retained sidecar and compares the result with the retained output |
| Boundary application | crates/batchalign-transform/src/utseg.rs | Maps admitted assignments back to typed CHAT structure |
Local submissions (auto-daemon or loopback --server) use paths_mode=true
as of 2026-04-14: the CLI posts source/output path lists instead of CHAT
bytes. See Submission Modes.
Caching behavior
Text NLP tasks (utseg, translate, morphotag) do not use the utterance cache.
Boundaries are computed from word sequence, language, exact model revision, and
current postprocessing during each inference run; no per-utterance result cache
exists. Server mode still avoids repeated model-loading startup cost by keeping
the worker warm. Transcribe debug evidence can be replayed for policy research
without rerunning the boundary model, but it is not a production result cache.
Worker IPC: utseg task
Rust freezes the text batch in a prepared artifact, then sends
execute_v2(task="utseg") with the language and explicit Stanza-fallback
authorization. Each result item has exactly one success representation:
assignments: one group ID per request word. A boundary-model result also carriesboundary_model_evidence, including model identity and one typed evidence state per word.trees: raw constituency trees from the explicitly authorized Stanza fallback. Rust computes assignments from the trees.error: a per-item failure with no success payload.
AdmittedUtsegPrediction rejects error/success mixtures, assignments plus
trees, evidence without assignments, empty model identity, and any assignment
or evidence length that differs from the request words. Its variants preserve
boundary-model, unobserved direct-assignment, and constituency sources, and the
batch path keeps them all the way to the stamp, which is how a file’s engine=
names the boundary model that segmented it.
admit_prediction is the one constructor of that type. admit_worker_item
classifies a live worker result’s mutually exclusive payloads into a
UtsegPredictionOrigin and hands it over; evidence read back from disk takes
the same route. One constructor means one copy of the parallel-vector and
consistency checks, so a retained sidecar is admitted because it was checked,
not because it is on disk.
A locally rederived decision carries a LocalUtsegDecisionReceipt, and because
a receipt can be deserialized from an artifact, with_local_decision treats it
as a claim rather than a label: check_explains recomputes through the same
owner that performed the reapplication, requiring that the evidence declare the
policy the receipt names (reapplication stamps it there), that the worker’s own
declared policy reproduce the worker assignments recorded, and that replaying
the local policy reproduce both the applicable assignments and the exact
suppressions claimed.
Because admission already refuses a non-parallel prediction, the batch application has no length check and no “keeping original” branch. That branch was unreachable, and had it ever run it would have produced silently unsegmented output where admission produces a failure.
engine= carries no placeholder. A boundary model is written
<model id>@<revision> and never by its id alone; the Stanza fallback is
stanza-constituency; and a worker that returns assignments without naming
their source contributes no name at all, so a file with no named source gets no
stamp and the run records NoStampReason::SourceNotNamed on that file. The
invented names this replaced (unobserved-worker, <id>@unrecorded-revision)
were exactly what W0 and W1 removed elsewhere.
The revision is a required part of a model’s identity, so the id-only form has
no representation rather than merely no callers: UtsegBoundaryModelEvidenceV2
carries a HubCommitV2, not an Option<String>, and the renderer has no branch
that could emit an unqualified id. That is a consequence of the load, not a rule
imposed on it. The boundary model is resolved from a pinned snapshot
(model_manifest::UTSEG_BOUNDARY_MODELS, sent to the worker under
PINNED_UTSEG_MODEL_KEY), and the commit is read off the directory that
actually exists on disk, so a worker that cannot say which revision it loaded
refuses instead of reporting none. Requiring the revision BEFORE pinning the
load would have refused every run rather than fixed anything.
The segmenter route, decided at planning time
crates/batchalign/src/utseg_route.rs owns one question: which segmenter a
language gets. UtsegRoute::resolve(lang, fallback_policy) answers
BoundaryModel, StanzaFallback, or the typed refusal UtsegUnavailable.
Three things about its shape are deliberate:
- One table.
model_manifest::UTSEG_BOUNDARY_MODELSis the only statement of which languages have a TalkBank boundary model, and it is the same table that names the model and pins its commit. There were three: amatches!inpipeline/transcribe.rs, aBOUNDARY_MODEL_LANGUAGESlist inutseg_route.rs, and the key set of_RESOLVER["utterance"]inbatchalign/models/resolve.py, which the worker consulted to decide whether to refuse. Availability is now a CONSEQUENCE of the pin (has_boundary_modelasks the manifest), so a language this build claims to segment and a language it can name a model for are the same set by construction. The Python resolver no longer carries anutterancefamily at all: an id must be known before a load in order to pin its revision, so Rust owns it and sends it with the spawn. - “Refused” is the error arm, not a variant. Both variants of
UtsegRouteare runnable. ARefusedvariant would have been a value every consumer had to remember to reject, which is the shape that let the old refusal be skipped; as anErrit cannot be stored in a plan or handed to a worker. - It reads the language and the policy only. The refusal it replaces was computed inside the worker from the payload, below a short-circuit that answered single-word items trivially, so a batch whose items were all one word long bypassed it and reported success for a language with no segmenter.
Resolution happens where the answer is first knowable:
TranscribeDispatchPlan::from_job refuses before any ASR is dispatched, and
runner::routing refuses a standalone utseg job before dispatch. Under
--lang auto the language is not known until ASR returns, so that case alone
resolves the same route inside the pipeline instead.
The worker keeps its own UtsegModelNotFoundError. It is NOT the same
predicate and was deliberately left in place: it fires when this worker
process has no boundary model LOADED, which can happen for a language that
has one, so it stays as a worker-local backstop rather than the decision that
fails a job after ASR has run.
Stanza constituency availability
About 11 languages have Stanza constituency models. A language without a
configured TalkBank boundary model is refused by default; the operator must
pass --utseg-fallback-stanza. The available processors are queried at worker
startup via batchalign/worker/_stanza_capabilities.py, never hardcoded.
Evidence retention scope
The transcribe pipeline can retain exact boundary evidence with
--debug-dir: pre-CHAT and post-CHAT phases get separate files. The standalone
utseg command currently admits the same typed worker result but deliberately
projects it to assignments without writing those transcribe sidecars. Do not
claim that a standalone run retained evidence unless a future command-specific
artifact surface explicitly does so.
Replaying retained evidence
batchalign3 eval utseg-replay reapplies a retained sidecar and checks that it
still produces the document the run wrote. post-chat consumes the
_pre_utseg.cha dump, the post-CHAT sidecar and the _post_utseg.cha dump;
pre-asr consumes the retained *_asr_response.json, the pre-CHAT sidecar and
the _post_asr.cha dump, and rebuilds CHAT through transcribe’s own functions
(prepare_asr_chunks, build_prechat_utseg_items, apply_prechat_assignments)
so the replay cannot drift from the code it checks. Each mode names the phase it
reproduces when it admits the artifact, so a pass cannot be replayed against the
other’s evidence.
Chunk preparation is one implementation, not two: prepare_asr_chunks_with_snapshot
delegates to the transform’s prepare_asr_chunks when there is no trace to
fill, and the traced path is held to the same answer by
snapshot_and_plain_preparation_agree, which is the test that would catch its
digit fast path diverging on number expansion.
The input gate is production’s: the document is parsed leniently and then judged
by validate_to_level with its parse errors in hand, exactly as the utseg
pipeline does, so the replay refuses what the run would have refused. The
pre-ASR pass additionally refuses a retained output whose @Languages names
anything but the single language the evidence records, or that carries a
[- code] precode, because those are the marks of a --lang auto run’s
per-file and per-utterance detection, which this pass does not perform.
Binding is what makes the comparison meaningful: the requests the current build
collects must match the retained items one for one, in count, transcript
position, words and text. Both passes then compare on one basis, the AST of the
serialized CHAT text (comparison_basis), because text is what a run writes and
what every later stage sees; doing it in both keeps a serialization-only defect
visible in both. The comparison sets aside the comments a run generates (the
[fc-ba3 ...] stamp and the unchecked-ASR warning), recognized through
provenance::recognize_generated_comment, the same codec extract_provenance
reads with. A stamp’s timestamp can never match by equality, so comparing it
would report every replay as a difference.
The verdict is a typed outcome: reproduced, or a difference naming the comparable line counts and where they first differ. A difference exits 1, a refused input exits 2. See the user guide for the flags and the admission rules.
Replaying adjacency policies
scripts/probe_utterance_boundary_policy.py compares the current decoder
policy with a boundary-only alternative over retained
*_asr_response.json artifacts. It makes no ASR request. The local model is
loaded once, raw evidence is captured once per source monologue, and both
policies are applied to that identical evidence.
uv run python scripts/probe_utterance_boundary_policy.py \
<retained-asr-directory> \
<output-report.json>
The probe validates the complete retained input schema, records SHA-256 for every input, refuses model-identity or evidence-length drift, and atomically publishes a versioned report. Each assignment-changing case includes lexical context, fixed-point boundary probability, and a typed known-or-missing interword timing. The report is experimental evidence, not a production-policy switch or an accuracy verdict. Candidate promotion requires a human-linked, controlled comparison.
Pre-validation gate
utseg requires CHAT Level 1 (parseable + valid headers). Gate in
crates/batchalign/src/utseg.rs. Implemented via
validate_to_level(chat, ValidationLevel::StructurallyComplete).
Testing
make test
cargo test -p batchalign utseg::
cargo test -p batchalign utseg_evidence::
uv run pytest -q batchalign/tests/models/test_bert_utterance_sliding_window.py
uv run pytest -q batchalign/tests/models/test_utterance_boundary_policy.py
uv run pytest -q batchalign/tests/models/test_utterance_policy_probe.py
uv run pytest -q batchalign/tests/pipelines/utterance/test_utseg_inference.py
# ML golden tests, only on Fleet/Large-tier hosts
cargo test -p batchalign --features ml-golden --test ml_golden utseg::golden
Related developer documentation
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
translate: Developer Reference
Status: Current Last updated: 2026-09-15 20:20 EDT
Implementation guide for the translate command. For user-facing
documentation, see User Guide: translate.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: TranslateArgs | --translate-engine flag, parsed straight into TranslateEngineName by engine_selection_parser::<TranslateEngineName>() |
| CLI → wire | crates/batchalign/src/cli/args/options.rs: Commands::Translate arm | No mapping: the flag already holds a TranslateEngineName. The CLI-private mirror enum and its hand-written match were removed 2026-08-06; see SelectableEngine in types/engines.rs |
| Catalog entry | crates/batchalign/src/recipe_runner/catalog.rs | the CatalogEntry for translate |
| Stage recipe | crates/batchalign/src/recipe_runner/recipes.rs | TRANSLATE_RECIPE |
| Translate orchestration | crates/batchalign/src/translate.rs | The cross-file text pipeline (the only one; the per-file entry point was deleted with the workflow trait), per-item result admission including the empty-translation refusal, provenance. No result cache |
| Source model and injection | crates/batchalign-transform/src/translate.rs | TranslationSource (what the speaker produced) and render(), its one renderer; TranslationText (a translation with something to apply) and %xtra injection |
| Job dispatch | crates/batchalign/src/execution/translate.rs: dispatch_translate_job | Reached from runner::routing::dispatch_batched_text_command; one gateway call per file, each with the source language read from that file’s @Languages: header |
| Engine type | crates/batchalign/src/types/engines.rs: TranslateEngineName | Wire-format enum (google / seamless / nllb / tencent / aliyun), EngineBackend impl, EngineOverrides.translate field |
| Engine resolution (server) | crates/batchalign/src/types/options.rs: TranslateOptions::effective_translate_engine | Precedence: shared --engine-overrides {"translate":"..."} > --translate-engine flag > Google default |
| Engine bootstrap | batchalign/worker/_model_loading/translation.py::load_translation_engine(bootstrap) | Reads bootstrap.engine_overrides["translate"], dispatches via exhaustive match to _load_google_translate, _load_seamless_translate, _load_nllb_translate, _load_tencent_translate, or _load_aliyun_translate. Unknown engine names raise ValueError |
| Engine resolution (worker) | batchalign/worker/_model_loading/translation.py::resolve_translate_engine | Pure function from engine_overrides dict → TranslationBackend; default Google |
| Worker IPC | batchalign/inference/translate.py: batch_infer_translate() | Iterates batch items through the loaded translation record and returns one tagged item per input: translated (with raw_translation and the record’s engine) or blank_input. Sleeps 1.5s per item when backend is GOOGLE (rate limit). The text arrives already rendered by Rust (TranslationSource::render, which owns the Chinese-script rule); post-processing happens in Rust after. No backend strips the terminator |
Local submissions (auto-daemon or loopback --server) use paths_mode=true
as of 2026-04-14: the CLI posts source/output path lists instead of CHAT
bytes. See Submission Modes.
No result cache
Translations are not cached. The utterance cache holds audio evidence only (forced alignment, UTR ASR, Rev.AI transcripts and speaker evidence); text NLP caching was removed because re-running warm inference cost less than the cache lookups. Every translate run calls the worker.
Worker IPC: translate task
request item (rendered from that utterance's TranslationSource; the source and
target languages travel on the request envelope, not on each item):
{ "text": "bonjour le monde." }
TranslationResultV2 items, one per request item:
{ "kind": "translated", "raw_translation": "Hello world.", "engine": "googletrans-v1" }
{ "kind": "blank_input" }
{ "kind": "failed", "error": "Translation failed: ..." }
The PyO3 bridge parses each host item through the Rust wire type; an item that
does not parse becomes that item’s failed outcome.
What reaches the engine
TranslationSource::render is the only place source text is built, and three
closed rules decide its content, each a match with no catch-all:
| Rule | Sent | Not sent |
|---|---|---|
Words (TranslatableWordText::of_produced_word) | Ordinary words and filled pauses (without the &- prefix); a replaced word contributes its replacement | 0-prefixed and CA omissions, &~ nonwords, &+ fragments, xxx / yyy / www |
Separators (TranslatableSeparator::of_separator) | The comma | The tag marker and vocative („, ‡), the CA prosodic marks |
Terminator (render_terminator) | . (the ideographic full stop in Han script), ? for every question-bearing variant including +/?, +!?, +//?, +..?, and ! | +..., +/., +//., +"/., +"., +. |
RENDERED_TERMINATORS lists every string the terminator rule can emit, and
TranslationText::admit refuses a translation equal to one of them, so an
engine echoing the punctuation batchalign3 sent cannot become a %xtra tier.
Engine identity and provenance
The engine comes from the results, not from the worker’s capability report.
translate.rs admits each item into AdmittedTranslation
(Translated { text, engine } or BlankInput), and the batch pipeline stamps
each file with [fc-ba3 translate | engine=... ; lang=... | ...], naming the
distinct engines on the translations that file applied, joined with + in text
order (result_named_provenance with ResultNamedCommand::Translate, the
builder coref shares; it cannot fail). A file where nothing was translated
carries no stamp and the run says why (TextStamp::NotStamped). Because
nothing is read from the report, a translate job is never refused for a worker
that has not named its translation engine; that pre-dispatch refusal was
removed.
Pre-validation gate
translate requires CHAT Level 1.
Idempotency
inject_translation (in talkbank-transform::translate) calls
replace_or_add_tier, which overwrites any existing %xtra tier on the
utterance. Re-running translate on a file that already has %xtra tiers
re-translates and replaces them. This diverges from BA2, which guarded
with if i.translation: continue and preserved the first translation.
Engine selection precedence
TranslateOptions::effective_translate_engine mirrors
AlignOptions::effective_fa_engine and
BenchmarkOptions::effective_asr_engine. From highest priority to
lowest:
common.engine_overrides.translate: set by--engine-overrides '{"translate":"<engine>"}'.TranslateOptions.translate_engine: TranslateEngineName: set by--translate-engine google|tencent|aliyun|nllb|seamless. Defaults to Google viadefault_translate_engine().
There is deliberately no server.yaml knob for engine selection.
Translation engine is a policy choice, not a host fact, and policy
belongs at the invocation site (CLI flag or shell alias), never in
a config file. See the no-config-junk principle in
book/src/batchalign/user-guide/commands/translate.md.
The worker pool key includes the resolved translate engine
(dispatch_engine_overrides_json always emits a translate entry).
Google, Tencent, Aliyun, Seamless, and NLLB workers are not
interchangeable, so they end up in separate pools.
Tencent backend specifics
The Tencent loader reuses the shared read_asr_config() helper at
batchalign/inference/languages/cantonese/_common.py:77, which
prefers BATCHALIGN_TENCENT_{ID,KEY,REGION} environment variables
(injected by the Rust control plane at worker spawn) and falls back
to ~/.batchalign.ini [asr] section:
engine.tencent.id→TencentSecretIdengine.tencent.key→TencentSecretKeyengine.tencent.region→TencentRegion
These are the same CAM credentials used by the Tencent ASR backend
(the [asr] section name is historical; the SecretId/SecretKey pair
authorizes any product the CAM user has permission for). The user
must have tmt:TextTranslate policy attached (e.g.,
QcloudTMTFullAccess), and the TMT product must be “opened” at the
Tencent Cloud account level, both are root-account / admin actions
on the Tencent side.
Rate-limit handling: the inference closure in
batchalign/inference/translate.py sleeps 0.2 s per item when the
backend is Tencent (5 QPS standard free-tier limit on
TextTranslate). This is the analogue of the existing 1.5 s
per-item sleep for Google.
Language-code handling: _ISO_639_3_TO_TENCENT_LANG (in
batchalign/worker/_model_loading/translation.py) maps the ISO-639-3
codes BA3 emits to Tencent’s ISO-639-1 codes (spa→es, cmn→zh,
etc.). Unmapped source languages raise a clear ValueError
recommending --translate-engine nllb. Tencent does NOT list
yue→en in its supported pairs, Cantonese requests are rejected at
the table lookup, not at the API call.
Empty SourceText would be rejected by the Tencent API with a typed
InvalidParameter error. The loader short-circuits empty input
(returns the empty string) so a stray empty utterance doesn’t surface
as a SDK exception that looks like a credentials problem.
Aliyun backend specifics
The Aliyun loader (_load_aliyun_translate) uses the same shared
read_asr_config() helper, with credentials drawn from the
BATCHALIGN_ALIYUN_AK_{ID,SECRET} environment variables (injected
by the Rust control plane at worker spawn) or the
~/.batchalign.ini [asr] section:
engine.aliyun.ak_id→ Aliyun Access Key IDengine.aliyun.ak_secret→ Aliyun Access Key Secret
These are the same access-key pair used by the Aliyun NLS ASR
backend. Aliyun MT does NOT need the ak_appkey field that NLS
ASR consumes, that key authorizes the WebSocket speech service,
not the REST translation service.
Region is pinned to cn-hangzhou (_ALIYUN_MT_REGION in
translation.py). Aliyun MT exposes a single global endpoint at
mt.aliyuncs.com across every supported region, so the AcsClient
region only affects request signing, there is no
cn-hangzhou vs us-west-1 quality / availability split. If
region-pinning becomes a deployment concern later, promote to a
config-driven override.
SDK package: aliyun-python-sdk-alimt (pinned at >=3.2.0 in
pyproject.toml). The loader uses the v20181012 General Translation
endpoint via TranslateGeneralRequest with FormatType="text" and
Scene="general" (both promoted to module-level constants
_ALIYUN_MT_FORMAT_TYPE / _ALIYUN_MT_SCENE so the wire shape is
visible without grepping for magic strings).
Language-code handling: _ISO_639_3_TO_ALIYUN_LANG (in
batchalign/worker/_model_loading/translation.py) maps the
ISO-639-3 codes BA3 emits to Aliyun’s ISO-639-1-ish codes
(spa→spa, cmn→zh, kor→ko, yue→yue, etc.). The presence
of yue is the load-bearing reason this backend exists alongside
Tencent, see User Guide: translate
for the operator-visible rationale.
Response envelope: Aliyun MT returns a JSON byte payload of the
shape {"Code": "200", "Data": {"Translated": "...", "DetectedLanguage": "...", "WordCount": "..."}, "RequestId": "..."}. Non-"200" codes
surface as ClientException/ServerException from
do_action_with_exception before the loader parses; by the time
json.loads runs, Code == "200" is expected.
Empty SourceText short-circuits the same way Tencent does (return
empty string before any SDK call) for the same reason, Aliyun
treats empty input as an invalid request and would surface a typed
SDK exception that looks like a credentials problem.
End-to-end verification: the loader’s SDK call shape is wired
against the aliyun-python-sdk-alimt==3.2.0 source. Real-API smoke
testing happens at the operator boundary per the user-guide; CI
covers the wire shape via the mocked-SDK test in
batchalign/tests/pipelines/translate/test_translation_model_loading.py::TestLoadAliyunTranslate.
BA2 → BA3 migration notes
| Concern | BA2-jan9 | BA3 |
|---|---|---|
| CLI shape | batchalign translate IN_DIR OUT_DIR (separate dirs) | batchalign3 translate <dir-or-file> (in-place by default) |
| Default engine | googletrans (dispatch.py: "translate": "gtrans") | googletrans, with explicit per-host opt-in to Seamless via server.yaml default_translate_engine or --translate-engine seamless |
| Concurrency | Sequential per utterance, with time.sleep(1.5) on Google | Batched cross-file dispatch, multiple worker groups per language, 1.5s sleep retained per-item on Google only |
| Re-run behavior | Skip already-translated utterances | Overwrite existing %xtra |
| What is sent | utterance.strip(join_with_spaces=False, include_retrace=True, include_fp=True) in gtrans.py and seamless.py: words, retraces, filled pauses and punctuation including the terminator, detokenized | The same words, as a typed TranslationSource rendered once at the wire boundary. Which words, which punctuation and how a terminator is written are BA3’s own closed rules (see below). Before 2026-09-15 BA3 sent only %mor-domain words, with no retraces, no filled pauses and no terminator |
| Chinese preprocessing | Inline in gtrans.py only (spaces removed, . to 。); seamless.py did NOT strip spaces (BA2 bug) | A property of the language: WritingSystem::of_language marks the Han-script varieties (zho, cmn, yue, wuu, nan, hak), and TranslationSource::render applies the rule for every backend |
| Empty translation | Dropped at injection: generator.py wrote %xtra only when the text was not "", ".", "!" or "?", leaving the utterance with no tier | Refused when the result is admitted, as a typed per-item failure naming the engine and the remedy. Terminal, not retryable: an identical request gets an identical answer |
| Per-item failure | Aborts the file (single-file CLI invocation) | Marks the affected file as failed with a typed TextWorkflowFileError::ItemErrors carrying the engine error(s); other files in the same cross-file batch continue normally. Transient errors at the batch dispatch layer retry; per-item engine failures propagate to file-level failure without retry. |
| Output tier | %xtra | %xtra (identical) |
Tier-name clarification. Neither BA2 nor BA3 produces a %tra tier.
Both versions emit %xtra. Any other translation-tier name observed in
the wild was not written by Batchalign.
Testing
make test
cargo test -p batchalign translate::
Related developer documentation
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
coref: Developer Reference
Status: Current Last updated: 2026-09-15 20:20 EDT
Implementation guide for the coref command. For user-facing documentation,
see User Guide: coref.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: CorefArgs | merge-abbrev only; coref has no --lang |
| Language shape | crates/batchalign/src/dispatch_language.rs | Declares coref per-file, and mints the proof a job-level dispatcher needs |
| Coref dispatch | crates/batchalign/src/execution/coref.rs: dispatch_coref_job | Plans, tracks, batches across files, writes; takes no language |
| Catalog entry | crates/batchalign/src/recipe_runner/catalog.rs | the CatalogEntry for coref |
| Stage recipe | crates/batchalign/src/recipe_runner/recipes.rs | COREF_RECIPE |
| Coref orchestration | crates/batchalign/src/coref.rs | Full-document context assembly, worker dispatch, sparse injection |
| Injection | crates/batchalign/src/coref.rs | Writes sparse %xcoref: tiers |
| Worker IPC | batchalign/inference/coref.py: batch_infer_coref() | Loads Stanza coref model, returns chain structures |
Local submissions (auto-daemon or loopback --server) use paths_mode=true
as of 2026-04-14: the CLI posts source/output path lists instead of CHAT
bytes. See Submission Modes.
No caching
coref intentionally bypasses the utterance cache. Coreference chains span
the entire document, the same utterance has different coreference in different
document contexts, making per-utterance BLAKE3 keys meaningless. Every coref
invocation always calls the worker.
This is a deliberate architectural decision, not an oversight. Annotated in
the coref.rs orchestration module.
Sparse output
%xcoref: tiers are only written on utterances that contain at least one
mention participating in a coreference chain. Most utterances in a file are
untouched. This makes coref output stable under incremental edits, adding
or removing utterances that don’t participate in chains doesn’t disturb the
existing annotations.
Worker IPC: coref task
request item (one per document):
{ "sentences": [["hello", "world"], ["she", "said"]] }
CorefResultV2 items, one per document:
{ "kind": "resolved",
"annotations": [ { "sentence_idx": 0, "words": [[{"chain_id": 0, "is_start": true, "is_end": true}], []] } ],
"engine": "stanza-<version>/<coref package>" }
{ "kind": "no_sentences" }
{ "kind": "failed", "error": "Coref failed: ..." }
Each annotation gives, per word of one sentence, the chain references that
start or end at it. A worker exception is a failed item, never an empty
resolved one. The PyO3 bridge parses each host item through the Rust wire
type; an item that does not parse becomes that item’s failed outcome.
Engine identity and provenance
The engine names the model that resolved the chains, not only the library:
stanza-<version>/<coref package>, for example
stanza-1.10.1/ontonotes-singletons_roberta-large-lora, built by
batchalign/inference/coref.py::coref_engine from the installed Stanza release
and the same package constant the pipeline is constructed with.
The engine comes from the results, not from the worker’s capability report.
coref.rs admits each document into ResolvedCoref
(Resolved { response, engine } or NoSentences), and the batch path stamps
each eligible file with [fc-ba3 coref | engine=... ; lang=eng | ...], naming
the engine that file’s own resolved result named, through the builder
translate shares (result_named_provenance with ResultNamedCommand::Coref),
which joins distinct names with + in text order and cannot fail. The stamp’s
language is the constant eng, never a job-level value (see the 2026-05-03
incident). A file the worker resolved nothing for gets no stamp and the run
says why; a file that was never eligible (dummy, or not English) had no coref
run, so no stamp question arises for it. Because nothing is read from the
report, a coref job is never refused for a worker that has not named its
coreference engine; that pre-dispatch refusal was removed.
There is one implementation of the coref lifecycle. The per-file entry point
(process_coref / run_coref_impl) was deleted with the workflow trait that
reached it: it duplicated parse, gate, English check, dispatch, injection and
stamping, and the two copies had already drifted apart on whether a batch file
records its provenance.
Per-file language dispatch, and why coref takes no language at all
Coref is a per-file command in the sense dispatch_language.rs defines: it
has no --lang on the CLI, so submission validation requires every coref job
to arrive as LanguageSpec::PerFile, and the command owns its own inference
language, the constant eng.
dispatch_coref_job therefore takes no language parameter, and neither does
WorkerGateway::coref_batch. That is not a simplification, it is the fix for a
defect that made the command unrunnable. Coref used to dispatch through a
shared “simple batched text” path that began by demanding
job.dispatch.lang.as_resolved(). On a per-file job that is always None, so
every coref job ever submitted was refused before any work was dispatched,
with a message telling the operator to pass a --lang flag coref does not
have. The language it demanded was then discarded unread by the batch, which
hardcodes eng.
Two spellings of one rule are what allowed this: submission validation listed
the per-file commands in a matches!, while each dispatcher re-asked the same
question of the job’s LanguageSpec, and the two answers disagreed.
dispatch_language::language_source is now the single owner, read by both.
A per-file command’s dispatcher takes no language; a job-level command’s
(utseg, compare) takes a JobLanguage, whose inner code is private, so the
only way to obtain one is a resolution that refuses to mint it for a per-file
command. The absent case those dispatchers used to check for at runtime no
longer exists for the compiler to be reminded of.
The shared path had exactly one caller, so it was deleted with the defect rather than made conditional.
English-only restriction
Stanza’s coreference model is English-only. The per-file English gate lives
inside the batch (coref.rs::file_has_english), which reads each file’s own
@Languages: header; files with no header fall back to eng. Non-English
files pass through with no %xcoref tiers written and no error reported. There
is no job-level language to check, and no stage that checks one.
Testing
make test
cargo test -p batchalign coref::
# Requires Stanza coref model
cargo test -p batchalign --features ml-golden --test ml_golden coref::golden
Related developer documentation
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
compare: Developer Reference
Status: Current Last updated: 2026-05-19 22:58 EDT
Implementation guide for the compare command. For user-facing
documentation, see User Guide: compare.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: CompareArgs | lang, num-speakers |
| Command definition | crates/batchalign/src/commands/compare.rs | CommandDefinition impl, gold-file discovery |
| Compare engine | crates/batchalign-transform/src/compare/engine.rs | compare(): produces ComparisonBundle |
| Compare orchestration | crates/batchalign/src/compare.rs | Pairs each main transcript with its gold companion, runs the comparison, drives the materializers |
| Part-of-speech evidence | crates/batchalign-transform/src/compare/pos.rs | GoldPos: whether a document carries %mor tags at all, decided ONCE per file from the gold side. GoldTag and MainTag are separate types over the same primitive so the two sides cannot be passed the wrong way round |
| Comparison states | crates/batchalign/src/compare.rs: private artifacts module | MorphotaggedMain and ComparisonArtifacts, with private fields and one constructor each |
| Released materializer | crates/batchalign/src/compare.rs: materialize_released() | Projects %mor/%gra/%wor from main to gold, injects %xsrep/%xsmor |
| Benchmark materializer | crates/batchalign/src/compare.rs: materialize_main_annotated() | Annotates main transcript with %xsrep/%xsmor (internal to benchmark) |
| CSV writer | crates/batchalign-transform/src/compare/metrics.rs: format_metrics_csv() | Typed metrics model → CSV output |
Local submissions (auto-daemon or loopback --server) use paths_mode=true
as of 2026-04-14: the CLI posts source/output path lists instead of CHAT
bytes. Compare derives FILE.gold.cha first and falls back to
template.gold.cha at execution time inside the same directory.
ComparisonBundle
The central typed model. Produced by compare() in talkbank_transform::compare:
pub struct ComparisonBundle {
pub main_utterances: Vec<UtteranceComparison>, // per-utterance main-side comparisons
pub gold_utterances: Vec<UtteranceComparison>, // per-utterance gold-side comparisons
pub gold_word_matches: Vec<GoldWordMatch>, // structural word matches (gold → main)
pub metrics: CompareMetrics, // aggregate WER + per-POS breakdown
}
Each UtteranceComparison contains:
utterance_index: position in filespeaker: speaker codetokens: comparison tokens (status: Match/ExtraMain/ExtraGold, with optional POS)
Each GoldWordMatch maps one word position in a gold utterance to one position in a main
utterance, establishing the structural alignment used by projection.
Two materializer functions consume this bundle:
materialize_released()→ gold-projected output (for released compare command)materialize_main_annotated()→ main-annotated output (internal path, used by benchmark)
The main side arrives as a proof, not as text
crates/batchalign/src/compare.rs owns a private artifacts module holding two
states and the only transitions between them:
MorphotaggedMain, whose single constructor isfrom_proof(PostValidated). It consumes morphotag’s own post-validation proof and continues in the document that proof carries.ComparisonArtifacts, whose single constructor isbuild(MorphotaggedMain, ChatFile). It RUNS the comparison rather than accepting a bundle, so the bundle a materializer reads is always the comparison of the two documents beside it.
Both types keep their fields private to that module, so there is no route into a
comparison that begins with a String, in production or in this crate’s own
tests. That is the point of the shape rather than a side effect of it. Compare
used to serialize the morphotagged main transcript and parse it back with
parse_lenient, so the document it compared was the parser’s recovery of our
own bytes rather than the document the gate had judged, and a parse failure
there was a warn! and a continue rather than an answer. Deleting that parse
without closing the route would leave nothing to stop it growing back.
WorkerGateway::morphotag_for_compare therefore returns PostValidated, and
process_compare_morphotagged_main takes one. The execution kernel carries the
proof between its Morphosyntax and CompareAlign stages and has no text to
offer in its place.
PostValidated::into_judged_document() is the transition that hands back the
model. It succeeds for a gated proof and for a declined one, because a CA main
transcript that morphotag declines to analyze still has to be compared, and it
refuses a pass-through, which carries the input’s own bytes and no output model
at all.
The gold companion is still parsed leniently, and its parse errors are still
reported rather than refused, because nothing admits a gold companion at any
validity level: gate_comparison_output judges compare’s output against what
that companion HAD, so refusing it for its own faults would refuse a document
compare never damaged.
Which side’s tags a comparison reports
%mor is a per-utterance tier, so “this word has no tag” and “this document
tags nothing at all” reach a consumer as the same None. compare/pos.rs
separates them once per file: GoldPos::of returns Tagged only when some
%mor item exists somewhere in the document and Untagged otherwise, and the
field behind Tagged is private, so the variant cannot be spelled for a
document that tags nothing.
compare morphotags the main transcript itself and reads the gold companion off
disk as it is, so the ordinary case is a tagged main beside an untagged gold.
A tagged gold reports its own tag for a matched pair, which is the point of
running compare at all. An untagged gold reports the MAIN tag, which is the
only tag that word has, and a deletion still reports nothing because no side
tagged it. Until 2026-09-16 every matched word took its part of speech from the
untagged gold form and reported the literal ?, so %xsmor came out as a row
of question marks and the whole per-POS breakdown landed in one ? bucket.
The decision reaches past the reported column. Punctuation is recognized by
surface OR by tag, and the tag half can only fire on a document that has tags,
so GoldPos::excludes_from_comparison falls back to the surface rule alone on
an untagged side, where a token that is punctuation only by its tag enters the
alignment as an ordinary word. Nothing can recover a tag a document does not
have; what the file-level decision buys is that the weaker rule is a stated
property of an untagged document rather than an accident of a per-form None.
GoldTag and MainTag wrap the same Option<&str> deliberately: passing them
the wrong way round is the one mistake pos_for_match’s call site could make,
and the compiler now refuses it instead of a reviewer catching it.
Known defect: the projection census and the alignment filter disagree
Recorded 2026-09-16, not fixed. It belongs to neither change made that day: it sits in a third seam, the compare engine’s own word universe.
materialize.rs::compared_word_counts filters words by SURFACE only, through
is_punct_or_filler. The alignment’s engine.rs::flatten_side filters through
GoldPos::excludes_from_comparison, which on a tagged document is the
disjunction “the surface is punctuation OR the word’s %mor tag is PUNCT”.
The two therefore disagree about any word that is punctuation by TAG but not by
surface, and compared_word_counts is exactly what exact_projection_source
compares its match count against.
Measured with a differential probe rather than argued: two documents identical
except for the tag on one word, *CHI: hello comma . carrying
%mor: intj|hello PUNCT|comma . on both sides. The comparison itself is
perfect, 1 match with 0 insertions and 0 deletions, both sides having excluded
the tagged word. project_gold_structurally nevertheless copied no %mor,
%gra or %wor at all. With that word tagged noun instead, the exact
projection fired and %wor was copied. The tag is the only difference between
the two runs.
The failure is a silent DEGRADATION rather than wrong output. The exact
projection path is refused, and the partial %mor reconstruction beneath it is
refused too, because both measure gold words with the census while the matches
they count came from the filter. An exactly matching utterance can therefore
project nothing.
The fix is one owner for “does this word take part in the comparison”. The
alignment already computes that set in FlattenedSide, so the repair is for the
projection to read that set rather than recompute a different one, which also
removes the parallel-count shape that let the two drift apart.
Gold projection semantics
The gold projection process (project_gold_structurally()) iterates each gold utterance
and determines whether to copy or reconstruct comparison tiers.
For each gold utterance, a six-condition check (exact_projection_source()) determines if
all gold words have perfect structural alignment to a single main utterance:
- Match completeness: Every gold word position must have exactly one match
- Uniqueness: Matches must map to distinct gold positions
- Mono-utterance: All matches must originate from a single main utterance
- Word parity: Compared word counts must match between gold and the source main utterance
- Alignable parity: Alignable word counts (same universe) must match
- No errors: All tokens must have
Matchstatus (no insertions/deletions)
If exact match found (all 6 conditions pass):
Copy %mor, %gra, %wor tiers directly from the source main utterance to the gold
utterance. This is the safest projection path.
Otherwise (any condition fails):
Reconstruct a projected %mor tier from the partial matches in gold_word_matches.
This handles cases where words are reordered, inserted, or deleted. The projected
%mor is built directly from the bundle’s typed data, never from serialized text.
CSV output model
pub struct CompareMetricsRow {
pub label: MetricLabel, // "aggregate" or POS string
pub wer: f64,
pub accuracy: f64,
pub matches: u32,
pub insertions: u32,
pub deletions: u32,
pub total_words: u32,
}
Written once at the serialization boundary via csv::Writer. No ad-hoc string
assembly.
Testing
# Unit tests (no ML models)
make test
cargo test -p batchalign compare::
# Golden tests (real Stanza for morphotag step, only on Fleet/Large-tier hosts)
cargo test -p batchalign --features ml-golden --test ml_golden compare::golden
Related developer documentation
- Command Flowcharts: compare
- BA2 Compare Migration, how compare was re-architected from BA2
- Adding Commands, use
compareas the reference forReferenceProjection - benchmark developer reference, composite command that calls compare internally
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
benchmark: Developer Reference
Status: Current Last updated: 2026-07-29 18:27 EDT
Implementation guide for the benchmark command. For user-facing
documentation, see User Guide: benchmark.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: BenchmarkArgs | asr-engine, lang, num-speakers, wor/nowor |
| Catalog entry | crates/batchalign/src/recipe_runner/catalog.rs | the CatalogEntry for benchmark |
| Stage recipe | crates/batchalign/src/recipe_runner/recipes.rs | BENCHMARK_RECIPE |
| Benchmark pipeline | crates/batchalign/src/runner/dispatch/benchmark_pipeline.rs | Orchestrates transcribe → compare → materialize |
| Benchmark composition | crates/batchalign/src/benchmark.rs: process_benchmark() | Calls process_transcribe(), then process_compare_main_annotated() |
Composite architecture
benchmark is the canonical Composite command. It calls two sub-workflows
in sequence using their shared internal dispatch helpers:
transcribe_pipeline.rs: produces the hypothesisChatFilecompare.rs: producesComparisonBundlefrom hypothesis + gold
The materializer for benchmark is materialize_main_annotated() function
(injects comparison annotations on the main/hypothesis side), which is the
opposite of the released compare command’s materialize_released() function.
They share the same ComparisonBundle type but use different output views.
Gold file discovery
Gold files (FILE.cha) are expected alongside the audio (FILE.mp3) with the
same stem. If the gold file is missing, the audio file is reported as failed
with a typed GoldFileMissing error.
The pairing is DERIVED, never submitted. plan_benchmark_pairs
(recipe_runner/planner.rs) takes each discovered input as the recording and
builds the gold path from it by replacing the extension.
Two things enforce that, because the planner used to accept anything it was given:
-
Sources are classified at submission.
JobSubmission::validaterefuses a CHAT source for a command whose planner isPlannerKind::BenchmarkPairs, naming the file. Without this, a submitted.chabecame an audio work unit whose gold was itself, andprepare_asr_media_inputhanded the transcript toensure_wav, which hands it to ffmpeg.The planner is the right key for exactly this case, and only this case: it is the planner that derives the gold companion by replacing the source’s extension, so a
.chaunder it is its own gold. It is NOT a proxy for “takes audio”:alignandspeaker_identifyarePlannerKind::AudioInputsandCommandIoProfile::PathsModeAudioyet consume CHAT.
Known gap: the same shape is still open for the media commands
transcribe, opensmile, avqi and diarize have the identical exposure. A
.cha submitted to any of them is accepted and reaches ensure_wav, which
hands it to ffmpeg; runner/policy.rs deliberately leaves MediaAnalysisV2
outside the dispatch-time CHAT gate, so nothing classifies the source in
between.
Generalizing the submission check to every command whose declared
CommandSourceKind is Media was implemented and then withdrawn, because it
is not sound against this repository’s own tests: the server suites drive
transcribe with CHAT fixtures against the test-echo worker, in both content
mode (FilePayload { filename: "test.cha" }) and paths mode
(source_paths: [..._test.cha]), since test-echo echoes whatever it is given.
The generalized rule failed 45 tests in cli_integration_suite.
Closing it properly therefore needs a decision this change did not make: either
the test harness stops using CHAT as a stand-in for media, or the check
distinguishes a real submission from a test-echo one. CommandSourceKind is
declared and pinned per command in recipe_runner/catalog.rs so that the fact
is stated wherever that decision is taken.
BenchmarkWorkUnithas private fields and apub(super)constructor, soplan_benchmark_pairsis the only place one is built.benchmark_pipeline.rsandplanning/read it throughaudio()andgold_chat()accessors and cannot mint one, which is what stops a second route from pairing two unrelated files or putting a transcript in the audio slot.
Testing
make test
# Full ML golden test (ASR + compare, only on Fleet/Large-tier hosts)
cargo test -p batchalign --features ml-golden --test ml_golden benchmark::golden
Related developer documentation
- Command Flowcharts: benchmark
- compare developer reference
- transcribe developer reference
- Adding Commands, use
benchmarkas the reference forComposite
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
opensmile: Developer Reference
Status: Current Last updated: 2026-07-29 18:27 EDT
Implementation guide for the opensmile command. For user-facing
documentation, see User Guide: opensmile.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: OpensmileArgs | Positional input/output dirs, feature-set, lang |
| Catalog entry | crates/batchalign/src/recipe_runner/catalog.rs | the CatalogEntry for opensmile |
| Stage recipe | crates/batchalign/src/recipe_runner/recipes.rs | OPENSMILE_RECIPE |
| Audio prep | Shared media prep in crates/batchalign/src/runner/ | Converts audio to mono PCM artifact |
| Worker IPC | batchalign/inference/opensmile.py: extract_features() | Loads openSMILE, returns feature dict |
| CSV writer | crates/batchalign/src/commands/opensmile.rs | Typed feature map → row-oriented CSV via csv crate |
Positional I/O
opensmile does not use CommonOpts (PATHS... -o DIR). It uses positional
INPUT_DIR OUTPUT_DIR syntax. This is an intentional deviation inherited from
BA2 and documented in Command I/O.
Output format change from BA2
BA2 wrote a transposed CSV (feature per row, file per column). BA3 writes a row-oriented CSV (file per row, feature per column). This is a breaking output format change. The feature names and values are identical.
Worker IPC: opensmile task (V2 protocol)
execute_v2 request:
{
"task": "opensmile",
"audio_ref_id": <prepared-audio-ref>,
"feature_set": "eGeMAPSv02",
"feature_level": "functionals"
}
execute_v2 response:
{
"features": { "F0semitoneFrom27.5Hz_sma3nz_amean": 12.3, ... }
}
Related developer documentation
This page last changed: 2026-07-29 (commit 36ab6582). The whole book last changed: 2026-09-16 (commit 34d249d8).
avqi: Developer Reference
Status: Current Last updated: 2026-05-02 08:18 EDT
Implementation guide for the avqi command. For user-facing documentation,
see User Guide: avqi.
Implementation map
| Layer | Location | Responsibility |
|---|---|---|
| CLI args | crates/batchalign/src/cli/args/commands.rs: AvqiArgs | Positional input/output dirs, lang |
| Command definition | crates/batchalign/src/commands/avqi.rs | CommandDefinition impl, paired file discovery |
| Audio prep | Shared media prep | Converts .cs.* and .sv.* to typed PCM artifacts |
| Worker IPC | batchalign/inference/avqi.py: calculate_avqi() | parselmouth + torchaudio analysis |
| Output writer | crates/batchalign/src/commands/avqi.rs | Writes .avqi.txt from typed metrics struct |
Positional I/O
Like opensmile, avqi uses positional INPUT_DIR OUTPUT_DIR rather than
CommonOpts. Inherited from BA2 for interface parity.
Paired file matching
For each continuous speech file STEM.cs.EXT in INPUT_DIR, the command
looks for STEM.sv.EXT (any supported audio extension). Unpaired files are
reported as errors. Matching is case-insensitive on the .cs. / .sv.
fragment; the extension can differ between the two files of a pair.
Worker IPC: avqi task (V2 protocol)
execute_v2 request:
{
"task": "avqi",
"cs_audio": { path, start_ms, end_ms, sample_rate },
"sv_audio": { path, start_ms, end_ms, sample_rate }
}
execute_v2 response:
{
"avqi_score": 3.14,
"hnr": 12.5,
"jitter": 0.003,
"shimmer": 0.04,
...
}
Daemon preference
avqi prefers the local daemon (auto_daemon path) when available. Explicit
--server overrides this. The daemon preference exists because AVQI requires
access to both paired audio files on the same host, which is always true for
the local daemon but may not be true for a remote server.
Related developer documentation
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Rust Contributor Onboarding
Status: Current Last updated: 2026-09-05 03:20 EDT
This page is the shortest path to productive work on the Rust side of Batchalign3.
Start Here
- Read the user-facing CLI reference.
- Read the Rust workspace map.
- Read the Rust CLI and Server for dispatch architecture and command-creation checklist.
- Read the migration book if you need historical context from Batchalign2.
- Run the root workspace tests before changing behavior.
Current Rust Surfaces
The batchalign side of the workspace is split across three crates:
crates/batchalign-types/: shared domain and worker-boundary types (worker protocol, language/domain scalars, wire-facing identifiers). No filesystem, no network, no model loading.crates/batchalign/: the application: CLI, HTTP server, worker pool, cache, daemon lifecycle, command dispatch. Depends onbatchalign-typesand on thetalkbank-*crates for CHAT parsing/validation/transform.crates/batchalign-pyo3/: the PyO3 bridge, building thebatchalign_corePython module that the worker processes import. Worker-runtime-only surface (ASR / FA / media / cantonese-asr adapters); no morphosyntax orchestration.
Setup
make sync
make build
cargo check --workspace
cargo test --workspace
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
make sync is the normal setup path even for Cantonese/provider work.
Cantonese ASR engines are part of the main package surface, not a separate
extra or plugin tier.
Rebuild rule of thumb while iterating:
- CLI/server-only changes:
cargo build -p batchalignormake build-rust batchalignorcrates/batchalign-pyo3/changes:make build-python- the fast contributor loop: run
cargo build -p batchalignonce, thenuv run batchalign3 ...will use the repo CLI fallback in a source checkout after a slimmake build-python
Where To Work
- CLI flags, args parsing, cache, daemon, dispatch:
crates/batchalign/src/cli/,crates/batchalign/src/cache/,crates/batchalign/src/daemon.rs - Server routes, jobs, persistence, OpenAPI:
crates/batchalign/src/server.rs,crates/batchalign/src/routes/,crates/batchalign/src/openapi.rs - Worker pool, IPC, daemon spawn:
crates/batchalign/src/worker/ - Shared CHAT transformations and morphosyntax / FA / UTR / mapping logic:
crates/batchalign-transform/(andcrates/batchalign/src/chat_ops/for the batchalign-side adapters that route through it) - Worker-boundary types and wire-facing scalars:
crates/batchalign-types/ - Python extension boundary:
crates/batchalign-pyo3/
Expectations
- add or update tests before large behavioral changes
- keep public docs in sync with the actual CLI and server surface
- do not introduce maintainer-local filesystem paths into public docs
- treat migration notes as historical context, not as the current API contract
Useful Commands
cargo build -p batchalign
make build-python
cargo test -p batchalign --test cli_integration_suite cli::
cargo test -p batchalign --test cli_integration_suite e2e::
cargo test -p batchalign --test cli_integration_suite integration::
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
cargo run -q -p batchalign -- openapi --check --output openapi.json
This page last changed: 2026-09-05 (commit 7f5e86f1). The whole book last changed: 2026-09-16 (commit 34d249d8).
Rust Core (batchalign_core)
Status: Current Last updated: 2026-09-16 03:36 EDT
For new contributors, start with:
Repository structure
The PyO3 bridge lives at crates/batchalign-pyo3/ inside the
talkbank-tools workspace. It’s a single-crate project that builds
the batchalign_core Python extension module that worker processes
import. Its workspace deps are batchalign-types (domain newtypes
- V2 IPC types) and
talkbank-transform(Cantonese ASR projection, tokenizer realignment, morphosyntax sentence mapping, ASR post-processing). The runtimebatchaligncrate is NOT a pyo3 dependency.
Editable installs use the maturin build backend declared in
pyproject.toml (build-backend = "maturin", [tool.maturin] manifest-path = "crates/batchalign-pyo3/Cargo.toml", feature
pyo3/extension-module, profile = "dev" for fast rebuild).
For a packaged install you build the wheel via make batchalign-build-wheel, which compiles batchalign3 in release
mode, copies it into batchalign/_bin/, and then runs uv build --wheel --out-dir dist/ to produce the maturin-built wheel that
bundles both the extension and the native binary.
| Crate | Location | Purpose |
|---|---|---|
batchalign-pyo3 | crates/batchalign-pyo3/ | Worker runtime, what Python imports as batchalign_core. Deps: batchalign-types + talkbank-transform |
batchalign | crates/batchalign/ | The big Rust crate: CLI, axum server, dispatch, FA, morphosyntax, Rev.AI client, batchalign-specific CHAT extraction/injection in chat_ops/. NOT consumed by batchalign-pyo3. |
batchalign-types | crates/batchalign-types/ | Shared domain types and worker IPC types (depended on by both batchalign and batchalign-pyo3) |
batchalign-transform | crates/batchalign-transform/ | Pipelines, CHAT↔JSON, morphosyntax, Cantonese normalization, tokenizer realignment, asr_postprocess: the shared CHAT-aware logic the pyo3 worker calls into |
Module organization
The PyO3 crate is a slim worker runtime. All CHAT manipulation lives
in the sibling batchalign crate and is called directly by the Rust
runtime, never via callbacks from Python.
| Module | Purpose |
|---|---|
lib.rs | Module registration |
worker_protocol.rs | IPC message dispatch (health, capabilities, infer, execute_v2) |
worker_execute.rs | Shared executor control plane: request validation, failure taxonomy, response building |
worker_text_exec.rs | Batched text-task executors (morphosyntax, utseg, translate, coref) |
worker_asr_exec.rs | ASR execution (Whisper, Cantonese providers) |
worker_fa_exec.rs | Forced alignment execution |
worker_media_exec.rs | Speaker diarization, OpenSMILE, AVQI |
worker_text_results.rs | Text task normalization + align_tokens |
worker_artifacts.rs | Prepared artifact loading from IPC attachments |
cantonese_asr_bridge.rs | Cantonese provider projection + normalization |
py_json_bridge.rs | Python ↔ JSON conversion utility |
For exact line counts and the current module-by-module shape, read
the source under crates/batchalign-pyo3/src/.
Key PyO3 entry points
Worker protocol
prepare_protocol_message(...): admit a constructor-private pending request or return an immediate reader reply for rejection/shutdown.dispatch_protocol_message(...): execute only an admitted pending request through typed Python handlers and return its response payload. Shutdown has no executable operation variant.
Worker V2 executors
| Function | Purpose |
|---|---|
execute_asr_request_v2(...) | Load prepared audio, call Whisper / Cantonese provider |
execute_forced_alignment_request_v2(...) | Load prepared audio + text, call FA model |
execute_speaker_request_v2(...) | Load prepared audio, call pyannote / NeMo |
execute_opensmile_request_v2(...) | Load prepared audio, extract acoustic features |
execute_avqi_request_v2(...) | Load paired audio, calculate voice quality |
execute_morphosyntax_request_v2(...), execute_utseg_request_v2(...), execute_translate_request_v2(...), execute_coref_request_v2(...) | Load the prepared text batch and check its item count, call the Python runner adapter, then normalize the host’s BatchInferResponse into the typed V2 result (normalize_<task>_result in worker_text_results.rs) |
Utilities
| Function | Purpose |
|---|---|
align_tokens(...) | Map Stanza tokenizer output back to CHAT words |
| Cantonese bridge functions | Project FunASR / Tencent / Aliyun output into common shapes |
Cantonese normalization is not on this list any more. It was exported as
normalize_cantonese and cantonese_char_tokens until 2026-09-16, for Python
callers production did not have; it now has one owner,
AlignedNormalization in batchalign-transform, applied by the server.
These functions are internal, they exist for the Rust runtime to call into the worker process. They are not part of any public API surface and external code should not import them.
What was removed (historical context)
The PyO3 surface previously exposed a much larger set of callback methods and standalone Python-facing functions. They were removed as the Rust runtime grew enough to own the CHAT lifecycle directly:
ParsedChatclass and all its callback methods (parse,serialize,add_morphosyntax,add_forced_alignment, etc.)run_provider_pipeline()and provider-pipeline helpers- Standalone functions:
build_chat,parse_and_serialize,extract_nlp_words,wer_compute,wer_metrics,dp_align, etc. - All inner-function modules:
morphosyntax_ops,fa_ops,text_ops,speaker_ops,cleanup_ops,tier_ops
All domain logic now lives in the Rust batchalign crate (with
inline tests there) and is exercised through the Rust runtime.
Tree-sitter grammar
The CHAT grammar lives in this same workspace at grammar/. After
editing grammar/grammar.js, regenerate the C parser:
cd grammar && tree-sitter generate
This regenerates parser.c, which the tree-sitter parser depends on.
Forgetting this step causes the parser to use a stale grammar.
After grammar changes, always test against real corpus data in
addition to the curated test suite. See the parent
talkbank-tools/CLAUDE.md “Grammar Change Workflow” section for the
full mandatory sequence.
Building for development
When you change the PyO3 bridge or the shared Rust logic that worker processes consume, rebuild the extension and reinstall it into the dev environment:
make batchalign-python-prepare
This depends on batchalign-build-wheel, which rebuilds the native
batchalign3 binary in release mode, copies it into
batchalign/_bin/, and produces a maturin-built wheel via uv build --wheel. The prepare target then runs uv sync --group dev --no-install-project and uv pip install --reinstall --no-deps dist/*.whl to install the freshly built wheel into the active
uv environment.
For day-to-day editable iteration on the PyO3 layer, plain
uv run <anything> will trigger an incremental rebuild against
pyproject.toml’s maturin backend (profile = "dev").
If you also plan to run the standalone Rust CLI directly after a shared-crate change, rebuild that binary too:
cargo build -p batchalign
Running Rust tests
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
The parser integration suite lives in the chatter repository now, not here:
talkbank-parser-tests is consumed as a git dependency, so -p cannot
select it from this workspace. Run it from a chatter checkout.
GIL release strategy
All pure-Rust batchalign_core entry points release the Python GIL
via py.detach() (pyo3 0.29). This lets other Python threads run
while Rust does CPU-bound work.
The few entry points that take Python callbacks hold the GIL only during the callback invocation; outside of that they release. The pattern is:
- Release GIL, walk Rust data, collect inputs.
- Acquire GIL, call the Python callback.
- Release GIL, process the callback’s result.
This means CPU-bound Rust work doesn’t block other Python threads, while the callback (which runs Python model inference) holds the GIL as expected.
Workflow: adding a new worker-side capability
The “add a new CHAT transformation” workflow used to involve adding
#[pymethods] on a ParsedChat class that no longer exists. The
current workflow for adding capability to the worker side is:
- Decide where the work belongs. Pure CHAT/AST work belongs in
the sibling
batchaligncrate (Rust), not inbatchalign-pyo3. Only ML inference and provider-side glue go in the PyO3 layer. - If the work is ML inference, add the executor in the appropriate
worker_*_exec.rsfile undercrates/batchalign-pyo3/src/and wire it into the IPC dispatch. - Add Rust tests in the same crate.
- If you changed shared types in
batchalign-types, regenerate any IPC type mirrors. - Rebuild
batchalign_core(make batchalign-python-prepare, or justuv run …for incremental dev rebuilds). - Test against real corpus data, not just unit tests.
If you find yourself wanting to call the new function from Python “directly” rather than through the worker IPC dispatch, that’s a sign the work belongs in the Rust runtime instead.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Rust Workspace Map
Status: Current Last updated: 2026-09-05 03:20 EDT
The Batchalign code lives inside the talkbank-tools Cargo workspace
as sibling crates under crates/. There are no cross-repo path
dependencies: parser, model, transform, and Batchalign all build from
the same workspace.
flowchart TD
subgraph batchalign["Batchalign crates"]
ba["batchalign\n(CLI binary, axum server, job store,\nworker pool, CHAT ops, FA, ASR post-processing,\nRev.AI client)"]
types["batchalign-types\n(domain types, IPC contracts, newtypes)"]
pyo3["batchalign-pyo3\n(batchalign_core Python extension)"]
ba --> types
pyo3 --> types
end
subgraph tt["talkbank-tools core"]
model["talkbank-model"]
parser["talkbank-parser"]
transform["talkbank-transform"]
end
ba --> model & parser & transform
pyo3 --> transform
Crates
| Crate | Responsibility |
|---|---|
batchalign | the batchalign3 binary plus the axum server, dispatch, daemon lifecycle, worker pool, job store, cache, CHAT extraction/injection/mapping, FA, ASR post-processing, and the Rev.AI client. The bulk of the runtime. |
batchalign-types | shared domain, protocol, and scheduling types (newtypes, worker IPC contracts), the small low-dependency crate everything else builds on. |
batchalign-pyo3 | builds the batchalign_core Python extension module via maturin. Workspace deps are batchalign-types + talkbank-transform; it does NOT depend on the runtime batchalign crate. |
xtask | repo-local automation: affected-check selection, install/build smoke tests, repository policy checks. |
Internal organization of the batchalign crate
Top-level modules under crates/batchalign/src/:
cli/,commands/: CLI argument parsing, released-command specs, dispatch.routes/,db/,pipeline/,planning/,worker/,execution/, axum HTTP server, job store, worker orchestration.chat_ops/: CHAT extraction and injection.fa/: forced alignment and review-tier handling.morphosyntax/:%mor/%grainjection and Stanza interaction.revai/: Rev.AI ASR client.host_facts/,host_memory.rs,host_policy.rs: host-aware runtime configuration.cache/,provenance.rs,compare.rs,benchmark.rs: caching, provenance metadata, and compare/benchmark utilities.
Typical commands
cargo build -p batchalign
cargo check --workspace
cargo test --workspace
cargo test -p batchalign --test cli_integration_suite cli::
cargo test -p batchalign --test cli_integration_suite integration::
cargo xtask affected-rust packages
# Python extension build
uv run maturin develop -m crates/batchalign-pyo3/Cargo.toml -F pyo3/extension-module
# or, for a full wheel install into the dev env:
# make batchalign-build-wheel && make batchalign-python-prepare
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
Where to make changes
- CLI behavior, server APIs, job execution, logs, cache handling,
worker orchestration, CHAT ops, FA, morphosyntax, Rev.AI →
crates/batchalign/. - Shared newtypes or worker IPC contracts →
crates/batchalign-types/. - Python extension surface (
batchalign_core) →crates/batchalign-pyo3/. - Parser, data model, validation, or transform behavior consumed by
Batchalign → the corresponding
talkbank-*crate in the same workspace.
First files to read
crates/batchalign/src/cli/mod.rs:251:run_command(), the canonical command router.crates/batchalign/src/cli/: CLI dispatch (explicit server vs. auto-daemon).crates/batchalign/src/execution/: server-side task routing and dispatch shapes.crates/batchalign/src/routes/: Axum HTTP routes.crates/batchalign/src/worker/: worker pool and IPC.crates/batchalign/src/commands/: released-command specs and command-owned wrappers.crates/batchalign-pyo3/src/lib.rs: PyO3 module organization and entry points.
See also: Rust CLI and Server for detailed dispatch documentation and the checklist for adding new commands.
This page last changed: 2026-09-05 (commit 7f5e86f1). The whole book last changed: 2026-09-16 (commit 34d249d8).
Workflow Contributor Guide
Status: Current Last updated: 2026-07-30 18:21 EDT
This is the shortest path for adding a new command, workflow family, or engine without fighting the refactor stream.
If you read code before prose, start at
crates/batchalign/src/recipe_runner/catalog.rs. That table is the
contributor-facing map: every released command is one entry in it. From there,
jump to:
recipe_runner/recipes.rsfor the ordered stages each entry points atrecipe_runner/command_spec.rsfor what aCommandFamilyimpliescrates/batchalign/src/command_family.rsfor the workflow-family metadatacrates/batchalign/src/text_batch.rsfor reusable text-family helpers- the family-specific runner/dispatch modules only when the command shape is genuinely new
(crates/batchalign/src/commands/ no longer exists. It held a per-command
authoring layer that produced values nothing read, deleted 2026-07-28, and a
catalog of one-line delegations, deleted 2026-07-29.)
For batch-oriented text commands, the important typed seams are:
TextBatchFileInputfor one named file plus its owned CHAT payloadTextBatchFileResultsfor one batch’s named file outcomesTextWorkflowFileErrorfor a file-scoped failure that keeps the message separate from file identity
Choose A Family
The catalog already assigns released commands to one of these families, so the first question is usually “which family is my command reusing?”
The families are the variants of CommandFamily
(recipe_runner/command_spec.rs), and the choice is load-bearing: the family
implies seven runtime policies through const fns on the enum, so read those
before picking.
AudioSequentialfor one media file at a time (GPU lane, bounded file-level parallelism).BatchedTextwhen work is pooled across files into shared infer batches (CPU lane, one dispatch per job).ReferenceProjectionwhen two artifacts are jointly primary (paired inputs).MediaAnalysiswhen the output is not CHAT (IO lane).Compositewhen you are composing existing command flows (all policy delegated to children).- Use
text_batch.rsand typed materializers when the hard part is output shape rather than dispatch shape.
An earlier WorkflowFamily enum offered a coarser 4-way version of this choice
and was deleted on 2026-07-29; it merged the audio and media-analysis families
into one, and nothing read it.
Current Examples
Every one is a CatalogEntry in crates/batchalign/src/recipe_runner/catalog.rs
plus a Recipe in recipe_runner/recipes.rs:
transcribe:AudioSequential,TRANSCRIBE_RECIPEalign:AudioSequential,ALIGN_RECIPEmorphotag:BatchedText,MORPHOTAG_RECIPEcompare:ReferenceProjection,COMPARE_RECIPEbenchmark:Composite,BENCHMARK_RECIPE
The first three are the simplest command-owned wrappers over shared runner
families. compare is the reference-projection example, and benchmark is the
composite example that chains shared kernels while still keeping output
materialization in Rust rather than CLI glue.
Today compare is also the clearest example of “output shape is the hard
part”:
build_comparison_artifacts()morphotags only the main transcript and parses the gold companion rawComparisonBundleis the compare IR: main/gold utterance views, structural gold-to-main word matches, and metrics- the released materializer writes projected-reference
%xsrep/%xsmorthrough typed tier-content models, then lowers once toUserDefinedDependentTier - the internal benchmark/main materializer is separate from the compare command
- projection must stay AST-first rather than rebuilding tiers from
%xsrep/%xsmorstrings .compare.csvcomes from a typed row/table model, not handwritten CSV text
transcribe_s is the same per-file family as transcribe, but surfaced as the
diarized variant in the catalog.
Add A New Command
- Add the stage recipe to
crates/batchalign/src/recipe_runner/recipes.rs. - Declare one
CatalogEntryincrates/batchalign/src/recipe_runner/catalog.rs. That is the whole registration; there is no second place. - Reuse an existing runner family when possible; only widen
runner/dispatch/when the command shape is genuinely new. - Keep the command-specific orchestration in Rust helper modules, not in
pyo3. - Keep runner/dispatch code focused on job lifecycle, resource policy, and shared execution mechanics.
The step-by-step version, including which tests fail until each step is done, is Adding a New Command.
If the command batches text across files, prefer the
TextBatchFileInput/TextBatchFileResults seam over raw tuples at the
text-family boundary, and keep any file-local error detail in
TextWorkflowFileError rather than stringly return values.
If the command emits structured output, add a typed pre-serialization model in
the owning crate before you add serializer code. New semantic strings should be
newtyped, CHAT tier payloads should flow through WriteChat, and CSV should be
rendered from structured row/table types via csv.
Add A New Engine
- Keep provider selection at the control-plane boundary.
- Keep engine-specific transport or worker protocol code in the provider or worker layer.
- Add new typed payloads in a shared crate before widening the command-owned Rust API.
Practical Rule
If a change makes commands/* more obvious and keeps runner/dispatch/*
reusable, it is probably a real improvement. If it pushes orchestration back
into pyo3, cli, or scattered dispatch tables, it is probably the wrong
direction.
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
Decision Evidence
Status: Current Last updated: 2026-09-02 20:07 EDT
Current policy
Batchalign3 records machine decisions such as timing removal, boundary
clamping, grouping refusal, and morphosyntax mapping failure as typed
DecisionRecord values. It does not write %xalign or %xrev dependent
tiers. The align and morphotag serialization paths strip those two
abandoned legacy tiers if they are present in an input file.
The CLI and wire protocol still accept review_level values so stored jobs and
older clients continue to deserialize. The value is deliberately absent from
the operation that finalizes decision evidence, so no caller can use it to
authorize CHAT-tier generation.
flowchart LR
P["Pipeline decision"] --> R["DecisionRecord<br/>typed module + strategy"]
R --> L["Structured tracing"]
R --> F["FA decision finalizer"]
F --> W["WrittenFaDecisions<br/>typestate proof"]
W --> J["FaDecisionTrace<br/>in *_fa_evidence.json"]
C["Parsed CHAT"] --> S["strip_decision_tiers"]
S --> O["Serialized CHAT<br/>no %xalign / %xrev"]
V["Legacy ReviewLevel"] -. "wire compatibility only" .-> X["No presentation authority"]
This separation is intentional. CHAT remains the researcher-facing transcript; machine confidence and provenance remain machine-readable evidence rather than dependent-tier clutter.
The typed record
DecisionRecord is defined in
crates/batchalign-transform/src/decisions.rs:
pub struct DecisionRecord {
pub line_idx: LineIdx,
pub speaker: String,
pub strategy: DecisionStrategy,
pub reason: String,
pub needs_review: bool,
}
LineIdx is a newtype over the index into ChatFile.lines; it cannot be
silently confused with an utterance ordinal. DecisionStrategy is an
exhaustive enum over module-specific strategy enums. Stable module and strategy
names are derived from those variants for tracing and serialized evidence.
DecisionRecord::new_and_trace() constructs and immediately traces a record.
Callers that construct records through an outcome adapter call trace() at the
decision boundary. A caller must not emit a second ad-hoc warning for the same
decision.
Forced-alignment evidence lifecycle
Forced alignment has the complete durable path. The pipeline collects every
decision source in a FaDecisions struct rather than assembling parallel
vectors by convention. Adding a new source breaks both the full and incremental
paths at compile time until each explicitly supplies it.
stateDiagram-v2
[*] --> FaApplied: apply word timings
[*] --> NoInjectionProjection: reuse or empty groups
FaApplied --> FaFinalized: optional repair, then typed monotonicity
NoInjectionProjection --> FaFinalized: finalize_without_injection
FaFinalized --> FaDecisions: add rescue / refusal records
FaDecisions --> WrittenFaDecisions: retain_decision_evidence
WrittenFaDecisions --> FaEvidence: into_evidence
FaEvidence --> [*]: serialize debug evidence
retain_decision_evidence performs two inseparable actions:
- strips legacy
%xalignand%xrevtiers from the CHAT model; and - returns
WrittenFaDecisions, whose records and numeric timing effects are consumed into the FA evidence trace.
With --debug-dir, the resulting <stem>_fa_evidence.json contains typed
decision records alongside group windows, word identities, cache keys, source
classification, raw/pre-injection timings, fallback events, and validation
violations. The evidence file is the research and replay surface; CHAT is not.
Discarded measurements: dropped_word_timings
One decision does not adjust a timing, it removes one. When two utterances’
measured words genuinely interleave, the resolver clamps the earlier bullet
and every word past the bound; a word lying wholly past the bound keeps no
positive extent, so its measured span is discarded and the %wor slot is left
untimed. That span is a measurement, and losing it silently would be the
defect.
The artifact therefore carries a flat dropped_word_timings section: one
entry per discarded span, each naming the line and utterance it came from, the
speaker, the tier (main_tier or wor), the word’s position on that tier, the
measured start_ms/end_ms, and the bound_ms it exceeded. The join is
already done, so an entry is readable on its own; the nested form inside
timing_decisions only knows the span and the position, and needs its parent
effect for everything else.
The section is DERIVED from timing_decisions when the trace is assembled
(FaTimingDecisionTrace::dropped_word_timings), never maintained beside it, so
the two cannot disagree. It is always present, and empty when the run discarded
nothing: an absent key and an empty one read identically to a consumer that does
not know which schema version wrote the file.
The full, incremental, complete-%wor, and grouping-empty paths all produce
the same FaFinalized typestate. Optional repair is therefore always before
the declared monotonicity policy, and a run with zero fresh inference groups
cannot substitute compatibility policy, erase a grouping refusal, or omit a
monotonicity decision.
Other command families
The shared vocabulary is broader than the currently durable evidence sinks. That distinction matters:
| Command family | Typed outcome/record today | Trace today | Durable per-file decision evidence today |
|---|---|---|---|
| Forced alignment | Yes | Yes | Yes, in FA debug evidence when requested |
| Morphotag | Yes | Yes for anomaly records | No; the injection collection is not yet serialized |
| Utterance segmentation | Typed outcomes and adapters exist | Not a complete production sink | No |
| Coreference | Typed outcomes and adapters exist | Not a complete production sink | No |
Do not describe the common DecisionRecord vocabulary as though every command
already persists it. Extending durable evidence to morphotag, utseg, and coref
requires a typed result owned by each command and an explicit serialization
boundary; reintroducing CHAT tiers is not that boundary.
CHAT cleanup paths
Both align and morphotag strip legacy review tiers unconditionally before
serialization. This includes:
- a normal inference run;
- a cache-only or no-work run;
- incremental morphotag with no changed utterances; and
- CA morphotag pass-through.
The CA case is covered by an end-to-end pipeline regression test because it previously returned before cleanup and preserved old tiers.
Adding a decision
- Add a variant to the narrowest module-specific strategy enum.
- Add its stable name in that enum’s exhaustive
as_str()match. - Construct the record at the point where the decision is made, using a typed line index and a structured key/value reason.
- Set
needs_reviewonly when a human can usefully adjudicate the outcome. - Thread the record into the command’s typed result. For FA, add a field to
FaDecisionsif it is a new producer; do not append it independently in the full and incremental paths. - Add a boundary test proving the record reaches its evidence sink. Do not add a test for CHAT-tier generation; no such operation exists.
Legacy compatibility
ReviewLevel::{None, LowConfidence, All} remains serializable and parseable.
All values have the same presentation behavior: no %xalign or %xrev output.
New scripts should omit --review-level.
strip_decision_tiers retains its explicit legacy labels because removal must
recognize old files. References to those labels in cleanup tests are historical
fixtures, not supported output examples.
This page last changed: 2026-09-02 (commit 219ef89c). The whole book last changed: 2026-09-16 (commit 34d249d8).
Terminator Architecture
Status: Current Last updated: 2026-05-21 13:15 EDT
This document is a comprehensive reference for how CHAT terminators are created, stored, defaulted, validated, and propagated through every batchalign3 pipeline. It also covers the relationship between terminators and bullet placement, the CA transcript special case, and the differences from batchalign2.
What Is a Terminator?
A terminator is the punctuation mark that ends a CHAT utterance. It indicates the utterance type (declarative, interrogative, etc.) and in CA transcripts, the intonation contour.
CHAT manual reference: https://talkbank.org/0info/manuals/CHAT.html#Utterance_Terminators
In standard CHAT, every main-tier utterance must end with a terminator:
*CHI: I want a cookie . ← Period (declarative)
*MOT: do you want a cookie ? ← Question (interrogative)
*CHI: give me cookie ! ← Exclamation
In CA transcripts (@Options: CA), terminators are optional. Many
utterances have no terminator at all, and others use CA-specific intonation
markers instead of standard punctuation:
*KE: I like LA the best , LA's quiet -->
*CO: yeah
*KE: I'm gonna make a little tiny
The --> is a CA level-pitch terminator. The other two utterances have no
terminator at all. This is valid CHAT for CA mode.
The 20 Terminator Variants
Source: talkbank-model/src/model/content/terminator.rs
| Category | Variant | CHAT Token | Unicode |
|---|---|---|---|
| Standard | Period | . | |
Question | ? | ||
Exclamation | ! | ||
| Interruption | TrailingOff | +... | |
Interruption | +/. | ||
SelfInterruption | +//. | ||
InterruptedQuestion | +/? | ||
SelfInterruptedQuestion | +//? | ||
BrokenQuestion | +!? | ||
TrailingOffQuestion | +..? | ||
BreakForCoding | +. | ||
| Quotation | QuotedNewLine | +"/. | |
QuotedPeriodSimple | +". | ||
| CA Intonation | CaRisingToHigh | (arrow) | U+21D7 |
CaRisingToMid | (arrow) | U+2197 | |
CaLevel | (arrow) | U+2192 | |
CaFallingToMid | (arrow) | U+2198 | |
CaFallingToLow | (arrow) | U+21D8 | |
| CA Break | CaTechnicalBreak | (symbol) | U+224B |
CaNoBreak | (symbol) | U+2248 |
The Terminator enum does not implement Default. An utterance with no
terminator is represented as Option<Terminator>::None, never a defaulted
variant.
Storage in the AST
Source: talkbank-model/src/model/content/tier_content.rs
pub struct TierContent {
pub linkers: Linkers,
pub language_code: Option<LanguageCode>,
pub content: TierItems, // words, separators, groups, etc.
pub terminator: Option<Terminator>, // <-- HERE
pub postcodes: Vec<Postcode>,
pub bullet: Option<Bullet>, // terminal utterance-level bullet
pub span: Span,
}
Serialization order is critical:
flowchart LR
L["linkers"] --> LC["language_code"] --> C["content items"] --> T["terminator"] --> PC["postcodes"] --> B["terminal bullet"]
When terminator is None, the serializer skips it entirely. The bullet still
appears at the end, but there is no punctuation mark between content and bullet.
The Terminator-Bullet Problem
How the grammar classifies bullets
The tree-sitter grammar defines two positions where MEDIA_URL (bullet) nodes
can appear:
flowchart TD
subgraph "Grammar Rule: tier_body"
contents["contents\n(repeat1: words, separators,\noverlap markers, CA arrows)"]
utt_end["utterance_end\n(terminator? postcodes? media_url?)"]
end
contents --> utt_end
contents -->|"MEDIA_URL in contents"| IB["InternalBullet\n(content item)"]
utt_end -->|"MEDIA_URL in utterance_end"| TB["TierContent.bullet\n(terminal bullet)"]
A MEDIA_URL node in contents becomes UtteranceContent::InternalBullet.
A MEDIA_URL node in utterance_end becomes TierContent.bullet.
The CA arrow bug (grammar.js)
The 5 CA intonation arrows are defined as content separators, NOT as
terminators in the grammar. The terminator rule lists 16 variants but
excludes the arrows. The arrows are in the ca_intonation rule inside
separator, which is consumed by the greedy contents rule.
Grammar:
terminator = choice(PERIOD, QUESTION, EXCLAMATION, ..., CA_NO_BREAK, CA_TECHNICAL_BREAK)
← CA arrows MISSING here
separator = choice(..., ca_intonation)
← CA arrows consumed here
contents = repeat1(choice(content_item, separator, ...))
← greedy, eats arrows before utterance_end runs
Result for a CA line with two bullets:
Input: *KE: words --> [bullet1] [bullet2]
Grammar parse:
contents = [words, Separator(-->), InternalBullet(bullet1)]
utterance_end = [no terminator, terminal bullet(bullet2)]
The arrow is eaten by contents as a separator. The first bullet after it is
also eaten as an InternalBullet. Only the second bullet reaches
utterance_end and becomes the terminal TierContent.bullet.
Consequence: double bullets on the main line
When the file is serialized:
- Content items are written (including
InternalBullet(bullet1)) - Terminator is written (None for CA, so skipped)
- Terminal bullet is written (
TierContent.bullet = bullet2)
Output: *KE: words --> [bullet1] [bullet2] – two bullets.
The corrective direction
The arrows are not terminators. Per CHECK, the five CA intonation
arrows (→ ↗ ⇗ ↘ ⇘) are separators, not utterance enders, and
the current Terminator::CaRisingToHigh / CaRisingToMid / CaLevel / CaFallingToMid / CaFallingToLow variants are the misclassification.
The corrective direction is to remove those five variants from the
model and grammar, keeping the arrows in the separator rule where
they belong, and to fix the double-bullet symptom downstream: bullets
positioned after an arrow on a CA tier should remain a single terminal
TierContent.bullet, not split into InternalBullet + terminal.
Tracked as BUG-009.
Where Terminators Are Created
Every code path that creates a Terminator value:
1. Parser (talkbank-tools)
File: talkbank-parser/.../terminator.rs
Maps 16 of 20 CST node kinds to Terminator variants. The 5 CA intonation
arrows are not mapped (they never reach the terminator position due to the
grammar bug above). The 2 CA break variants (CaTechnicalBreak, CaNoBreak)
and their linker forms are mapped.
2. ASR post-processing
File: crates/batchalign-transform/src/asr_postprocess/mod.rs
When retokenize() flushes remaining words without explicit punctuation, it
unconditionally appends a period:
buf.push(AsrWord::new(".", None, None)); // HARDCODED PERIOD
Impact: All ASR-produced utterances get a period terminator. CA transcripts created from ASR will have periods that should not exist.
3. Utterance segmentation (utseg)
File: crates/batchalign-transform/src/utseg.rs
When splitting an utterance into multiple, every split gets a hardcoded Period:
Terminator::Period { span: Span::DUMMY }
Impact: Running utseg on a CA transcript destroys the original terminator-less structure.
4. CHAT building from ASR
File: crates/batchalign-transform/src/build_chat/mod.rs
Infers terminator from the last word text. Default is Period:
_ => Terminator::Period { span: Span::DUMMY }
Impact: All build-from-ASR paths produce Period terminators.
Where Terminators Are Silently Defaulted
Morphosyntax payload collection
File: crates/batchalign-transform/src/morphosyntax/payload.rs
.unwrap_or_else(|| ".".to_string())
When collecting words for the Python Stanza worker, a missing terminator is
silently replaced with "." in the payload string. This affects the NLP model’s
sentence-type features.
MOR tier injection terminator handling
File: crates/batchalign-transform/src/morphosyntax/injection.rs
The injection layer copies the utterance’s terminator into the rebuilt
MOR tier. When the terminator is None, the MOR tier gets an empty
string at that position. There is no morphosyntax cache layer to read
from (text NLP runs use a no-op cache).
Where Terminators Are Validated
Pre-command validation (CA-aware)
File: ../chatter/crates/talkbank-transform/src/validate.rs
let is_ca = file.options.iter().any(|f| matches!(f, ChatOptionFlag::Ca));
if !is_ca && utt.main.content.terminator.is_none() { /* error */ }
Correctly exempts CA files from the missing-terminator check.
Post-command validation (NOT CA-aware)
File: ../chatter/crates/talkbank-transform/src/validate.rs
if utt.main.content.terminator.is_none() { /* warning */ }
Does not check for CA mode. This is why the Sprott output shows “utterance by *KE lost its terminator” warnings – the CA utterances legitimately have no terminator, but post-validation doesn’t know that.
NLP mapping assumptions
File: crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs
The MOR/GRA coordination code assumes every utterance has a terminator that maps to a PUNCT relation in GRA:
let terminator_idx = mors.iter().map(|m| m.count_chunks()).sum::<usize>() + 1;
The +1 is the terminator. For CA utterances without terminators, this count
may be wrong.
Single-Parse Pipeline (eliminates serialize→re-parse)
The align pipeline previously serialized the ChatFile to text after UTR and
re-parsed it before FA, creating 3 parses and 2 serializations per file. The
re-parse was the mechanism that turned UTR-injected terminal bullets into
InternalBullet content items (when CA arrows prevented correct terminal
placement).
As of 2026-03-30, the pipeline uses a single-parse architecture:
fa_pipeline.rsparses CHAT text once →ChatFilerun_utr_pass(&mut ChatFile, ...)mutates the AST in place, no serializationrun_fa_from_ast(ChatFile, ...)receives the AST directly, no re-parse- Final serialization happens once at the end
This eliminates the serialize→re-parse cycle entirely. Combined with the talkbank-tools CA terminator resolution (which promotes trailing CA arrows from separator to terminator during CST→AST conversion), the double-bullet bug cannot occur.
Where Terminators Are Correctly Preserved
-
FA pipeline (
fa/injection.rs,fa/postprocess.rs,fa/orchestrate.rs): FA never touchesTierContent.terminator. It only modifies wordinline_bulletandTierContent.bullet. -
Retokenize (
retokenize/parse_helpers.rs): When Stanza produces a different terminator than the original, a warning is logged and the original is preserved. -
Compare (
compare.rs): Collects terminators asOption<String>– safe forNone.
Batchalign2 Comparison
BA2 (Python, baseline commit 84ad500b) handled terminators very differently:
BA2 data model
BA2’s Utterance stores content as a flat List[Form] where Form has
text: str and time: Optional[Tuple[int, int]]. The terminator is the
last Form in the list whose text is in ENDING_PUNCT. There is no separate
terminator field – it’s just a regular form.
ENDING_PUNCT = [".", "?", "!", "+//.", "+/.", "+...", "+/?", "+!?", ...]
MOR_PUNCT = ["", "", ","]
BA2 parsing
BA2 regex-matches \x15\d+_\d+\x15 to find bullets. Terminators are detected
by scanning the last word of the content. If no terminator is found, . is
the implicit default via the delim property.
BA2 forced alignment
- Groups utterances into 20-second windows
- Strips ALL punctuation (both
ENDING_PUNCTandMOR_PUNCT) from the text sent to Whisper - Whisper returns timestamped words WITHOUT terminators
- Character-level DP alignment maps words back
- Terminators remain in the document but get no timing
BA2 CA handling
BA2 had no CA-specific terminator handling. CA intonation arrows were not
in ENDING_PUNCT and would have been treated as regular word content.
Key differences
| Aspect | BA2 | BA3 |
|---|---|---|
| Terminator storage | Last Form in content list | Separate Option<Terminator> field |
| Terminator type | String from ENDING_PUNCT | 20-variant enum |
| CA arrows | Not recognized as terminators | Enum variants exist but grammar doesn’t wire them |
| Missing terminator | Implicit . default | Explicit None |
| Bullet model | Regex on serialized text | AST: InternalBullet vs TierContent.bullet |
| FA terminator treatment | Strip before Whisper | Exclude from word extraction |
| Grammar-level distinction | N/A (no grammar) | contents vs utterance_end position |
What changed (and what broke)
BA3 introduced a principled AST with separate terminator and bullet fields, replacing BA2’s string manipulation. This is architecturally correct but exposed a grammar gap: the CA intonation arrows are defined as content separators in the grammar, not as terminators. BA2 never had this problem because it never parsed terminators from a grammar – it just did string matching.
The double-bullet bug is a consequence of this grammar gap. When the grammar
consumes a CA arrow as a content separator, it also consumes the next bullet as
an InternalBullet content item. Then utterance_end creates a second
terminal bullet. Both serialize on the main line.
Exhaustive File Inventory
Every file in batchalign3 that references terminators, classified by behavior:
All paths below are under crates/.
| File | Behavior | Notes |
|---|---|---|
talkbank-transform/src/asr_postprocess/mod.rs | DEFAULTS to . | Appends period when no punctuation |
talkbank-transform/src/build_chat/mod.rs | CREATES from last word | Defaults to Period for unrecognized |
talkbank-transform/src/utseg.rs | CREATES Period | Split utterances always get period |
talkbank-transform/src/morphosyntax/payload.rs | DEFAULTS to "." | Payload string includes silent default |
talkbank-transform/src/morphosyntax/injection.rs | READS, patches | Copies utterance terminator into MOR tier |
talkbank-transform/src/inject.rs | PRESERVES | Top-level injection entry; passes through to retokenize |
talkbank-transform/src/retokenize/parse_helpers.rs | PRESERVES | Keeps original when Stanza differs |
talkbank-transform/src/retokenize/rebuild.rs | PRESERVES | Carries expected_terminator through |
batchalign/src/compare.rs | READS | Collects as Option<String> |
talkbank-transform/src/translate.rs | READS | Lists all variants for spacing |
talkbank-transform/src/validate.rs | CHECKS (CA-aware) | Pre-validation exempts CA |
talkbank-transform/src/validate.rs | CHECKS (NOT CA-aware) | Post-validation always warns |
talkbank-transform/src/morphosyntax/sentence_mapping.rs | ASSUMES +1 | GRA index assumes terminator exists |
talkbank-transform/src/morphosyntax/gra_validate.rs | ASSUMES | Skips last GRA as terminator PUNCT |
batchalign/src/fa/ | PRESERVES | Forced-alignment never touches terminator |
Known Issues
-
Grammar bug: CA intonation arrows not in
terminatorrule – causes double bullets andInternalBulletmisclassification. -
Post-validation not CA-aware:
validate_output()warns about missing terminators even for CA files. -
Hardcoded period defaults: ASR postprocess, utseg, and build_chat all default to Period when no terminator is found. CA transcripts processed through these paths get incorrect periods.
-
NLP mapping +1 assumption: MOR/GRA coordination assumes a terminator PUNCT relation exists. CA utterances without terminators may have wrong GRA indices.
-
Morphosyntax silent default: Cache key computation silently defaults missing terminator to
".", which could cause cache collisions between CA utterances that differ only in terminator presence.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Overlap-Aware Alignment Improvements
Status: Current Last updated: 2026-09-06 23:14 EDT
This page documents overlap-aware alignment improvements: what is shipped and known limitations.
Shipped Features
Two-pass UTR and its tuning require explicit opt-in. The default global strategy uses case-insensitive exact matching and does not apply two-pass fuzzy matching, CA window narrowing, density exclusion or tight-buffer tuning.
| Feature | Default | CLI override |
|---|---|---|
| Two-pass overlap UTR | Gated: requires --utr-strategy two-pass | --utr-strategy two-pass to enable |
| Fuzzy word matching (Jaro-Winkler) | Two-pass only: threshold 0.85 | --utr-fuzzy 1.0 for exact-only |
| CA marker window narrowing | Enabled (when two-pass active) | --utr-ca-markers disabled |
| Density-aware fallback | 30% threshold | --utr-density-threshold |
| Tight buffer for pass-2 | 500ms | --utr-tight-buffer |
| End-time overlap clamping | Always on | N/A |
| %wor suppression for CA | Auto when @Options: CA | --nowor for non-CA |
Defaults were tuned on: SBCSAE, Jefferson NB, TaiwanHakka, APROCSA.
Two-pass is opt-in. The two-pass UTR strategy is gated behind
--utr-strategy two-pass because it can regress alignment on certain
inputs (CA transcripts with two timestamps on a main line; transcripts
with substantial overlap between consecutive utterances). Auto always
selects GlobalUtr.
End-time overlap clamping: enforce_monotonicity() clamps utterance
N’s end to utterance N+1’s start, eliminating the systematic overlap
that UTR’s independent per-utterance token range assignment can produce.
CA %wor suppression: @Options: CA files automatically skip %wor
generation, CA prosodic notation cannot be represented in %wor.
This page also evaluates further improvements to UTR alignment for transcripts
with dense overlapping speech (&* markers) and heavily restructured transcripts.
Why This Matters
The cost of alignment failure is not abstract. Every untimed utterance that the aligner could have gotten right but didn’t becomes manual labor: a human must listen to the audio, locate the utterance, and fix the bullet by hand. For overlap-heavy corpora (aphasia protocols, conversation analysis, any multi-party recording), this can mean hours per file. Even a small improvement in coverage, 5-10% fewer untimed utterances, translates directly into saved human effort on the last mile of correction.
This makes alignment quality a high-leverage investment. The algorithms don’t need to be perfect; they need to be measurably better, and the improvement needs to be validated empirically on real data.
Strategy Selection and Two-Pass Architecture
The UTR strategy is selected based on the file’s overlap markers. The following diagram shows the selection logic and the two-pass approach.
flowchart TD
start(["UTR alignment requested"])
scan{"Any utterance has\n+< linker or ⌊ CA\noverlap markers?"}
scan -->|No| global["GlobalUtr\n(utr.rs)\nSingle monotonic pass\nover all utterance words"]
scan -->|Yes| twopass["TwoPassOverlapUtr\n(two_pass.rs)"]
twopass --> pass1["Pass 1: Global alignment\nFlatten ALL words\n(including &* overlap words)\ninto one reference sequence\nAlign via Hirschberg DP"]
pass1 --> pass2["Pass 2: Per-backchannel\nre-alignment\nNarrow ASR token window\naround overlap regions\nRe-align problematic\nutterances only"]
global --> result
pass2 --> result(["UtrResult\ninjected / skipped / unmatched"])
Source: select_strategy() in
crates/batchalign/src/chat_ops/fa/utr.rs:116, overlap detection in
chat_ops/fa/utr/overlap_markers.rs, two-pass logic in
chat_ops/fa/utr/two_pass.rs.
Problem Statement
The UTR global DP aligner is monotonic: it assumes that words later in the transcript correspond to words later in the audio. Two things break this assumption:
&*markers embed one speaker’s words inside another’s utterance, creating text positions that don’t match audio positions.- Transcript restructuring: splitting run-on ASR utterances, adding
speakers, reattributing speech, creates a word sequence that diverges
from the ASR’s word sequence in ways that go beyond
&*.
In the worst documented case (2265_T4 from APROCSA), 36.5% of utterances lost timing (232 of 636).
See Monotonicity Invariant for the user-facing explanation and Known DP Failure Modes for the theoretical context.
What We Don’t Know Yet
We do not have empirical data on which failure source dominates. The estimates in this document are architectural reasoning, not measurements. Before committing to either approach, we need the simulation experiment described in Empirical Validation.
Key unknowns:
-
In the 2265_T4 file, only 15 of 232 untimed utterances actually contain
&*markers (6.5%). The untimed blocks are dominated by rapid multi-speaker turn-taking (16 speaker transitions in 23 utterances). This suggests transcript restructuring, not&*: is the primary cause in this file. -
We do not know whether per-speaker word order matches audio order after restructuring. If a user moved PAR utterances around (not just added
&*markers), even per-speaker DP would see divergence. -
The 2-speaker experiment (reassigning REL1/REL2 to INV) produced identical results. This proves extra speakers aren’t the issue, but it doesn’t distinguish between
&*pollution and turn-order divergence as the root cause. -
Of the 271 files on the no-align exclusion list, 249 are from Fridriksson-2 (which may have different failure patterns than APROCSA). We haven’t characterized why those files fail.
Two Sources of Failure
Source 1: &* words pollute the reference sequence
The current flatten_side()
(crates/batchalign-transform/src/compare/engine.rs, named flatten_words()
until 2026-09-16) includes &*-embedded words in the reference
sequence. These words belong to a different speaker and appear at different
temporal positions in the ASR output. The DP tries to match them, consumes
ASR tokens at wrong positions, and desynchronizes subsequent matches.
Fixable by stripping &* words from the DP reference.
Source 2: Transcript structure diverges from ASR structure
When a reviewer splits a run-on ASR utterance into three turns, adds new
speakers, and reattributes speech, the resulting word sequence diverges from
what fresh ASR produces, even with &* words stripped. The DP must align
two genuinely different word sequences (the edited transcript vs. fresh ASR),
and the cumulative divergence across hundreds of utterances causes sync loss
in dense regions.
Not fixable within a single-stream monotonic framework. Requires per-speaker separation (Approach 2) or a fundamentally different alignment strategy.
Empirical Validation Before Building
Do this before writing any production code.
Simulation: per-speaker coverage without per-speaker UTR
The key question for per-speaker UTR is: within each speaker’s stream, does the word order match the audio order? We can test this cheaply:
- Take the 2265_T4 input file (636 utterances, 4 speakers).
- Produce 4 single-speaker CHAT files (one per speaker, same audio).
- Run existing
alignon each single-speaker file against the full audio. - Measure timed-utterance coverage per speaker.
- Sum across speakers and compare to the 63.5% global coverage.
If per-speaker coverage is dramatically better (e.g., 85-90%), per-speaker UTR is worth building. If it’s similar to 63.5%, the problem is within-speaker divergence and per-speaker UTR won’t help either.
This is a few hours of manual work, not an engineering project. It should
be done on at least 3-4 files: 2265_T4, 2420_T3 (72 &* markers),
2463_T2 (82 &* markers), and one Fridriksson-2 file from the no-align
list.
Fuzzing: alignment degradation curves
Programmatically generate variants of a known-good aligned CHAT file by applying controlled perturbations:
- Utterance reordering: Swap adjacent utterances, swap across speakers, move utterances to random positions.
&*injection: Insert synthetic&*markers at random positions.- Turn splitting: Split one utterance into two at a random word boundary.
- Turn merging: Merge two consecutive same-speaker utterances.
- Speaker reattribution: Change the speaker code on random utterances.
Run align on each variant and measure coverage. Plot coverage as a
function of perturbation intensity. This gives a degradation curve that
tells us:
- How robust the current aligner is to each type of perturbation.
- Which perturbation types cause the steepest degradation.
- Whether backbone extraction or per-speaker UTR flattens the curve.
The fuzzer can reuse APROCSA files (which have audio) as base documents. Each variant preserves the same audio, so alignment infrastructure works unchanged.
Curated multi-version transcripts
The gold standard: multiple independent human transcriptions of the same
recording, each with different editorial choices about turn boundaries,
overlap annotation, and speaker attribution. Running align on all
versions measures how sensitive the pipeline is to transcriber style.
This is more expensive (requires human effort) but APROCSA may already have partially independent versions: the raw ASR output, a user’s review, and potentially a second reviewer’s version.
Approach 1: Backchannel-Aware Backbone Extraction
What it does
Before building the UTR reference sequence, strip all &*-embedded segments
from each utterance. The stripped words are recorded for later interpolation
but excluded from the DP alignment.
Original utterance:
*PAR: I'm hoping to play here &*INV:yeah in a month or two .
Backbone for DP:
I'm hoping to play here in a month or two
Stripped (recorded, not matched):
yeah (from &*INV, position: after "here", utterance index: 37)
The stripping operates at the AST level using the existing &* structure in
UtteranceContent::OverlappingSpeech. No text parsing or regex is needed.
After backbone alignment, stripped segments get timing via bracketed interpolation (from neighboring backbone words) or optional ASR cross-reference.
What it fixes
- Eliminates
&*words as a source of DP desync. - For files where
&*is the primary divergence (moderate overlap, no heavy restructuring), this may recover most or all untimed utterances. - For short isolated backchannels (“mhm”, “yeah”), prevents a single misplaced word from cascading into a multi-utterance sync loss.
What it does not fix
- Does not help when transcript restructuring (split/merge/reattribute) is
the dominant divergence. In the 2265_T4 case, the backbone word sequences
still diverge substantially from fresh ASR because a user reorganized turns,
not just because of
&*markers. - Even after stripping, the ASR output still contains the
&*speakers’ words as unmatched noise. For isolated cases the DP skips them as insertions. For dense overlap (30% of utterances with&*, many multi-&*utterances) the cumulative unmatched ASR words shift the alignment cost landscape enough to cause sync loss in the worst regions.
Impact estimates (unvalidated)
These are architectural guesses. The simulation experiment above must validate them before we rely on them.
| File type | Current untimed | After backbone extraction |
|---|---|---|
Moderate &*, no restructuring | 5-15% | ~0-5% (good recovery) |
Dense &*, no restructuring | 15-25% | ~5-15% (meaningful improvement) |
Dense &* + heavy restructuring (2265_T4 class) | 35%+ | ~25-30% (modest improvement) |
No &* markers | 0-5% | Identical (no-op) |
Implementation
The change is entirely within UTR
(crates/batchalign/src/chat_ops/fa/utr.rs) and its caller in
crates/batchalign/src/chat_ops/fa/orchestrate.rs. No FA changes
are needed.
#![allow(unused)]
fn main() {
/// A word excluded from the DP reference sequence because it belongs
/// to an &* overlapping speech segment.
struct StrippedOverlapWord {
/// Index of the utterance this word belongs to.
utterance_idx: usize,
/// Position within the utterance's word list (so we can reinsert timing).
word_position: usize,
/// The word text (for optional ASR cross-reference).
text: String,
/// Speaker of the &* segment (e.g., "INV").
overlap_speaker: String,
}
/// Result of the stripping phase.
struct BackboneExtraction {
/// Backbone words in text order (input to DP).
backbone_words: Vec<String>,
/// Mapping from backbone word index to (utterance_idx, word_position).
backbone_provenance: Vec<(usize, usize)>,
/// Stripped overlap words, for post-DP interpolation.
stripped: Vec<StrippedOverlapWord>,
}
}
The content walker (for_each_leaf with domain None) already traverses
OverlappingSpeech nodes. Stripping is a read-only partition, the AST is
not modified.
Interpolation for stripped segments:
start = backbone_word[i].end_ms
end = backbone_word[i+1].start_ms
If the gap is large enough (>100ms) and the ASR cross-reference finds a match within that window, the ASR timestamp is used instead. ASR cross-reference is optional and can be deferred.
Backward-compatible: files without &* markers produce identical backbone
sequences and behave identically to today.
Approach 2: Per-Speaker UTR
What it does
Instead of aligning one interleaved reference sequence against one interleaved ASR sequence, run ASR separately per speaker channel (using diarization boundaries) and match each speaker’s utterances against only that speaker’s ASR stream.
A full per-speaker UTR implementation is not currently shipped.
Why this should be the correct solution
The fundamental problem is that the DP aligns two interleaved multi-speaker sequences. When the interleaving order differs between transcript and ASR (which is guaranteed for restructured transcripts), a monotonic aligner cannot represent the crossing.
Per-speaker alignment eliminates the interleaving entirely. Each speaker’s words are in temporal order within their own stream. There is no crossing to represent.
However: we have not validated that within-speaker word order is preserved
after transcript restructuring. If a user reordered PAR’s utterances
(not just added &* markers), even per-speaker DP would see divergence.
The simulation experiment must answer this question.
Impact estimates (unvalidated)
| File type | Current untimed | After per-speaker UTR |
|---|---|---|
Moderate &*, no restructuring | 5-15% | ~0-2% |
Dense &*, no restructuring | 15-25% | ~0-5% |
Dense &* + heavy restructuring (2265_T4 class) | 35%+ | ~5-10% (if within-speaker order is preserved) |
No &* markers | 0-5% | ~0-2% (slight improvement from cleaner per-speaker matching) |
The remaining untimed percentage comes from cases where diarization boundaries are wrong (speaker confusion) or where the ASR itself fails (very short utterances, heavy noise). These estimates assume that within-speaker word order matches audio order, which is unverified.
What it does not fix
- Files where diarization is unreliable (very similar voices, heavy cross-talk where speakers talk simultaneously for extended periods).
- Files where the transcript attributes speech to speakers the diarization model cannot distinguish.
- Within-speaker reordering (if it exists in real transcripts).
- The cost: N speaker channels x ASR calls instead of 1. For a 4-speaker file this is roughly 4x the ASR compute. Caching mitigates repeat runs.
Recommendation
What shipped (two-pass + fuzzy) is the low-hanging fruit. Further improvements require empirical validation before building.
The two-pass overlap strategy and fuzzy matching are already live (see
Shipped Features above). The
remaining proposals below address the harder cases, dense &* markers
and heavy transcript restructuring, where two-pass alone is insufficient.
Step 0: Simulation experiment (before any further code)
Run the per-speaker coverage simulation on 4+ files. This takes hours, not days, and tells us whether per-speaker UTR is worth the ~500-800 line investment. Also characterize the 249 Fridriksson-2 files on the no-align list, they may have entirely different failure patterns.
Step 1: Backbone extraction (only if simulation shows &* matters)
If the simulation shows that &* stripping alone improves coverage on some
file class, build it. ~100-150 lines of Rust, zero regression risk.
Step 2: Per-speaker UTR (only if simulation shows per-speaker helps)
If per-speaker single-speaker files show dramatically better coverage than
the global run, build the full per-speaker UTR pipeline. Gate behind
--per-speaker-utr flag initially.
Step 3: Fuzzing harness (regardless)
Build the alignment fuzzer to generate degradation curves. This is valuable independent of which approach we build, it gives us a regression test suite for any future alignment changes, and it characterizes the robustness of the current system. The fuzzer can be reused to validate any future algorithm changes.
What we should not do
-
Non-monotonic local alignment. Custom alignment algorithms are research projects, not engineering tasks. The benefit over per-speaker UTR is marginal and the implementation/testing cost is much higher.
-
Accept non-monotonic bullets. Relaxing E362 would require changes to CLAN’s player and every downstream tool that assumes monotonic seeking. The ecosystem cost is prohibitive.
-
Build without measuring. The estimates in this document are guesses. Shipping code based on architectural reasoning alone risks building something that doesn’t actually help the files users complain about.
-
Nothing. The current behavior is defensible (no corruption, only coverage loss) but the user experience is poor for overlap-heavy files. Users who invest hours hand-editing a transcript and then lose a third of their timing have a legitimate complaint. Even small improvements in coverage save real human time on the last mile of manual correction.
Relationship to other approaches
-
Trouble-window alignment (a complementary unimplemented design): trouble-window would address the re-run workflow (preserve existing timing, realign only changed regions). These proposals address initial alignment quality for overlap-heavy files; both can coexist.
-
Per-speaker UTR (the unimplemented detailed plan for Approach 2): backbone extraction composes with it, strip
&*from per-speaker references. -
align --before: Already implemented. Preserves timing for unmodified utterances during re-runs. Helps with the re-edit workflow but does not improve initial alignment of a restructured transcript.
Test Strategy
-
Simulation first: Per-speaker coverage on real files, before any production code.
-
Fuzzing: Alignment degradation curves on controlled perturbations. Reusable as a regression suite for any future alignment changes.
-
Unit tests: Synthetic CHAT files with known
&*patterns, verifying backbone extraction and interpolation. -
Golden tests: Run on existing golden test files and verify no regression. Update golden expectations where coverage improves.
-
Corpus-level validation: Run on APROCSA and other overlap-heavy corpora from aphasia-data. Report timed-utterance percentage before and after, broken down by file overlap density. No hand-waving: if the numbers don’t improve meaningfully, document that honestly and reconsider the approach.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Worker Protocol V2
Status: Current Last verified: 2026-08-31 07:13 EDT Last updated: 2026-09-16 04:34 EDT
Speaker attribution (2026-09-16). AsrMonologueV2.speaker is a tagged
SpeakerAttribution rather than a bare string, and ProviderMediaInput
carries a typed ProviderDiarization rather than a bare num_speakers. The
pair exists so the bridge can tell two absences apart: a provider that named no
speaker because its engine separates nobody (admitted as undiarized) from one
that named none although the request asked it to separate (refused by name).
A bare string could express neither, so every undiarized engine wrote "0",
which downstream became a PAR0 tier indistinguishable from a real first
speaker. A diarization count of one has no representation anywhere in this
protocol, and that is now a property of the types rather than of one caller’s
discipline: ProviderDiarization::Integrated holds a SeparatedSpeakers, which
is at least two by construction and refuses less both through its only
constructor and on deserialization, so such a count can be neither built nor
received. Submission still refuses it first, with the message that explains the
contradiction to an operator.
The same tagged value crosses into Python. The bridge builds the provider’s
AsrBatchItem with a diarization field carrying ProviderDiarization
verbatim, serialized by the derive this document’s schema is generated from and
parsed once by the Pydantic model. Until 2026-09-16 it flattened to an integer
kwarg whose ZERO meant “do not separate”, while the Python field defaulted to 1,
so one closed union had two representations and absence had two spellings, one
of them the contradiction above.
This document is the implementation spec for the live typed worker boundary
currently named worker_v2.
See also: INTERFACE_MAP.md section “1. Worker Protocol Dispatch” for the unified reference to all protocol-related files, Python implementations, and shared schema definitions.
The v2 suffix is still intentional. The older JSON-lines worker /
batchalign/worker/_types.py surface remains in-tree as a frozen compatibility
contract, so the Rust module (crates/batchalign-types/src/worker_v2/), schema
directory (ipc-schema/worker_v2/) and hand-written models
(batchalign/worker/_types_v2.py) stay versioned together until V1 is removed
as a whole.
Startup identity handshake
The JSON ready envelope precedes V2 task dispatch and is a separate startup
control boundary. Current stdio workers emit ready, pid, transport, and a
schema-1 runtime object. Runtime evidence contains Python version plus full
SHA-256 identities for the resolved interpreter, installed batchalign
package tree, the exact loaded batchalign_core native extension, and sorted
distribution inventory. It deliberately contains no local paths. The native
extension digest is required because a distribution version and Python-source
tree cannot distinguish two locally built .so or .pyd files containing
different Rust code.
Rust admits this through WorkerRuntimeIdentity; malformed schema revisions,
empty Python versions, shortened digests, uppercase digests, and non-hex text
cannot construct the typed identity. ReadySignal.runtime is required: a
current stdio worker without content identity cannot cross the ready boundary.
The handle and pool retain the identity for /health even while the worker is
busy or after it exits. The first identity pins the server process. Any later
worker whose identity differs is refused before job admission, so a health
receipt identifies all local stdio work from that server rather than merely
listing several possible producers. Synthetic worker fixtures must emit a
valid identity instead of weakening the production schema for test
convenience.
flowchart LR
P["Python computes package<br/>and source-tree digests"] --> J["Ready JSON with RuntimeIdentityV2"]
J --> V{"Rust validates closed schema<br/>and digest syntax"}
V -->|"invalid"| R["Refuse worker startup"]
V -->|"valid"| I["WorkerRuntimeIdentity"]
I --> S{"Server identity state"}
S -->|"unbound"| B["Pin first admitted identity"]
S -->|"same identity"| A["Admit replacement worker"]
S -->|"different identity"| X["Refuse mixed runtime"]
B --> H["Expose pinned receipt at /health"]
A --> H
The relevant files are batchalign/worker/_runtime_identity.py,
batchalign/worker/_protocol.py,
crates/batchalign/src/worker/runtime_identity.rs, and
crates/batchalign/src/worker/handle/protocol.rs.
The Python cutover is complete. Audio tasks and text-only NLP tasks
(morphotag, utseg, translate, coref) now use typed V2 requests. Text
commands preserve cross-file batching by freezing one prepared-text artifact per
miss batch and sending one batched execute_v2 request per task.
Utseg item results may carry boundary_model_evidence. Its model ID is
nonempty, its model revision is REQUIRED and is a hub commit rather than free
text, so a worker that cannot say which revision it loaded refuses instead of
reporting one, and its word_evidence vector is parallel to the request words. Each word is a
discriminated classified, normalization-omission, or model-short-circuit
state. Classified words carry closed raw/applied action enums and a validated
integer probability in the inclusive range 0 through 1,000,000. Rust
revalidates the complete shape against the dispatched request before an
applicable segmentation response can exist; schema validity alone is not
sufficient admission.
Speaker results use a discriminated SpeakerInferenceEvidenceV2 union:
pyannote_aicarries the completed provider job ID, output object, and optional warning before normalization;pyannotecarries local Pyannote segments; andnemocarries local NeMo segments.
The Rust FFI checks that the response variant matches the backend selected by the request. Rust then owns versioned normalization and the separate raw and derived cache envelopes. This boundary is intentional: Python retains only irreducible runtime/provider behavior, while a future normalizer revision can replay paid provider evidence without another remote call.
The goal is simple:
- Python should be only a thin model host.
- Rust should own preprocessing, postprocessing, caching, incremental logic, document mutation, and workflow orchestration.
That requires replacing the current worker protocol, not just trimming more helper code.
Why V1 Is No Longer Enough
The current worker protocol in:
types/worker.rs(re-exports frombatchalign-types/src/worker.rs)\_types.py\_protocol.py\_protocol_ops.py
is a good fit for small JSON payloads. It is not a good fit for the target architecture where Rust should own local audio preparation and Python should receive only model-ready inputs.
Current V1 problems:
- it is JSON-lines over stdio, which is convenient but weak for large and structured binary-heavy inputs
- local-model tasks still encourage Python-side audio loading/chunking because the easiest payload is a file path
- request shapes are task-level (
InferTask) but not engine-input-level - the direct Python pipeline path still exists as a separate conceptual surface
- the control plane and data plane are conflated
The redesign should therefore replace:
- the old process-path worker orchestration model that used to be primary
- JSON-shaped local-model payloads as the canonical boundary
- Python-owned preprocessing
Design Principles
- Rust owns all workflow semantics.
- Python owns only irreducible runtime/model semantics.
- Large binary inputs must not ride inside generic JSON request bodies.
- The protocol should describe model-ready inputs, not CLI commands.
- Cache formats may change freely; old cache compatibility is not required.
- The legacy config file remains important.
Provider credentials in
~/.batchalign.inistill matter until specific providers move fully into Rust.
Target Shape
flowchart LR
rust["Rust control plane"]
prep["Rust media and text prep"]
cp["Control plane\nMessagePack envelopes"]
dp["Data plane\nprepared artifact refs"]
py["Python worker"]
sdk["Model runtime or SDK"]
raw["Raw model output"]
post["Rust postprocess and CHAT ops"]
rust --> prep --> cp
prep --> dp
cp --> py
dp --> py
py --> sdk --> raw
raw --> cp --> post
The boundary becomes two-plane:
- control plane: typed envelopes over stdio
- data plane: explicit references to prepared artifacts owned by Rust
Protocol V2 Overview
Transport
Control-plane transport:
- length-prefixed MessagePack frames over stdio
- one request, one response, no JSON-lines framing
- request/response/event envelopes carry a stable
request_id
Data-plane transport:
- explicit artifact descriptors
- first implementation: file-backed prepared artifacts in a per-worker temp directory, designed so Rust can later switch specific paths to shared memory without changing the logical request schema
This keeps the first migration practical and cross-platform while still stopping the abuse of JSON payloads for local-model inputs.
Phase 1 Status
The staged schema and drift-test guardrails now exist:
- Rust schema:
crates/batchalign-types/src/worker_v2/(re-exported bycrates/batchalign/src/types/worker_v2.rs) - Python schema:
batchalign/worker/_types_v2.py - shared fixtures:
tests/fixtures/worker_protocol_v2/ - Rust drift test:
crates/batchalign/tests/worker_protocol_v2_compat.rs - Python drift test:
batchalign/tests/test_worker_protocol_v2_types.py
Those fixtures are still JSON because the current task is schema drift prevention, not transport rollout. The production transport can move to MessagePack framing later without losing the shared logical contract.
Envelope Types
Boot and Metadata
HelloRequest
{
protocol_version: 2,
worker_kind: "infer"
}
HelloResponse
{
protocol_version: 2,
worker_pid: u32,
runtime: {
python_version: string,
free_threaded: bool
}
}
There is no V2 capabilities exchange. A worker’s capability report
(infer_tasks, and engine_versions, which names only the FA engine) travels
over the older capabilities control op (see “Control channel” below, and
Capability Discovery).
V2 capabilities request, response and per-task types were once staged here,
but nothing used them beyond the schema generator and the compatibility tests,
so they were deleted from Rust, Python, the shared fixtures and ipc-schema.
Execution
ExecuteRequest
{
request_id: string,
task: InferenceTask,
payload: TaskRequest,
attachments: [ArtifactRef]
}
ExecuteResponse
{
request_id: string,
outcome: Success | Error,
result: TaskResult | null,
elapsed_s: float
}
ProgressEvent
{
request_id: string,
completed: u32,
total: u32,
stage: string
}
ShutdownRequest
{
request_id: string
}
Error Contract
Worker protocol errors should be typed, not free-form strings:
ProtocolErrorCode =
unsupported_protocol
invalid_payload
missing_attachment
attachment_unreadable
model_unavailable
runtime_failure
Domain/model errors can still carry human-readable detail, but the top-level category should be machine-stable.
Current classification on the live execute_v2 path is intentionally split:
invalid_payloadmeans the request or prepared artifact metadata was invalid before the model host could do useful workmissing_attachment/attachment_unreadablemean the referenced prepared artifact was absent or unreadablemodel_unavailablemeans the selected backend is not loaded in this workerruntime_failuremeans the model host accepted the request but then crashed or returned malformed result data (for example non-finite metrics, reversed timing ranges, wrong per-item result shapes, or host/result count drift)
Artifact References
The data plane is the critical change.
ArtifactRef should be a tagged union:
ArtifactRef =
PreparedAudioRef
PreparedTextRef
InlineJsonRef
PreparedAudioRef
This is the main local-model boundary.
PreparedAudioRef {
id: string,
kind: "prepared_audio",
path: string,
encoding: "pcm_f32le",
channels: 1,
sample_rate_hz: u32,
frame_count: u64,
byte_offset: u64,
byte_len: u64
}
Rules:
- Rust creates the artifact.
- Rust owns decode, mixdown, resample, chunk extraction, and hashing.
- Python only memory-maps or reads the prepared PCM window.
- Python must treat the descriptor as immutable input.
PreparedTextRef
Used when the request needs large normalized text or token arrays without stuffing them into the main envelope.
PreparedTextRef {
id: string,
kind: "prepared_text",
path: string,
encoding: "utf8_json",
byte_offset: u64,
byte_len: u64
}
InlineJsonRef
Kept only for small structured payloads.
InlineJsonRef {
id: string,
kind: "inline_json",
value: object
}
Task Model
The current InferTask enum is too coarse for the next boundary. V2 should
keep a top-level task enum, but the request/response shape should be explicit
per task family.
Task Families
InferenceTask =
Morphosyntax
Utseg
Translate
Coref
Asr
ForcedAlignment
Speaker
Opensmile
Avqi
Removed 2026-04-26:
ExpandNumbers. Number expansion was migrated entirely into Rust (asr_postprocess::expand_number+ordinal_year_eng); the IPC typesExpandNumbersRequestV2,ExpandNumbersResultV2,NumberExpansionModeV2, and theInferenceTaskV2::ExpandNumbersenum variant no longer exist. See Number Expansion.
ASR
Request
AsrRequest {
lang: iso639_3,
backend: AsrBackend,
input: AsrInput,
models: AsrRequestedModels,
decode_budget_seconds: f64 | null
}
models is the composition the control plane pinned for this request: one
required entry per role the engine needs, so a Qwen request cannot omit its
forced aligner and a Paraformer request cannot omit its voice-activity and
punctuation models. Each entry is an id plus a requested revision, which is an
exact commit, a published tag (ModelScope exposes no commit behind one), a
provider parameter, or explicitly unpinned for a checkpoint this build does
not pin. unpinned is a real state rather than a gap: the worker must then
report the commit it loaded, and such a composition can never build a cache
key.
The worker loads exactly these and reports back what it observed. The pinned composition also reaches the worker on the spawn argv, not only here, because loading happens once per worker while requests arrive many times; the argv carries it because it is a pure function of the worker key’s own target, language and engine overrides, so injecting it there keeps the capability key and the execute key equal by construction instead of by coincidence.
decode_budget_seconds, added 2026-09-02, is the request’s own wall-clock
decode budget, derived once by Rust from the audio’s duration and a named
realtime factor (DecodeBudgetSeconds,
crates/batchalign-types/src/worker_v2/requests.rs). null means Rust could
not derive one for this request (a ProviderMediaInput whose duration could
not be probed); the receiving engine then derives its own, exactly the
pre-existing fallback. Two consumers read it from one value rather than
computing two independent numbers that can drift apart: Python’s native
Qwen3-ASR decode loop (_qwen_chunking.DecodeBudget) bounds decode time with
it, and Rust’s own worker-transport read timeout
(TaskRequestV2::timeout_seconds_with_config) is the same value plus a fixed
margin, so the transport ceiling can never be shorter than the budget it just
sent.
AsrBackend =
local_whisper
hk_tencent
hk_aliyun
hk_funaudio
revai
AsrInput =
PreparedAudioInput { audio_ref_id: string }
ProviderMediaInput { media_path: string, diarization: ProviderDiarization }
ProviderDiarization =
| { kind: "not_requested" } // one track; do not separate
| { kind: "integrated", speakers: u32 >= 2 } // separate into exactly this many
// One monologue's speaker, on the result side:
SpeakerAttribution =
| { kind: "attributed", label: string } // the provider's OWN label
| { kind: "undiarized" } // this engine separates nobody
SubmittedJobInput { provider_job_id: string }
Rules:
local_whispermust usePreparedAudioInput- cloud providers may keep
ProviderMediaInputtemporarily if Rust has not replaced their transport yet revaiis expected to stay Rust-owned in production and should eventually disappear from the Python protocol
Result
Python should return only raw provider/model output, not shared normalized ASR.
AsrResult =
WhisperChunkResult
HkMonologueResult
ProviderTranscriptResult
Every ASR result carries a required model: the composition the worker
actually loaded, each member with the revision it was observed at. It is
required rather than optional because a transcript whose models are unknown
cannot be stamped honestly or cached safely, and an optional field would let a
producer forget while still compiling.
Requested and observed are kept apart on purpose. An exact commit must come back as that same commit; a tag may come back unexposed, because ModelScope publishes no commit behind one; a provider parameter can only come back unexposed, because a cloud service reports nothing about what it ran; and an unpinned model must come back with a commit, which is the entire point of leaving it unpinned rather than refusing the job. The bridge admits the reported composition against the request’s pin and refuses a disagreement by name, and for provider backends it does so before calling the provider, so a mismatch costs no paid request. A worker that recorded no identity at all is refused per engine; an identity is never inferred from the request, because that would record what was asked for as though it had been seen.
Rust remains responsible for:
- shared normalization
- timestamp harmonization
- Cantonese postprocessing
- utterance segmentation
- CHAT generation
Forced Alignment
This is the first major migration target because it still depends on Python-side audio chunk loading.
Request
ForcedAlignmentRequest {
backend: FaBackend,
payload_ref_id: string,
audio_ref_id: string,
text_mode: "space_joined" | "char_joined",
pauses: bool
}
Rules:
- Rust writes the word arrays into a prepared JSON payload artifact
- Rust prepares the audio span before the request
- Python receives only model-ready PCM plus token text
- worker code should not call
load_audio_file()for FA
Result
ForcedAlignmentResult =
WhisperTokenTimingResult
IndexedWordTimingResult
Rust still owns:
- token-to-word reconciliation
- retry/fallback policy
- injection into CHAT
- incremental eligibility
Speaker
SpeakerRequest {
backend: SpeakerBackend,
input: SpeakerInput,
expected_speakers: u16 | null
}
SpeakerInput =
PreparedAudioInput { audio_ref_id: string }
Current implementation status:
batchalign/worker/_speaker_v2.pynow executes live speaker requests throughexecute_v2(task="speaker")using Rust-prepared audio attachments onlycrates/batchalign/src/worker/speaker_request_v2.rsnow builds typed speaker requests on the Rust side from prepared audio artifacts- speaker results now round-trip as typed raw segment payloads rather than generic JSON bags
- the old legacy
batch_infer(task="speaker")route is no longer part of the live worker dispatch table speakerremains a low-level infer-task name rather than a CLI command; both integratedtranscribe_sand standalonediarizecompose this taskopensmileandavqinow use the sameexecute_v2(...)envelope family with Rust-owned prepared-audio attachments and dedicated Rust request builders
Morphosyntax, Utseg, Translate, Coref
These tasks now share one batched text-V2 pattern:
- Rust normalizes the whole cross-file miss set into one
PreparedTextRef - the request payload carries
payload_ref_idplusitem_count - Python reads the artifact, runs the model batch, and returns one typed batched
result whose morphosyntax, translate and coref items are tagged outcomes
(
kind), each carrying only what that outcome needs (table below) - the PyO3 bridge (
worker_text_results.rs::normalize_item) parses each host item through the Rust wire type. A host error wins, and an item that does not parse becomes that item’sfailedoutcome, so one bad item never fails the rest of the batch; a count mismatch still refuses the whole batch - Rust keeps preprocessing, postprocessing, caching, repartitioning, and CHAT mutation. Provenance names the identities on the results a file applied. The identity travels per item, not in a capability snapshot, because the process that ran the item is the only honest witness
| Task | Outcomes |
|---|---|
| morphosyntax | analyzed (raw_sentences, the model that produced them, and the repairs it made to their UD relations), no_words (no identity, nothing repaired), failed (error) |
| translate | translated (raw_translation plus engine), blank_input (no identity), failed (error) |
| coref | resolved (annotations plus engine), no_sentences (no identity), failed (error) |
model (MorphosyntaxModelIdentityV2) names the Stanza version, the language
and the pipeline variant that ran: standard, mandarin_retokenize, or
cantonese_pycantonese_pos. engine and stanza_version are
ReportedEngineName values (non-blank, no surrounding whitespace, none of |,
;, ] or a line break). An item where no model ran carries no identity
rather than an invented one.
InlineJsonRef still exists for small metadata payloads, but the live text NLP
tasks no longer use inline JSON as their primary boundary.
speaker_embedding: many spans of ONE prepared decode
Added 2026-09-02. It is the first task whose request names several regions of a single attachment rather than one region per attachment, so it is worth reading before adding another of that shape.
SpeakerEmbeddingRequestV2 carries one audio_ref_id, naming the whole
prepared mono PCM view of a recording, plus a list of SpeakerEmbeddingSpanV2
entries. Each span is { span_id, start_frame, end_frame }. The response,
SpeakerEmbeddingResultV2, echoes every span_id with one outcome:
embedded carrying a vector, or too_short carrying the frame count that
fell short.
Four decisions in that shape, each with a reason:
- One attachment, many spans, not one attachment per span. Vectors are only comparable when they come from the same decode: two embeddings computed from separately decoded files can differ for reasons that have nothing to do with who was speaking. Sharing one decode also turns N ffmpeg invocations into one.
- Frames, not milliseconds. The prepared PCM view is the only coordinate
system the worker holds. A request in milliseconds would make the worker
re-derive a frame index from a sample rate it was told about separately, which
is the same number arriving by two routes. The single conversion lives on the
Rust side, in
chat_ops::speaker_identity::frames::PreparedPcm::locate, which is also the only place a span can be found to fall outside the recording. span_idis echoed, so nothing pairs by position. Two parallel sequences held together by index order is the shape that attributes one speaker’s acoustic evidence to another utterance, and an arity check does not catch a reordering. The PyO3 executor compares the requested and answered id SETS and refuses any disagreement.too_shortis a variant, not an empty or zero vector. Below its own minimum input length the pinned model returns a correctly shaped float array whose every component is NaN. Nothing downstream can tell that from a real embedding by inspecting its type, and a NaN compares false against every threshold, so it would read as a considered “not this speaker” rather than “not measurable”. The worker checks the model’s reportedmin_num_samplesand refuses; the RustSpeakerEmbeddingconstructor refuses a non-finite component again, in a different process and a different language.
dimension and minimum_frames are reported by the worker on every response
rather than assumed by the reader, because both are properties of the loaded
model file. A constant on the Rust side would be a second place the truth lives
and would go on agreeing with a model that had moved.
Worker-pool routing reuses InferTask::Speaker. A pool key names which
model host a request needs, and both tasks are served by the speaker host.
Splitting them would spawn a second process to hold a model the first could
have loaded. What makes that safe is that neither model is loaded at bootstrap:
a worker that only ever embeds never constructs the diarization pipeline, and
therefore never reaches the gated calibration artifact that pipeline pulls in.
Model access. Standalone embedding loads only the embedding node of the
pinned local model graph, through the same pinned-artifact loader, so it needs
no Hugging Face credentials at all. The gated-repository reclassification is
still wired (classify_runner_error, ModelAccessDeniedError) because a
future pin or a private mirror could reintroduce one.
The measured hazard, stated as a measurement. On the pinned model
(hbredin/wespeaker-voxceleb-resnet34-LM, the manifest’s embedding commit),
min_num_samples is 1680 frames, which is 105 ms at 16 kHz. Handed 1679
frames it returns a (1, 256) float32 array whose every component is NaN, and
raises nothing; handed 1680 it returns a finite vector. Measured 2026-09-02 by
running the loaded model directly at 1679, 1680, 4000 and 16000 frames. That
is why the outcome is a variant rather than a vector, why the Python host
checks the length before calling the model, and why SpeakerEmbedding refuses
a non-finite component again on the Rust side.
The consumer. speaker-identify (crates/batchalign/src/chat_ops/ speaker_identity/ for the decision graph,
crates/batchalign/src/runner/dispatch/speaker_identity_pipeline.rs for
dispatch). It decodes each recording ONCE and locates every enrollment span
and every utterance in that one decode: vectors from separately decoded files
can differ for reasons that have nothing to do with who was speaking.
Why File-Backed Prepared Artifacts First
The ideal long-term design may use shared memory for the hottest local-model paths. The first implementation should still use file-backed prepared artifacts because:
- it is cross-platform
- it is easy to inspect and debug
- it keeps the protocol migration tractable
- it still removes Python-owned audio decoding and chunking
Once that design is stable, specific high-throughput paths can swap
PreparedAudioRef.path to a shared-memory handle model without changing task
semantics.
What Gets Deleted
The redesign is intentionally not compatibility-first.
The following surfaces should be treated as disposable:
- the current JSON-lines worker framing
- process-path worker orchestration as an architectural center
- Python-side local-model audio preparation
- the old Python-owned pipeline orchestration that used to live in
pipeline_api.py - old cache formats that depend on Python-shaped intermediate results
Incremental Processing Impact
Incremental processing does not justify a wider Python boundary.
Incremental logic remains Rust-owned:
- cache-key computation
- change detection
- reusable chunk selection
- per-command invalidation rules
- partial reinjection
Python workers should see only the final narrowed subset that Rust chooses to recompute.
Cross-crate implications
This redesign may justify additional Rust-side changes outside the worker-pyo3 crate layout. Likely implications:
- the
batchaligncrate will want a dedicated prepared-artifact subsystem rather than ad-hoc temp-file helpers - the
batchaligncrate may gain more raw provider normalization helpers - the
talkbank-*core crates (model / parser / transform) may need ergonomic Rust-side hooks ifbatchalignbenefits from shared document or audio-domain utilities there
No current internal API should be treated as fixed.
Rollout Plan
Phase 1. Protocol spec and fixtures
Deliverables:
- this document
- canonical request/response examples
- Rust and Python golden fixtures for V2 envelopes
- drift tests that compare fixture decoding on both sides
Status:
- implemented
Phase 2. Rust prepared-artifact subsystem
Deliverables:
- file-backed prepared-audio writer/reader
- lifecycle cleanup policy
- worker-facing descriptor types
Current implementation status:
- the first file-backed store exists in
crates/batchalign/src/worker/artifacts_v2.rs - it can write prepared PCM audio descriptors, prepared text descriptors, and inline JSON attachments
- production dispatch does not use it yet
Phase 3. Forced alignment migration
Deliverables:
- FA requests use
PreparedAudioRef - FA requests use a Rust-owned prepared text payload artifact
- Python FA no longer loads audio from file paths
- Rust owns all chunk extraction before worker dispatch
Current implementation status:
crates/batchalign/src/fa/transport.rsnow narrows full-file and incremental FA orchestration behind a shared transport adapter instead of letting each path assemble legacybatch_infer(task="fa")payloads inlinecrates/batchalign/src/worker/request_builder_v2.rsnow builds staged V2 FA requests from existingFaInferItemvalues- that builder writes the transcript arrays into a prepared text artifact
- that builder extracts the model-ready PCM window into a prepared audio artifact
batchalign/worker/_artifact_inputs_v2.pynow provides the thin Python wrapper over Rust-owned prepared text JSON and prepared PCM audio readerscrates/batchalign-pyo3/src/worker_fa_exec.rsnow owns the live V2 FA executor control plane, whilebatchalign/worker/_fa_v2.pystays as a thin Python host wrappercrates/batchalign/src/worker/fa_result_v2.rsnow maps those typed V2 FA results straight back into the established Rust FA alignment domaincrates/batchalign/tests/worker_v2_fa_roundtrip.rsnow proves the staged Rust request builder, Rust-owned FA executor control plane, and staged Rust result adapter already form one coherent cross-language seambatchalign/worker/_execute_v2.pynow exposes a liveexecute_v2stdio handler that routes forced-alignment requests into the typed V2 executorcrates/batchalign/src/worker/handle/(mod.rs + spawn.rs + ipc.rs + lifecycle.rs + protocol.rs) andcrates/batchalign/src/worker/pool/mod.rsnow carry thatexecute_v2op across the long-lived worker process boundary- live full-file FA and incremental FA now use the V2 execute path in production, with the legacy V1 transport retained only as a narrow fallback seam
Phase 4. ASR migration
Deliverables:
- local Whisper requests use
PreparedAudioRef - Cantonese provider ASR requests use typed
provider_mediainputs instead of the legacy batch-infer bag - no Python-side local audio loading/chunking
- shared raw-result schema remains Rust-normalized after return
Current implementation status:
crates/batchalign/src/worker/asr_request_v2.rsnow builds typed ASR V2 requests with either Rust-owned prepared full-file audio artifacts or typed provider-media inputscrates/batchalign-pyo3/src/worker_asr_exec.rsnow owns the live V2 ASR executor control plane, whilebatchalign/worker/_asr_v2.pystays as a thin Python host wrapper for local Whisper, Tencent, Aliyun, and FunASRcrates/batchalign/src/worker/asr_result_v2.rsnow maps typed V2 ASR responses back into the established RustAsrResponsedomainbatchalign/worker/_execute_v2.pynow routes all live Python-hosted ASR through theexecute_v2stdio boundarycrates/batchalign/src/transcribe/(directory module) now uses that live V2 path for all Python-hosted ASR; only Rev.AI bypasses Python and stays Rust-owned
Phase 5. Speaker migration
Deliverables:
- complete the move from transitional media-path input to prepared audio once the speaker backends can consume Rust-prepared artifacts directly
- remove or isolate remaining global runtime overrides
Phase 6. Remove legacy orchestration
Deliverables:
- keep the released worker surface infer/execute-only; no generic process-path runtime remains
- do not regrow a Python-side document-orchestration layer; the previous
pipeline_api.pyfacade was removed and stays removed - remove V1 worker protocol once all production tasks are migrated
Acceptance Criteria
The redesign is successful when all of the following are true:
- Python no longer decodes, resamples, or chunks local audio for ASR or FA
- Python no longer owns shared result normalization or document-facing shaping
- the worker boundary is typed per task family, not a generic JSON bag
- the remaining Python code is mostly model loading, runtime calls, and thin transport adapters
- old cache compatibility is not preserved just for its own sake
Immediate Next Implementation Step
The next concrete step after the live FA migration should be:
- move the remaining Cantonese provider ASR engines onto typed V2 request shapes instead of the legacy batch-infer bag
- stop sending one FA request per miss group once the typed path is stable; add a batched or multiplexed V2 execute shape if profiling shows the per-group roundtrips matter
- remove the legacy FA transport once the V2 path has soaked
Concurrent dispatch (GPU profile)
GPU profile workers support multiple in-flight V2 requests via request_id
multiplexing. This restores the model-sharing throughput of batchalign-next’s
ThreadPoolExecutor while keeping the subprocess boundary.
Python side
GPU workers run _serve_stdio_concurrent(max_threads=4) instead of the
sequential _serve_stdio(). The main thread reads stdin and submits each
request to a ThreadPoolExecutor. PyTorch releases the GIL during CUDA
kernels, enabling real concurrent GPU inference across threads sharing the
same loaded model weights.
# Simplified concurrent serving loop
pool = ThreadPoolExecutor(max_workers=4)
stdout_lock = threading.Lock()
for line in sys.stdin:
message = json.loads(line)
pool.submit(_handle_and_respond, message, stdout_lock)
Responses are written under a stdout lock so JSON lines never interleave.
Rust side
The SharedGpuWorker type (in
crates/batchalign/src/worker/pool/shared_gpu/, with the transport
split between stdio.rs and tcp.rs) replaces the exclusive
CheckedOutWorker for GPU profile dispatch:
flowchart LR
subgraph Rust
t1["Task 1: FA file A"]
t2["Task 2: FA file B"]
t3["Task 3: FA file C"]
sem["dispatch_semaphore\n(Semaphore K=gpu_thread_pool_size)"]
gpu["SharedGpuWorker"]
reader["Background reader"]
p1["pending: id=1 → oneshot"]
p2["pending: id=2 → oneshot"]
end
subgraph Python
stdin["stdin reader"]
pool["ThreadPoolExecutor\n(max_workers=K)"]
th1["thread 1"]
th2["thread 2"]
end
t1 -->|"acquire permit"| sem
t2 -->|"acquire permit"| sem
t3 -. "wait at gate\n(no timeout yet)" .-> sem
sem -->|"slot held → execute_v2"| gpu
gpu -->|"stdin mutex"| stdin
stdin --> pool
pool --> th1
pool --> th2
th1 -->|"response(id=2)"| reader
th2 -->|"response(id=1)"| reader
reader --> p1
reader --> p2
Key components:
dispatch_semaphore: Arc<Semaphore>(shared_gpu/stdio.rs,shared_gpu/tcp.rs), caps in-flightexecute_v2calls per worker atgpu_thread_pool_sizeso Rust dispatch concurrency matches Python’sThreadPoolExecutorcapacity. Permit is acquired beforepending.insert()and before thetokio::time::timeoutwrap, so a caller waiting for a slot does not consume its per-request timeout budget on queue-wait. Without this gate, late callers would register pending oneshots and start their timers while their request sat in the Python executor’s queue, and the timer would expire ahead of the response, see “Why the dispatch semaphore exists” below.stdin: Mutex<ChildStdin>: serialized writes so JSON lines don’t interleavepending: Mutex<HashMap<String, oneshot::Sender>>: maps request_id to response channel- Background reader task: continuously reads stdout, parses responses, routes by request_id
- Control channel: sequential non-V2 ops (health, capabilities,
ensure_task, shutdown) via a separate oneshot. A dedicated control gate is held for the complete request/response round trip; locking only the oneshot slot would allow a concurrent caller to replace its recipient before the reader delivered the first response.
The dispatch semaphore contract
Architectural rule: Rust-side dispatch concurrency matches Python-side
serving capacity. Python serves V2 requests through a
ThreadPoolExecutor(max_workers=gpu_thread_pool_size). The Rust
dispatch_semaphore carries the same K = gpu_thread_pool_size permit
count, so at most K execute_v2 calls are in flight per worker on
either side. The two sides are kept in sync by a single config knob.
The permit is acquired before pending.insert() and before the
tokio::time::timeout wrap, so each caller’s timer ticks only during
the work that has been issued to the worker. A caller waiting for a
permit holds no timer.
Tuning rule by underlying device:
| Device | gpu_thread_pool_size | Why |
|---|---|---|
| CUDA / real GPU (releases GIL on native calls) | 2-4 | True parallel inference inside one Python process |
| Apple Silicon CPU (MPS excluded for batchalign3) | 1 | GIL-bound CPU Whisper inference; higher values cause core contention |
The regression test for the contract:
tests/gpu_concurrent_dispatch.rs::gpu_concurrent_dispatch_does_not_charge_queue_wait_against_per_request_timeout
uses test_delay_ms = 200, gpu_thread_pool_size = 1, and
audio_task_timeout_s = 1 to assert that N=8 concurrent callers all
succeed: each caller’s per-request budget governs work-time only,
never queue-wait.
Verified source files:
crates/batchalign/src/worker/pool/shared_gpu/stdio.rs,
crates/batchalign/src/worker/pool/shared_gpu/tcp.rs,
crates/batchalign/src/worker/tcp_handle.rs (carries
gpu_thread_pool_size on TcpWorkerInfo),
batchalign/worker/_protocol.py (_serve_stdio_concurrent).
Request/response correlation
ExecuteRequestV2.request_id and ExecuteResponseV2.request_id are the
multiplexing key. The background reader extracts the request_id from each
response and sends it to the matching pending oneshot channel.
Orphaned responses (the response arrives after the pending entry has been
removed) are logged at WARN level. The expected steady-state rate is
zero; the typical cause is a shutdown race where the worker drained a
request while the orchestrator tore down. A burst of orphaned-response
warnings during normal operation indicates the in-flight cap is wrong,
e.g., a gpu_thread_pool_size mismatch between Rust pool config and the
daemon’s spawn arguments.
Profile routing
WorkerPool::dispatch_execute_v2() checks WorkerProfile::is_concurrent():
- GPU profile →
dispatch_gpu_execute_v2()→SharedGpuWorker::execute_v2() - Stanza/IO profile →
checkout()→CheckedOutWorker::execute_v2()
Stanza and IO profiles keep the existing sequential checkout model.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Host Facts Pipeline
Status: Current Last updated: 2026-05-19 22:51 EDT
The host-facts pipeline is the four-layer architecture that resolves
operator overrides against detected host capabilities and warns when
they contradict each other. It exists so a single server.yaml can
deploy across heterogeneous fleet hosts (Apple Silicon dev machines,
Linux + CUDA GPU servers, no-GPU laptops) without per-host tuning;
each host’s resolved values come from the recommendation function
when the operator hasn’t overridden them.
For the operator surface, see doctor.
This architecture was motivated by a queue-wait incident on a
production-grade host.
The four layers
graph TD
A["Layer 1: HostFacts (snapshot)"] --> B
A --> D
C["Layer 1.5: ServerConfig (operator overrides, Option-typed)"] --> B
B["Layer 2: recommend_*(facts) -> RecommendedKnobs (pure)"] --> D
C --> D["Layer 3: EffectiveConfig::resolve(overrides, facts) -> resolved values"]
A --> E
C --> E["Layer 4: validate(cfg, facts) -> ConfigValidation"]
Layer 1: HostFacts
A snapshot of “what is this host?”. Populated once at server startup
by a HostFactsSource and held in AppState for the process
lifetime. Downstream layers consume this struct; nothing else in the
runtime polls the OS for facts that live here.
Production source: RealHostFactsSource (in host_facts/mod.rs)
performs sysinfo polls (RAM, CPU count), platform detection (OS,
arch via std::env::consts), and GPU detection (Apple Silicon
short-circuit on macOS+arm64, nvidia-smi subprocess on Linux).
Detection is millisecond-scale. The snapshot lives for the process
lifetime; runtime memory pressure is handled by
worker::memory_guard’s live polls, not by re-detecting facts.
Test source: MockHostFactsSource::new(facts) returns a
pre-constructed HostFacts for table-driven testing. Together with
the host_facts::test_helpers fixtures
(apple_silicon_64gb() and linux_cuda_24gb()), every layer can
be tested against synthetic host shapes without touching real
hardware.
Layer 2: recommend_* functions
Pure per-knob functions that derive a recommendation from &HostFacts.
Each lives in host_facts/recommendations.rs with table-driven unit
tests that pin the formula. The current per-knob recommenders:
| Function | Formula |
|---|---|
recommend_gpu_thread_pool_size | 4 on functional GPU, 1 otherwise |
recommend_force_cpu | !gpu.is_functional_for_batchalign() |
recommend_max_total_workers | clamp(ram_total_mb / 6 GB, 2, 32); fallback 4 when ram = 0 |
recommend_max_concurrent_jobs | tier-and-CPU bounded (CPU clamped to [1, 8] against tier max_suggested_workers) |
recommend_max_workers_per_job | per-command formula honoring gpu_thread_pool_size cap |
recommend_max_workers_per_key | per-profile RAM-derived (gpu = ram/16GB clamped to [1,8], stanza = ram/12GB clamped to [1,8], io = 1) |
recommend_memory_gate_mb was retired on 2026-05-08 alongside the
EMPIRICAL_MAX_CONCURRENT_JOBS_CAP = 4 clamp on
recommend_max_concurrent_jobs. Live admission/eviction primitives
in worker/pool/{cpu_gate,memory_gate,rss_observer,idle_eviction}.rs
replaced their roles, see Memory Safety for the
admission/eviction gate chain.
The RecommendedKnobs struct bundles the host-level scalar values;
max_workers_per_job and max_workers_per_key use richer per-key
shapes that don’t fit the bundle.
Layer 1.5: ServerConfig (operator overrides)
Each migrated knob is Option<T> in ServerConfig:
| Knob | Type | Sentinel migration |
|---|---|---|
gpu_thread_pool_size | Option<u32> | 0 -> None via zero_as_none |
force_cpu | Option<bool> | no shim, false is meaningful |
max_total_workers | Option<u32> | 0 -> None |
max_concurrent_jobs | Option<u32> | 0 -> None |
max_workers_per_job | Option<u32> | 0 -> None |
max_workers_per_key | Option<u32> | 0 -> None (uniform fan-out across profiles) |
memory_gate_mb | Option<MemoryMb> | 0 -> None (IsZero impl on the newtype) |
Some(v) is an explicit operator override; None falls through to
the recommendation. The zero_as_none serde shim
(in host_facts/serde_helpers.rs) collapses pre-migration
field: 0 from deployed server.yaml files to None so the
existing fleet keeps working without a coordinated re-render.
Phase G2 of the migration removes the shim once every host has been
re-rendered.
Layer 3: EffectiveConfig
The resolved per-host runtime view. Constructed via
EffectiveConfig::resolve_from_server_config(&cfg) (which detects
facts, lifts ServerConfig to ConfigOverrides, and merges) or
the lower-level EffectiveConfig::resolve(&overrides, &facts) for
tests. Stored as Arc<EffectiveConfig> on DispatchHostContext
so per-job dispatch reads the resolved view rather than re-detecting.
The merge rule per knob is uniform: override.unwrap_or(recommendation).
Per-profile and per-command merges follow the same rule, applied per
field/command.
Layer 4: validate
Pure function validate(cfg, facts) -> ConfigValidation. Reads
operator overrides directly from ServerConfig (NOT from
EffectiveConfig, which has already merged the two, the validator
needs to distinguish “operator explicitly set X” from “fell through
to recommendation”).
Today’s findings:
- Warnings (non-fatal): override contradicts recommendation in a
way that’s suboptimal but won’t crash. Surfaced as
tracing::warn!at startup;doctor --checkexits non-zero only if--warnings-as-errorsis set.GpuThreadPoolSizeAboveOneOnCpuMaxConcurrentJobsAboveRamBudgetMaxTotalWorkersAboveRamBudgetForceCpuFalseOnNonFunctionalGpu
- Errors (fatal): override would deterministically crash. Server
refuses to start;
doctor --checkexits non-zero. Today’s variant:MaxConcurrentJobsWouldDeterministicallyOom: fires whenconfigured * worst_case_per_job_peak_ram_mb(tier) > ram_total_mb. The “worst case” is the heaviest worker profile for the detected tier: 6 GB on Small (< 24 GB), 6 GB on Medium (Stanza > LazyProfile GPU), and 16 GB on Large/Fleet. If even that scheduling outcome exceeds physical RAM, no jobset can fit; the server refuses to start, and the error message suggests--sequentialso the operator has a one-flag fix.
Conservative-vs-recommendation cases (operator under-eager) are
intentionally silent: the operator knows their host better than
recommend() does, and silence is the right ergonomics for
“intentionally cautious”.
Wiring at startup
sequenceDiagram
participant CLI
participant serve_with_runtime as serve_with_runtime
participant DispatchHostContext as DispatchHostContext
participant Validate as validate()
participant Workers
CLI->>serve_with_runtime: cfg, pool_config, layout
serve_with_runtime->>Validate: cfg + RealHostFactsSource.detect()
Validate-->>serve_with_runtime: warnings -> tracing::warn!; errors -> abort
serve_with_runtime->>DispatchHostContext: from_store (per JobStore)
DispatchHostContext->>DispatchHostContext: EffectiveConfig::resolve_from_server_config
DispatchHostContext-->>Workers: WorkerRuntimeConfig populated from EffectiveConfig
The two RealHostFactsSource.detect() calls per startup
(serve_with_runtime for validation, DispatchHostContext for
runtime) are both millisecond-scale. Sharing the snapshot would be
a small cleanup; deferred until the cost actually shows up.
Adding a new knob
- Recommendation: add
recommend_NEW(facts: &HostFacts) -> Tinrecommendations.rswith table-driven unit tests covering the relevant fact shapes (Apple Silicon, CUDA, no-GPU). - Override slot: add
NEW: Option<T>toConfigOverridesineffective.rsand toEffectiveConfig(resolved value). - Resolve: extend
EffectiveConfig::resolvewith the merge lineNEW: overrides.NEW.unwrap_or(r.NEW). - ServerConfig field: add
NEW: Option<T>withzero_as_noneif T is integer-shaped (#[serde(default, deserialize_with = "zero_as_none", skip_serializing_if = "Option::is_none")]); forbooluse plain#[serde(default, skip_serializing_if = "Option::is_none")]. - Bridge: extend
From<&ServerConfig> for ConfigOverridesto populate the new field. - Consumers: production builders (
serve_cmd::start,dispatch::build_direct_pool_config) read fromeffective.NEWwhen populatingWorkerRuntimeConfig/PoolConfig. - Tests: 4 RED -> GREEN serde tests in
types/config/tests.rs(legacy zero, explicit, absent, round-trip). Plus thepropagates_to_config_overridesshape if relevant. - Validation (if the knob has a contradiction-with-fact mode):
add a
ConfigWarning::NEW { ... }variant with a self-explainingDisplay, the rule invalidate(), and 5 tests (above, equal, below, None, Display contract). - Doctor: add an arm to
explain_knobincrates/batchalign/src/cli/doctor_cmd.rssodoctor --explain NEWworks. Theexplain_handles_every_documented_knobtest catches forgotten arms. - Pyinfra render (if the knob is rendered into
server.yamlby pyinfra): add anOptional[T]field toBatchalignServerConfiginautomation/src/talkbank_automation/batchalign_render.pywith omit-when-None render policy, and an_optional_host_TYPEreader inautomation/pyinfra/deploys/deploy_batchalign3.py.
Public surface
External consumers (the doctor JSON output, future operator tools)
read through projection types in
crates/batchalign/src/cli/doctor_cmd.rs:
HostFactsReport { detected, effective, validation }EffectiveConfigSummary(flat bag of resolved scalars)ValidationReport { warnings: Vec<String>, errors: Vec<String> }KnobExplanation { knob, resolved_value, source, recommendation, rule, facts_used }
These are the JSON wire format, adding fields is
backwards-compatible but renames or removals must be conscious.
The runtime types (HostFacts, EffectiveConfig, ConfigWarning)
are NOT part of the operator API; they can evolve freely as long as
the projections still produce the documented JSON shape. This split
exists so internal refactoring doesn’t accidentally leak into the
operator contract.
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Rust CLI and Server
Status: Current Last updated: 2026-09-15 18:27 EDT
This page covers the Rust control plane that powers batchalign3: the CLI
client, the HTTP server, and how to extend them.
The current worker-boundary replacement plan is documented separately in Worker Protocol V2. That spec is the source of truth for replacing the legacy stdio JSON-lines worker contract.
Crate Map
After the 2026-04-28 monorepo merge, batchalign source lives as a small set of sibling crates inside this workspace:
| Crate | Role |
|---|---|
crates/batchalign/ | The runtime crate: Clap CLI, dispatch router, direct-host bootstrap, Axum HTTP server, job store, worker pool, cache, and command-owned orchestration. The chat_ops/ module owns CHAT extraction, injection, validation, ASR post-processing, and DP alignment that is batchalign-specific. |
crates/batchalign/src/commands/ | (submodule) released-command definitions, author-facing constructors, and the command catalog |
crates/batchalign-types/ | Domain newtypes, worker IPC types (V2), shared between the runtime crate and the PyO3 bridge |
crates/batchalign-pyo3/ | PyO3 bridge crate (batchalign_core); workspace member, slim dep tree (batchalign-types + talkbank-transform + pyo3/numpy/serde/tracing) |
crates/talkbank-{model,parser,transform,clan,...} | CHAT data model, parser, pipelines, CLAN tools, shared across the workspace; batchalign depends on the first three by workspace path |
Common Developer Commands
cargo check --workspace
cargo test --workspace
cargo check --manifest-path crates/batchalign-pyo3/Cargo.toml # PyO3 crate (separate)
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
CLI Command Dispatch (Single Source of Truth)
batchalign::cli::run_command() in
crates/batchalign/src/cli/mod.rs:251 is the
single canonical command router. The standalone binary (main.rs) calls it.
The installed batchalign3 console command is a tiny Python wrapper
(batchalign/_cli.py) that finds and execs the standalone binary, either
packaged in the wheel at batchalign/_bin/batchalign3, or from
target/debug/batchalign3 in a source checkout.
main.rs → batchalign::cli::run_command(cli)
batchalign/_cli.py → os.execv(batchalign/_bin/batchalign3) [installed]
→ os.execv(target/debug/batchalign3) [dev checkout]
main.rs and batchalign/_cli.py are thin wrappers.
No command-specific logic lives in either of them.
The CLI layer now exposes two contributor-facing named seams:
ReleasedCommandincrates/batchalign-types/src/domain.rs:36is the closed released command vocabulary for contributor-facing Rust code. Parse external strings into this enum as early as possible; keep the old string-backedCommandNameonly at wire/storage boundaries.CommandProfileincrates/batchalign/src/cli/args/mod.rs:148keeps the command identity, language, file extensions, and speaker count together as a typed profile instead of a positional tuple.DispatchRequestincrates/batchalign/src/cli/dispatch/mod.rs:42carries the typed command profile, I/O settings, and runtime flags into the dispatcher as one named boundary object.
The dispatcher also consults
batchalign::released_command_uses_local_audio() and the shared released
command catalog to decide whether a requested command uses the shared-filesystem
audio path under an explicit --server submission or can use ordinary
content-mode submission.
On the app side, the current execution split is now:
ExecutionEngine: shared command execution coreServerExecutionHost: queue/store/server-owned lifecycle behaviorDirectHost/DirectExecutionHost: inline local execution without queueing or registry discoveryServerBackend/LocalServerBackend, route-facing server control-plane seam over persisted jobs, orchestration, event subscription, traces, and runtime shutdownprepare_workers*()vsprepare_direct_workers(): explicit separation between server worker bootstrap and direct local worker bootstrap
Align / FA host flow
The part that was easiest to misunderstand during the recent align emergency
was where forced alignment actually runs.
align now has two honest host paths:
- Direct mode (no
--server) does not start Axum, an HTTP server, a queue, or registry discovery. The CLI prepares local workers and runs the job throughDirectHost. - Explicit server mode (
--server URL) submits a shared-filesystempaths_modejob. The server must be able to read the submitted source paths and write the requested output paths on the execution host.
Both paths converge on the same FA runner code in
crates/batchalign/src/runner/dispatch/fa_pipeline.rs.
flowchart TD
cli["batchalign3 align"]
route{"--server?"}
prep["prepare_paths_submission()\nsource_paths + output_paths\nmedia_mapping + media_subdir"]
direct["dispatch_direct_mode()"]
direct_workers["prepare_direct_workers()"]
direct_host["DirectHost::submit_submission()\nDirectHost::run_job()"]
server["dispatch_single_server()"]
post["POST /jobs (paths_mode)"]
backend["ServerBackend / queue / orchestrator"]
runner["process_one_fa_file()\nfa_pipeline.rs"]
fa["media resolution\n→ ensure_wav\n→ %wor / incremental reuse\n→ optional UTR\n→ FA transport\n→ traces + output"]
cli --> route
route -->|no| direct
route -->|yes| server
direct --> prep --> direct_workers --> direct_host --> runner
server --> prep --> post --> backend --> runner
runner --> fa
That shared convergence is deliberate: direct mode and server mode should differ in host/orchestration behavior, not in the actual forced-alignment logic.
When the CLI is polling or writing file results, FileErrorDetail
in crates/batchalign/src/cli/dispatch/helpers.rs:24 keeps
file-scoped failures as a named record instead of spreading
filename/message pairs through the progress code.
The command-specific logic starts from one declaration in
crates/batchalign/src/recipe_runner/catalog.rs: a CatalogEntry per released
command, naming its stage recipe and stating its family, planner, execution
mode, capability kind, io profile, dispatch kind, worker tasks and output
policy. The shared runner dispatches from that entry, so nothing per-command
needs to import store/queue/host plumbing to reach an existing family executor.
There is no per-command authoring layer any more. A commands/ module with one
module per command, six declare_*_command! macros and a CommandDefinition
type existed until 2026-07-28, when removing an #![allow(dead_code)] proved it
produced values nothing read; the compatibility views over the catalog followed
on 2026-07-29.
That is an intentional contributor contract:
- new commands should be authored direct-first and laptop-friendly
- command authors should not need to understand server internals
- command authors should usually only pick a family helper, not hand-author scheduling/runtime metadata
- server mode may opt into a different host/backend, but it should reuse the same generated command definition
command_family.rs keeps the small family enum used by command metadata,
text_batch.rs keeps reusable text-family helpers, and runner/dispatch/
keeps shared execution helpers. crates/batchalign/src/runner/ should stay
focused on job lifecycle, queueing, and policy rather than becoming a second
authoring surface for commands.
HTTP routes, SSE, and WebSocket handlers should prefer ServerBackend over
reaching through AppState to raw JobStore, queue, or runtime internals.
One dependency-graph cleanup already landed here: the standalone binary’s OTLP
telemetry stack and update-check helper are now gated behind the
batchalign crate’s binary-entry feature. The PyO3 cli_entry path
still shares run_command(), but it no longer drags those binary-only
dependencies into the extension build.
The embedded CLI bootstrap path now also lives in batchalign
(run_embedded_cli_from_argv()), so pyo3 no longer owns its own clap
parsing or Tokio runtime setup.
Under uv tool install, the Python wrapper now also primes
BATCHALIGN_SELF_EXE before it execs the packaged Rust binary. That gives
Rust server/daemon re-exec paths one explicit source of truth for “which binary
am I?” instead of forcing them to guess from current_exe() or PATH.
Server control-plane replacement guidance
The current recommendation is:
- Do not replace Axum just to say the server is off the shelf. The HTTP shell is not the main pain source.
- Do not move the primary CLI or server into Python. That would re-center orchestration around the worker/package layer instead of the typed Rust control plane.
- If we replace something, replace the control-plane backend behind
ServerBackend: queued-job claims, retries, recovery, runtime supervision, persisted progress, and cancellation.
That keeps the architectural split honest:
ExecutionEngineremains the canonical command execution core.DirectHostremains the BA2-style local/default execution path.ServerBackendbecomes the place where embedded-vs-durable server behavior can differ without teaching commands about server internals.
For local and single-host installs, the in-process local backend is the implemented server architecture.
Current backend contract:
ExecutionEngineowns canonical command execution only.DirectHostremains the direct/local execution path.ServerBackendowns app-facing job submission, inspection, cancellation, traces, event subscription, and runtime shutdown.LocalServerBackendowns queued-job orchestration, restart/recovery, runtime supervision, and store-backed lifecycle behavior.- The shared runner may report failures and progress, but it does not own higher-level server policy.
The important current design point is that direct mode stays simple while the server control plane remains local, explicit, and fully owned by the Rust runtime in this repository.
Validated so far:
cargo check -p batchalign -p batchalign
cargo test -p batchalign --lib -q
cargo test -p batchalign --test contract_suite json_compat:: -q
cargo test -p batchalign --lib -q
batchalign3 serve start --foreground --test-echo
batchalign3 jobs --server http://127.0.0.1:8111 <JOB_ID>
curl -X POST http://127.0.0.1:8111/jobs/<JOB_ID>/restart
curl -X DELETE http://127.0.0.1:8111/jobs/<JOB_ID>
Important validation caveat: existing e2e coverage already treats text-only
infer-task commands like morphotag as expected failures under --test-echo.
Use --test-echo to validate control-plane behavior, not infer-task success.
First-class debuggability
Direct mode and server mode should share the shape of the debugging handles they expose, but they should not be forced to share one live control-plane implementation just for symmetry.
What should be shared:
- stable
job_id - stable staging/artifact directory
- bug-report identifiers / files
- optional persisted trace artifact file
What should remain mode-specific:
- HTTP polling / WebSocket / dashboard transport
- queue persistence and recovery model
- live event fan-out
- server-only operational state
Direct mode now persists a machine-readable debug-artifacts.json file inside
the per-job staging directory and exports debug-traces.json when traces were
captured. The CLI also prints the direct job ID and artifact directory up front,
so a human or LLM agent can later inspect a failed local run by job ID instead
of relying on transient terminal output alone.
Opt-in telemetry is still worth considering, but only as an additive debugging aid for fleet/server deployments. It should not replace inspectable local artifacts. The first-class debugging path must remain: “here is the job ID and here are the files to inspect.”
For day-to-day command work, prefer the command layer first:
- add or extend
crates/batchalign/src/commands/<name>.rs - choose the existing runner family it should reuse
- keep the CLI argument plumbing thin
- let runner/dispatch handle lifecycle and resource policy, not semantics
Adding a New CLI Command
When adding a new processing command (e.g., batchalign3 foo), these files
must be updated:
1. CLI argument definition
crates/batchalign/src/cli/args/mod.rs: Add
Commands::Foo(FooArgs) variant to the Commands enum.
crates/batchalign/src/cli/args/commands.rs: Define FooArgs
struct with clap attributes. Include CommonOpts if the command
processes files.
2. CLI dispatch
crates/batchalign/src/cli/mod.rs: Add the match arm in
run_command() (defined at cli/mod.rs:251). For processing
commands, this typically falls through to the cmd => wildcard arm
that calls cli::dispatch::dispatch(). For utility commands (like
serve, jobs, models), add an explicit arm.
3. Typed command options
crates/batchalign/src/types/options.rs: Add
CommandOptions::Foo { ... } variant to the serde-tagged enum. This is the
wire format between CLI and server.
crates/batchalign/src/cli/args/options.rs: Add the builder in
build_typed_options() that converts FooArgs → CommandOptions::Foo.
4. Server-side task routing and capability gate
crates/batchalign/src/recipe_runner/recipes.rs: add the stage recipe.
crates/batchalign/src/recipe_runner/catalog.rs: declare the
CatalogEntry. That is the whole registration.
crates/batchalign/src/runner/policy.rs answers
command_requires_chat_infer() straight off that entry. It is total: a
ReleasedCommand always has an entry (pinned by
recipe_runner::catalog::tests::every_released_command_has_a_spec), so it
returns no Option for callers to unwrap.
Availability reads two different facts at two moments:
command_supported()incrates/batchalign/src/capability.rs: the worker’s admitted report (admitted once, inWorkerPool::record_capabilities()) advertises the entry’scapabilities.primary_infer_task. This alone decides what/healthadvertises and which submissions are accepted, from any report, including one a lazily loading worker gave before loading anything. It is the only availability rule in that module, besideWorkerCapabilitySnapshot.- At dispatch (
runner/routing.rs),WorkerPool::ensure_command_capabilities()loads the command’s task on the selected worker (ensure_task) and returnsLoadedCapabilities: the task it loaded and the report taken after that load. Routing runs step 1 again against that report. Then the forced-alignment dispatch arm, and only that arm, reads the FA engine withFaCacheNamespace::from_loaded(engine_reports.rs), because every FA cache row is namespaced by it. It refuses a report taken after a different task loaded (LoadedAnotherTask), an engine still unnamed after the load (UnreportedAfterLoad) and a worker that does not support FA (NotSupported). No catalog field declares this requirement; the arm that reads the namespace is the only place that says so.
The critical implementation rule is that startup capability state is not authoritative for execution. The current server intentionally allows an optimistic cold-start snapshot so app creation does not have to spawn a dedicated probe worker. Execution then resolves a live capability snapshot before it trusts infer-task gating:
WorkerCapabilitySnapshot::resolve()incrates/batchalign/src/capability.rsprefers the pool’s first admitted report over the startup view of every released command- the router (
runner/routing.rs) forces a command-appropriate live probe throughWorkerPool::ensure_command_capabilities(), which returns the selected key’s admitted report, before applying the availability rule WorkerPool::discover_from_registry()admits the report probed from a healthy TCP registry daemon under that daemon’s worker key, so registry-only deployments do not start withinfer_tasks = []
This split is deliberate. It avoids the old failure mode where lazy startup said
“we will discover capabilities later” but the first real morphotag or
compare job was still judged by an empty startup snapshot.
One implementation detail matters here: sequential TCP daemons accept one
connection at a time. Registry discovery therefore probes capabilities on the
same TcpWorkerHandle it already opened for the discovery health check, instead
of trying to race a second connection.
A checked-out TCP handle is a TcpCheckout (worker/pool/dispatch.rs), which
owns the handle and its group slot together. When the exchange ends, the
handle goes back to its group unless WorkerError::worker_after_failure()
(worker/error.rs) answers Retire. Only WorkerResponse, Bootstrap,
MemoryGuard, NoWorker and PoolShuttingDown leave the worker reusable;
every other error (a dead process, a protocol or I/O failure,
CapabilitiesRefused, and the rest) retires the handle. Retiring drops the
handle, which closes the connection, and releases the slot; a checkout dropped
without finishing (a cancelled exchange) does the same, because a half-read
stream could hand a later exchange a stale reply. The daemon itself is left
running and is adopted again by the next registry sweep.
A worker whose capability report was refused is never pooled, on any path: a
spawned worker is shut down, a registry GPU daemon is disconnected, a TCP
handle is dropped, a checked-out worker is taken out of its group, and
registry discovery (worker/pool/discovery.rs) does not integrate it.
The registry layer now also carries explicit daemon ownership metadata:
externaldaemons are preserved on routine shutdownserver_owneddaemons are tagged withserver_instance_idandserver_pid- shutdown only retires daemons owned by the current server instance
- discovery skips foreign live owners and reaps stale foreign owned daemons
- discovery refuses, without reaping it or removing its entry, a daemon whose
entry names another build or no build. Entries carry
build_identity, which the daemon reads fromBATCHALIGN_BUILD_IDENTITY, set by the server’s daemon spawner and bybatchalign3 worker start. The logged refusal names the remedy:batchalign3 worker stop, thenbatchalign3 worker startwith this build. Each sweep’s refusals are listed in/healthunderrefused_registry_workers(see Observability)
That ownership model is the durable fix for the old orphan-daemon/kill-all whackamole around server-spawned TCP workers.
On the Python side, you must also add the InferTask to _INFER_TASK_PROBES in
batchalign/worker/_handlers.py. See
Adding Inference Providers
for details.
5. Server-side dispatch shape
crates/batchalign/src/runner/routing.rs matches the catalog’s
RunnerDispatchKind exhaustively. Batched text commands (morphotag,
utseg, translate, coref, compare) are routed by name in
dispatch_batched_text_command() to the recipe-owned modules under
crates/batchalign/src/execution/ (morphotag/, utseg.rs, translate.rs,
coref.rs, and kernel.rs for compare); a new text command needs an arm
there. Every other kind goes to its module under
crates/batchalign/src/runner/dispatch/:
fa_pipeline.rs:dispatch_fa_infer()for per-file forced alignmenttranscribe_pipeline.rs:dispatch_transcribe_infer()for audio-to-CHAT generationbenchmark_pipeline.rs:dispatch_benchmark_infer()for transcribe + compare compositionmedia_analysis_v2.rs:dispatch_media_analysis_v2()for opensmile/avqi/diarizespeaker_identity_pipeline.rs:dispatch_speaker_identity()for speaker identification
Recipe-driven execution (new model): Compare has been migrated from
runner/dispatch/ to the recipe-driven execution/ kernel. New commands
should prefer the execution/ model when they have multi-stage workflows.
See crates/batchalign/src/execution/ for the StageExecutor trait
and crates/batchalign/src/planning/ for build_job_plan().
6. Orchestrator module
crates/batchalign/src/commands/foo.rs: The command-owned wrapper that
owns the command’s semantic shape, shared plan selection, and materialization
policy.
crates/batchalign/src/foo.rs or runner/dispatch/*: Keep shared
algorithmic code and reusable runner families here when it improves clarity, but
do not make them the only obvious home of the released command.
For batch text workflows, prefer the named wrappers in
crates/batchalign/src/text_batch.rs over raw tuples:
TextBatchFileInputkeeps one file name and one owned CHAT payload together.TextBatchFileResultskeeps the per-file outcome shape explicit.TextWorkflowFileErrorkeeps file-scoped failure details separate from file identity instead of returningStringerror messages.
7. Worker support
batchalign/worker/_model_loading/: Register the dynamic batch-infer
handler for InferTask.FOO during worker bootstrap if the task needs loaded
runtime state or engine-specific wiring.
batchalign/worker/_infer.py: Only update this file if the task is a
pure static route that does not need bootstrap-installed runtime wiring.
batchalign/inference/foo.py: The Python inference module (pure model
invocation, no CHAT awareness).
8. CHAT operations (if needed)
crates/batchalign/src/foo.rs: Payload collection, cache key
computation, result injection functions used by the orchestrator.
OpenAPI Workflow
# Generate OpenAPI schema
cargo run -q -p batchalign -- openapi --output openapi.json
# Verify schema is up to date (CI gate)
cargo run -q -p batchalign -- openapi --check --output openapi.json
Relationship to the PyO3 Layer
The CLI/server workspace and the PyO3 extension are separate build targets:
- Root workspace (
crates/): operational control plane (CLI + server) crates/batchalign-pyo3/: Python extension module (batchalign_core)
Both share CHAT operations through batchalign. The PyO3 crate
also depends on batchalign (for run_command()) and batchalign
(for OpenAPI types), but it now does so with default-features = false so the
extension path does not compile the standalone binary’s OTLP stack.
See Building & Development for the recommended fast
local loop (one cargo build -p batchalign for the source-checkout
fallback; uv run maturin develop -m crates/batchalign-pyo3/Cargo.toml -F pyo3/extension-module or the
make batchalign-build-wheel → make batchalign-python-prepare
chain when you need the PyO3 extension installed into the dev env).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
HTTP Request Body Limits
Status: Current Last updated: 2026-04-29 17:05 EDT
The Problem
The batchalign3 server has two independent body-size limits that gate
incoming HTTP requests. Before this was understood and fixed, large batch
submissions (e.g. 50+ CHAT files in a single POST /jobs) silently hit the
inner limit and returned 413 Payload Too Large even though the configurable
outer limit was generous.
Two Layers of Limits
Layer 1: RequestBodyLimitLayer (outer, configurable)
Defined in crates/batchalign/src/routes/mod.rs as the outermost
body-aware middleware:
let max_body_bytes = state.environment.config.max_body_bytes_mb.0 as usize * 1024 * 1024;
// ...
.layer(RequestBodyLimitLayer::new(max_body_bytes))
This is the intended body-size guard. It is configured via
max_body_bytes_mb in server.yaml and defaults to 512 MB
(default_max_body_bytes_mb() in types/config/server.rs).
Layer 2: axum Json extractor (inner, was 2 MB)
Axum’s Json<T> extractor enforces its own body limit independently of any
RequestBodyLimitLayer. The default is 2 MB: a safe-out-of-the-box
value for generic web applications, but far too low for batchalign’s use case.
The POST /jobs handler uses Json<JobSubmission> to deserialize the request.
A JobSubmission contains the full text content of every submitted CHAT file
(as Vec<FilePayload>, where each FilePayload.content is the raw CHAT
string). Even a modest batch of 20 CHAT files can exceed 2 MB.
This inner limit fires before the outer RequestBodyLimitLayer gets a
chance to evaluate the request, producing an identical 413 status code. The
error message ("Failed to buffer the request body: length limit exceeded")
gives no indication which limit was hit.
The Fix
The job router in crates/batchalign/src/routes/jobs/mod.rs applies
DefaultBodyLimit::disable() to all job routes:
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/jobs", post(submit_job))
// ... other routes ...
.layer(axum::extract::DefaultBodyLimit::disable())
}
This removes the 2 MB Json extractor limit entirely. The outer
RequestBodyLimitLayer remains as the sole body-size guard, governed by the
max_body_bytes_mb config value.
Practical Sizing
CHAT files average ~120 KB. JSON serialization adds minimal overhead (CHAT text is mostly ASCII, so JSON string escaping is negligible). Rough payload sizes for batch submissions:
| Files | Approximate payload |
|---|---|
| 10 | ~1 MB |
| 50 | ~6 MB |
| 200 | ~25 MB |
| 500 | ~62 MB |
| 1,000 | ~120 MB |
| 4,000 | ~480 MB |
The default 512 MB limit comfortably handles the largest batches the CLI
ships today (CHILDES-eng-uk, CHILDES-other), where 500-file chunks plus
headroom were the empirical ceiling that motivated the raise from the
historical 100 MB. Operators who need larger batches can raise
max_body_bytes_mb in server.yaml.
Operational Knobs
Global default
server.yaml max_body_bytes_mb overrides the compile-time 512 MB default.
Setting it to a smaller value tightens the global cap for every route;
setting it larger raises the ceiling for unusually large submissions.
Per-route limits
If a future route needs a tighter limit than the global cap (for example,
a small-payload endpoint where 512 MB is wasteful and a tight cap would
detect abuse early), the route’s Router can wrap a per-route
RequestBodyLimitLayer inside the global one:
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/some-tight-endpoint", post(handler))
.layer(RequestBodyLimitLayer::new(5 * 1024 * 1024)) // 5 MB
}
Tower-http’s RequestBodyLimitLayer is composable: the innermost
non-zero limit on the request path wins, so per-route limits are
strictly narrower than the global one. We have not wired this for any
specific route today because every body-accepting endpoint
(POST /jobs, the cancel/restart variants) wants the global cap.
When a new route lands that needs a different limit, the right move
is a per-route layer, not a new top-level config knob.
Inner-vs-outer rejection diagnosis
When a body limit fires, axum’s default 413 message
("Failed to buffer the request body: length limit exceeded") does not
indicate which layer rejected the request. This was the original
debugging headache that motivated the fix above. A typed
PayloadTooLarge { limit_layer: Inner | Outer, configured_bytes: u64 }
error shape is planned as part of the PyO3 typed-error contract work
(see crates/batchalign/src/error.rs); when that lands, the same
error type will surface inner-vs-outer at the rejection site through
tracing::warn!. Today, the outer RequestBodyLimitLayer is the only
configured body limit on /jobs, so any 413 from that route is the
outer layer firing.
Related Files
| File | Role |
|---|---|
crates/batchalign/src/routes/mod.rs | Outer RequestBodyLimitLayer |
crates/batchalign/src/routes/jobs/mod.rs | Inner limit disabled via DefaultBodyLimit::disable() |
crates/batchalign/src/types/config/server.rs | max_body_bytes_mb field and 512 MB default |
crates/batchalign/src/types/request.rs | JobSubmission and FilePayload structs |
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Plugin Architecture (Removed)
Status: Current Last updated: 2026-03-26 14:05 EDT
Status: Removed (March 2026). The plugin system (
batchalign.plugins,PluginDescriptor,InferenceProvider,discover_plugins()) was deleted. The only plugin that existed (batchalign-hk-plugin) was folded into the core repository as built-in engines.The original detailed design history is preserved in maintainer archives; the public docs keep only the migration outcome and the current extension pattern.
Why It Was Removed
- Single consumer:
batchalign-hk-pluginwas the only plugin. The discovery machinery existed for one package. - Entry-point fragility:
importlib.metadata.entry_points()failed silently on broken packages, loaded wrong versions across environments, and was difficult to debug. - Enum dispatch is safer:
AsrEngineandFaEngineenums provide compile-time exhaustiveness checking and clear error messages for missing engines. - Built-in providers are simpler: we keep provider dependencies in the base package rather than recreating plugin-style install tiers.
Current Engine Extension Pattern
To add a new inference engine, use the built-in engine pattern documented in Adding Inference Providers. The pattern is:
- Create a
(load_*, infer_*)function pair inbatchalign/inference/ - Add an enum variant to
AsrEngine,FaEngine, or the relevant engine enum - Wire the loader into
worker/_model_loading/ - Register the runtime handler during bootstrap in
worker/_model_loading/and keepworker/_infer.pythin - Add provider/runtime dependencies to the base package if the engine is part of the supported built-in surface
If you are adding a new command, do not reach for a plugin system. Put the
released command in crates/batchalign/src/commands/ and keep any
algorithmic or orchestration logic in the owning Rust module (compare.rs,
benchmark.rs, transcribe/, fa/, morphosyntax/, etc.). Engines remain
providers for Rust-owned command flows; command composition does not happen
through late-bound plugin discovery.
For a real-world example of this pattern, see the Cantonese ASR engines in
batchalign/inference/languages/cantonese/ and the
Cantonese and CJK, Architecture.
Migration Guide for Existing Plugins
If you have an existing batchalign.plugins plugin, migrate it to a built-in
engine:
- Move your
load_*andinfer_*functions intobatchalign/inference/ - Add an enum variant for your engine in
worker/_types.py - Remove
pyproject.tomlentry points andPluginDescriptor - Add your dependencies to batchalign’s base package if the engine is a supported built-in provider
- Update tests to use
monkeypatchinstead of mock-based plugin patching
This page last changed: 2026-06-19 (commit c82a6d03). The whole book last changed: 2026-09-16 (commit 34d249d8).
Coping with Upstream Bugs and Limitations: Policy and Workflow
Status: Current Last updated: 2026-05-20 01:09 EDT
Batchalign integrates several third-party NLP libraries, Stanza, Whisper, PyCantonese, Pyannote/NeMo, Rev.AI, Apple MPS, OpenSMILE, and others. Each is a working piece of software with real bugs, surprising edge cases, and version-to-version behavior changes. This document explains our policy for dealing with upstream defects and the workflow every contributor follows when one is discovered.
Core principle
Batchalign targets linguistic and data correctness, not upstream-library parity. When an upstream library is wrong on a specific input, we override with a principled rewrite. When the upstream fixes the issue, we retire our override. Both directions are driven by permanent tests that pin the observed behavior.
This principle is how we reconcile two real constraints:
- Users depend on batchalign to produce correct CHAT output. “The library we use is broken” is not an acceptable excuse when the CHAT on disk is wrong.
- Every override is technical debt we carry. We don’t override blindly, each rewrite is narrow, registered, and has a clear retirement condition.
Three layers of defense
Every upstream call site has three layers that together ensure corrupt output never ships silently:
flowchart TD
Upstream["Upstream library\n(Stanza, Whisper, PyCantonese, ...)"]
Detect["Detector\n(empirically narrow\ntrigger condition)"]
Rewrite["Rewrite / override\n(typed, in-place,\nnever text surgery)"]
Log["tracing.warn\n(visible in server log,\nnames the defect + version)"]
Registry["Defect registry\n(reference/*-limitations.md,\nversioned entry)"]
Tests["Permanent tests\n(reproducer + pure unit +\nintegration)"]
Validate["chatter validate\n(final boundary gate)"]
Upstream --> Detect
Detect -- known defect --> Rewrite
Detect -- unknown --> Validate
Rewrite --> Log
Log --> Registry
Registry --> Tests
Tests -.-> Rewrite
Rewrite --> Validate
Validate -- fails --> Reject["Reject at CHAT write\n(never ships to disk)"]
Validate -- passes --> Ship["File lands clean"]
- Targeted rewrite at the data boundary. The detector fires
only on a named defect with empirically narrowed trigger
conditions. The rewrite operates on the typed intermediate
representation (UD
Document.to_dict()before crossing into Rust,UdSentencein Rust), never on serialized CHAT text. - Ops-visible logging. Every rewrite emits a
tracing::warnnaming the defect, the upstream library version, the affected input, and the rewrite applied. An operator monitoring the fleet can see that the workaround fired. - Final validation gate.
chatter validatechecks every file produced against CHAT-manual semantics. If a defect variant escapes our detector, validation catches the malformed output at the write boundary, the file doesn’t land on disk in a corrupt state without a loud signal.
Why rewrite-and-log, not fail-hard
Early versions of this policy considered failing the whole language group on any defect detection. That approach was rejected because:
- Library bugs are common. Failing hard would turn any defect into a fleet-wide outage class.
- Batchalign already handles library imperfection on every language (Stanza scores below CHAT-quality on Cantonese, MWT hints are fragile, Apple MPS has kernel deadlocks on certain FA shapes, etc.). A uniform fail-hard policy would make the tool unusable.
- The validation gate (
chatter validate) is the correctness boundary, not the rewrite layer. If our detector ever misses a defect variant, validation still catches the bad CHAT. - A loud log is enough observability for an operator. A loud error mislocates the problem, the file’s data quality is fine after rewrite; the only thing “wrong” is the upstream library.
The one rule from this calculus: every rewrite MUST be logged. Silent rewrites would hide the defect from ops monitoring and make retirement impossible.
Workflow: adding a new workaround
When a new upstream defect is discovered, every contributor follows the same five-step pattern. The pattern is mechanical: fill in the six files below, link them together, commit.
1. Write a standalone reproducer
A pure test, no batchalign imports, safe to copy into the upstream library’s issue tracker. Calls the library directly on the minimum input that reproduces the defect. Asserts the CORRECT behavior, so the test is RED on the current upstream version.
Location: next to the workaround tests, file-named for the defect.
Example: batchalign/tests/pipelines/morphosyntax/test_stanza_fi_mwt_sos_leak.py.
The reproducer serves two audiences:
- The upstream maintainer who receives your bug report.
- The next batchalign contributor who runs it after a library upgrade to see if the defect has been fixed.
2. Empirically narrow the trigger
Don’t rewrite everything that looks suspicious. Bisect the input until you find the minimum conditions under which the defect fires. A narrow trigger is a narrow compatibility surface and is cheap to retire when the upstream fixes it.
Example from Defect 4 (Stanza 1.11.1 Finnish <SOS> leak): we
bisected from an 8-word Finnish sentence down to "a tollei b",
three ASCII tokens, no domain knowledge of Finnish required to
reproduce.
3. Implement the workaround at the typed layer
Operate on the typed intermediate representation, not on serialized
CHAT text. For Stanza leaks that’s the Python doc.to_dict()
boundary in batchalign/inference/*.py. For Stanza UD misanalyses
that’s the UdSentence layer at
crates/batchalign-transform/src/morphosyntax/invariants.rs (with
per-rule modules under crates/batchalign-transform/src/morphosyntax/invariants/,
e.g. finite_verb_main_clause.rs). For MPS GPU deadlocks that’s the
device selection layer. Never rewrite serialized output, that’s the
batchalign2 anti-pattern we deliberately avoid.
The workaround emits a tracing::warn per rewrite. The message
names the defect, the upstream version, the rewritten value, and the
affected language or input so ops monitoring is useful.
4. Pin behavior with permanent tests
Three levels are the standard:
- Pure unit tests: exercise the detector/rewriter on fabricated
inputs without the real library. Fast, run on every
make test. - Integration test: runs the full handler with the real
upstream library loaded. Marked
@pytest.mark.golden(Python) or gated behind theml-goldencargo feature (Rust). Asserts the rewrite fires and the output is clean. - Standalone reproducer from step 1, remains as the upgrade-time probe.
5. Register in the versioned defect doc
Each upstream library has its own registry in book/src/reference/:
| Library | Registry |
|---|---|
| Stanza | reference/stanza-limitations.md |
| Apple MPS | developer/apple-mps-workarounds.md |
| Per-language Stanza specifics | developer/non-english-workarounds.md |
A new registry is warranted only for an upstream dependency that accumulates several distinct defects over time. Before creating one, prefer extending a related document.
Every registry entry answers these questions:
- Nature: what is wrong, in the defect’s own terms (not “our code fails”).
- Upstream version: which version of the library was the defect observed in.
- Input examples: the minimum and a real-corpus example.
- Trigger conditions: empirically narrowed.
- Correct output: what a fixed upstream would produce.
- Batchalign mitigation: code pointer + date the workaround landed.
- Tests: links to the three levels from step 4.
- Re-evaluation criteria: specific steps to run after the next upstream upgrade to check whether the defect is fixed.
- Upstream reporting: issue URL once filed, or “not yet” if the reproducer is prepared but not submitted.
6. Link everything together
The tests, the mitigation code, and the registry entry all cross-reference each other by path. A contributor investigating a warning in a server log should be able to go from the log line → registry entry → code pointer → test file in two hops, without spelunking.
Retirement: the upgrade checklist
When an upstream library is upgraded:
- Open the registry for that library.
- For each active defect entry, run the standalone reproducer.
- If the reproducer is GREEN on the new upstream version, the defect is fixed. The workaround’s detector should now fire zero times on normal input, confirm by re-running the integration test. If it still passes without the workaround running, the workaround is retireable.
- Retire the workaround: delete the detect+rewrite code, remove
the
tracing::warncall, delete the pure unit tests and the integration test, update the registry entry from “ACTIVE” to “RETIRED (upstream fixed in version X.Y.Z)”. - Keep the standalone reproducer as a historical reference. It becomes documentation of a past defect rather than a regression guard.
Anti-patterns
Things this policy explicitly rejects:
- Silent rewrites without a log. No visibility → no way to track when the defect fixes upstream.
- Rewrites on serialized CHAT text. String surgery over emitted output is the BA2 approach we deliberately avoid. Work on typed models.
- Fail-hard on library defects. Turns operational workarounds into outage classes.
- Surface-pattern rewrites without a named defect. If you can’t name the invariant being violated, you’re hacking, not fixing.
- Permanently-disabled workarounds (feature flag “off by default”). Either the defect is real and the workaround is on, or the workaround is retired. No gray zone.
- Unregistered workarounds. A workaround that doesn’t appear in any registry is technical debt nobody knows about. Dead code waiting to surprise a future contributor.
See also
reference/stanza-limitations.md, Stanza defect registry (currently 4 entries).developer/non-english-workarounds.md, Per-language workaround catalog.developer/apple-mps-workarounds.md, MPS-specific defect registry.crates/batchalign-transform/src/morphosyntax/invariants.rs: the Rust-side typed UD rewrite module that anchors this pattern for morphosyntax; per-rule sub-modules live undercrates/batchalign-transform/src/morphosyntax/invariants/.- Stanza Defect 4 (Finnish
<SOS>leak) was retired in Stanza 1.12.0; the historical Python-side workaroundbatchalign/inference/_control_token_filter.pyis no longer in-tree. See Stanza Defect Mitigation Map for the current per-defect patch-point inventory.
This page last changed: 2026-07-30 (commit 5157a549). The whole book last changed: 2026-09-16 (commit 34d249d8).
Non-English Language Workarounds
Status: Current Last updated: 2026-05-21 13:20 EDT
This document catalogs every language-specific workaround in batchalign3’s morphosyntax pipeline (morphotag) and related commands. Each entry describes what the workaround does, why it exists, whether the underlying issue is likely to persist, and how to verify it is still needed.
Overview
The morphosyntax pipeline relies on Stanza for UD annotation. Stanza’s models have known per-language quirks: mislabeled POS tags, missing features, incorrect MWT expansion. These workarounds correct systematic errors to produce accurate CHAT %mor/%gra output.
These entries mix three kinds of behavior:
- CHAT/CHILDES conventions that should remain even if upstream models improve
- Stanza-specific workarounds that may become removable after verification
- architectural requirements such as code mapping or Cantonese FA romanization
All workarounds were ported from batchalign2 and now live entirely in Rust
(crates/batchalign-transform/src/morphosyntax/lang_*.rs). Python workers only
call Stanza and return raw output, all workaround logic is applied
server-side.
Decision Framework
The following diagram shows how workarounds are categorized and the keep/retire decision criteria for each type.
flowchart TD
workaround(["Language workaround"])
type{"Workaround type?"}
subgraph "Type 1: CHAT Conventions (permanent)"
t1["Keep: encodes CHAT/CHILDES rules"]
t1_langs["English: irregular verbs (morphosyntax/lang_en.rs)\nFrench: pronoun case + APM nouns (morphosyntax/lang_fr.rs)\nJapanese: comma → cm (morphosyntax/lang_ja.rs)\nCantonese: text normalization (asr_postprocess/cantonese.rs)\nCross-language: MWT dispatch, ISO mapping,\nnumber expansion"]
end
subgraph "Type 2: Stanza Bugs (testable for retirement)"
t2{"Stanza still\nexhibits bug?"}
t2_keep["Keep: still needed"]
t2_remove["Remove: Stanza fixed it"]
t2_langs["English: GUM MWT (worker/_stanza_loading.py)\nFrench: 'au' MWT (tokenizer_realign.rs)\nItalian: l' suppression, lei merge\n(tokenizer_realign.rs)\nPortuguese: d'água (tokenizer_realign.rs)\nJapanese: verb form overrides (morphosyntax/lang_ja.rs)"]
end
subgraph "Type 3: Mixed (convention + bug)"
t3["Requires per-rule analysis"]
t3_langs["English: contraction MWT (tokenizer_realign.rs)\nFrench: elision/multi-clitic (tokenizer_realign.rs)\nDutch: possessive 's (tokenizer_realign.rs)"]
end
workaround --> type
type -->|"CHAT convention"| t1 --> t1_langs
type -->|"Stanza model bug"| t2
t2 -->|Yes| t2_keep
t2 -->|No| t2_remove
t2 ~~~ t2_langs
type -->|"Mixed"| t3 --> t3_langs
A workaround should be kept if:
- Stanza still exhibits the bug (test with current Stanza version)
- The workaround encodes a CHAT convention (not just a Stanza fix)
- Removing it breaks golden tests or parity with CLAN manual output
A workaround should be removed if:
- Stanza fixed the underlying issue in a newer version
- The workaround’s behavior conflicts with CHAT manual specifications
- It was specific to a Stanza version we no longer support
Verification Method
For each workaround, the recommended verification test is:
- Feed the workaround’s trigger input through Stanza directly (no workaround)
- Compare output with the workaround applied
- If they differ, the workaround is still needed
- If they agree, the workaround can be retired
English (eng → en)
E1. Irregular Verb Conjugation Database
| File | crates/batchalign-transform/src/morphosyntax/lang_en.rs |
| Size | Irregular-form entries (see the file for the active list) |
| What | Static lookup of irregular past tense / participle forms (be→was/been, go→went/gone, etc.). Used by verb_features() to emit -PAST or -PASTP suffixes. |
| Why | Stanza’s lemmatizer doesn’t reliably map inflected forms back to base forms for irregular verbs. The lookup confirms whether a surface form is indeed a known irregular conjugation of its lemma. |
| Origin | Ported from batchalign2/pipelines/morphosyntax/en/irr.py |
| Still needed? | Yes, permanent. This is a CHAT convention: %mor must show -PAST/-PASTP suffixes on irregular verbs. Even if Stanza improved, the lookup table is needed to classify forms. |
| Tests | lang_en.rs: test_irregular_past, test_irregular_participle, test_regular_verb, test_case_insensitive |
E2. English Contraction MWT Handling
| File | crates/batchalign-transform/src/tokenizer_realign.rs |
| What | Tokens with apostrophes (don’t, can’t, ’ve, ’ll, etc.) are marked as (text, true) MWT hints for Stanza expansion. Exception: “o’clock” and “o’er” (prefix “o” before apostrophe). |
| Why | Stanza’s neural tokenizer sometimes fails to split contractions. Explicit MWT hints ensure consistent expansion. |
| Origin | batchalign2/ud.py:680-685 |
| Still needed? | Likely yes. English contractions remain a tokenization edge case. Removing this would require testing every contraction form with current Stanza. |
| Tests | tests embedded in tokenizer_realign.rs |
E3. English GUM MWT Package
| File | batchalign/worker/_stanza_loading.py |
| What | English uses Stanza’s “gum” MWT package instead of default. |
| Why | The GUM corpus MWT model provides better English contraction handling. |
| Origin | batchalign2 Stanza configuration |
| Still needed? | Unknown, testable. Newer Stanza versions may have improved the default package. Test: run English morphotag with and without “gum” package, compare results on contraction-heavy input. |
| Tests | test_stanza_config_parity.py |
French (fra → fr)
F1. Pronoun Case Lookup
| File | crates/batchalign-transform/src/morphosyntax/lang_fr.rs |
| Size | Pronoun-case lookup (Nominative + Accusative entries; see the file) |
| What | Hardcoded table mapping French pronouns to case (Nom/Acc) by surface form. Applied when UD word has POS=PRON. Handles apostrophes (e.g., “qu’” → check “qu”). |
| Why | Stanza’s French model often omits or misassigns the Case feature on pronouns. The lookup provides correct case for CHAT %mor output. |
| Origin | batchalign2/pipelines/morphosyntax/fr/case.py |
| Still needed? | Likely yes. Case assignment is a known weak point of UD French models. Even if Stanza improves, the lookup table is a CHAT-specific convention ensuring consistent output. |
| Tests | lang_fr.rs: 4 tests covering Nom, Acc, unknown, apostrophe |
F2. Auditory Plural Marking (APM) Noun Detection
| File | crates/batchalign-transform/src/morphosyntax/lang_fr.rs |
| Size | Noun form list (see the file for the active set) |
| What | List of French nouns that undergo auditory plural marking (e.g., “cheval”/“chevaux”). Used by noun_features() to correctly emit plural suffixes in %mor. |
| Why | Stanza may not distinguish between regular and APM plurals. CHILDES/CHAT convention requires explicit plural marking for these nouns. |
| Origin | batchalign2/pipelines/morphosyntax/fr/apmn.py |
| Still needed? | Yes, permanent. This is a CHAT/CHILDES convention for French child language analysis. The list defines which nouns get special plural treatment regardless of Stanza’s output. |
| Tests | lang_fr.rs: 4 tests; mapping.rs: test_french_noun_apm_plural, test_french_noun_non_apm_plural |
F3. MWT Overrides (3 rules + elision + multi-clitic)
| File | crates/batchalign-transform/src/tokenizer_realign.rs |
| What | Three explicit patches plus elision/multi-clitic logic: |
| “aujourd’hui” → plain text (prevent MWT expansion) | |
| “au” → force MWT (à + le contraction) | |
| Elision prefixes (jusqu’, puisqu’, quelqu’, aujourd’) → split on apostrophe | |
| Multi-clitic (e.g., “d’l’attraper”) → split into individual clitics | |
| Why | Stanza’s French MWT model has known quirks with these forms. |
| Origin | batchalign2/ud.py:671-689 |
| Still needed? | Likely yes for aujourd’hui and elision rules. These are French orthographic conventions, not Stanza bugs. The “au” forcing could be tested with current Stanza, it may handle it correctly now. |
| Tests | French-specific tests embedded in tokenizer_realign.rs |
Japanese (jpn → ja)
J1. Verb Form Overrides
| File | crates/batchalign-transform/src/morphosyntax/lang_ja.rs |
| Size | Order-dependent override chain (see the file) |
| What | If/elif chain matching substrings in Japanese word text. Can override both POS and lemma. Examples: |
| “ちゃ” → sconj/“ば”, “なきゃ” → sconj/“なきゃ”, “れる” → aux/“られる”, “はい” → intj/“はい” | |
| Why | Stanza’s Japanese models systematically mislabel auxiliary particles and verbs. The surface form is a reliable signal for the true grammatical function. |
| Origin | batchalign2/pipelines/morphosyntax/ja/verbforms.py |
| Still needed? | Almost certainly yes. Japanese auxiliary verb classification is a known challenge for UD models. These are systematic patterns, not isolated bugs. Each rule should be verified individually against current Stanza output, but the overall framework will likely remain necessary. |
| Order matters | The if/elif chain is order-dependent, matches Python exactly. |
| Tests | lang_ja.rs: 4 tests covering sconj, intj, de, and no-override cases |
J2. Combined Processor Package
| File | batchalign/worker/_stanza_loading.py |
| What | Japanese uses Stanza’s “combined” processor package for all processors instead of default. |
| Why | Japanese doesn’t use MWT. Combined models provide better accuracy. |
| Origin | batchalign2/ud.py:1048-1052 |
| Still needed? | Likely yes. Japanese tokenization is fundamentally different from European languages. |
| Tests | test_stanza_config_parity.py |
J3. Comma POS Normalization
| File | crates/batchalign-transform/src/morphosyntax/mor_word.rs |
| What | Japanese PUNCT tokens are remapped to cm POS. Japanese commas (“、”, “,”) specifically get lemma “cm”. |
| Why | CHAT uses “cm|cm” for comma punctuation, but Stanza tags these as regular PUNCT. |
| Origin | Python master Japanese handling |
| Still needed? | Yes, permanent. This is a CHAT convention, not a Stanza bug. |
| Tests | Covered by morphosyntax round-trip tests |
Italian (ita → it)
I1. “l’” MWT Suppression
| File | crates/batchalign-transform/src/tokenizer_realign.rs |
| What | When Stanza tags “l’” as MWT (l', true), suppress the expansion hint. |
| Why | Stanza aggressively expands “l’” which should not always be split. |
| Origin | batchalign2/ud.py:662-668 |
| Still needed? | Testable. Run Stanza on Italian text with “l’”, if it still over-expands, keep. |
| Tests | Italian tests embedded in tokenizer_realign.rs |
I2. “lei” Merge (le + i → lei)
| File | crates/batchalign-transform/src/tokenizer_realign.rs |
| What | If Stanza splits “lei” into “le” + “i”, merge them back. |
| Why | Known Stanza bug splitting the pronoun “lei” (she/her). |
| Origin | batchalign2/ud.py:668 |
| Still needed? | Testable. If Stanza no longer splits “lei”, can remove. |
| Tests | Italian tests embedded in tokenizer_realign.rs |
Portuguese (por → pt)
P1. “d’água” MWT Forcing
| File | crates/batchalign-transform/src/tokenizer_realign.rs |
| What | Force MWT expansion on “d’água” (de + água). |
| Why | Stanza may not recognize this as a contraction. |
| Origin | batchalign2/ud.py:669-670 |
| Still needed? | Testable. Run Stanza on “d’água”, if it splits correctly, can remove. |
| Tests | Portuguese test embedded in tokenizer_realign.rs |
Dutch (nld → nl)
D1. Possessive “’s” MWT Suppression
| File | crates/batchalign-transform/src/tokenizer_realign.rs |
| What | Tokens ending with “’s” (e.g., “vader’s”) get (text, false) hint to prevent MWT expansion. |
| Why | Dutch possessive ’s is not a contraction and should not be split. |
| Origin | batchalign2/ud.py:694-695 |
| Still needed? | Likely yes. Dutch possessive ’s is an orthographic convention that MWT models may mishandle. |
| Tests | Dutch tests embedded in tokenizer_realign.rs |
Cantonese (yue): Engines
C1. Text Normalization Pipeline
| File | crates/batchalign-transform/src/asr_postprocess/cantonese.rs |
| Size | ferrous-opencc s2hk conversion + a domain replacement table (see the file for the active entries) |
| What | Two-stage normalization: Simplified→Traditional via ferrous-opencc, then a domain replacement table (multi-char first to prevent partial matches). |
| Why | Cantonese ASR output uses simplified or colloquial forms that need normalization to standard written Cantonese. |
| Origin | Cantonese-specific (new in batchalign3) |
| Still needed? | Yes, permanent. Regional dialect normalization, not a model bug. |
| Tests | test_common.py |
C2. Jyutping Romanization for FA
| File | batchalign/inference/languages/cantonese/_cantonese_fa.py |
| What | Converts hanzi to jyutping (tone-stripped, apostrophe-joined) before Wave2Vec FA. |
| Why | Wave2Vec MMS was trained on romanized text, so hanzi must be romanized for alignment. |
| Origin | Cantonese-specific (new in batchalign3) |
| Still needed? | Yes, permanent. Architectural requirement of the FA model. |
| Tests | test_cantonese_fa.py |
Cross-Language
X1. MWT Language Dispatch Table
| File | batchalign/worker/_stanza_loading.py |
| Size | 39 languages currently enable MWT |
| What | Determines which languages use Stanza’s MWT processor. CJK, some Slavic languages excluded. |
| Why | MWT is not applicable to all languages. CJK languages don’t have multi-word tokens. |
| Origin | batchalign2/ud.py:1034-1036 |
| Still needed? | Yes, permanent. Fundamental to pipeline architecture. |
| Tests | test_stanza_config_parity.py |
X2. ISO 639-3 → ISO 639-1 Mapping
| File | batchalign/worker/_stanza_loading.py |
| Size | 55 explicit mappings |
| What | Converts 3-letter codes (batchalign internal) to 2-letter codes (Stanza). Special: yue→zh, cmn→zh. |
| Why | Stanza uses 2-letter codes. |
| Origin | Essential mapping maintained from batchalign2 |
| Still needed? | Yes, permanent. Different code systems. |
| Tests | Implicit in all morphosyntax tests |
X3. Number Expansion
| File | crates/batchalign-transform/src/asr_postprocess/num2text.rs, crates/batchalign-transform/src/asr_postprocess/num2chinese.rs |
| Size | Language-specific lookup tables (the authoritative list lives at crates/batchalign-transform/data/num2lang.json) plus a Chinese-script converter |
| What | Converts digit strings to word forms (5→“five”, 5→“五”) during ASR post-processing. |
| Why | ASR output digit strings need language-appropriate word forms for CHAT transcription. |
| Origin | batchalign2/pipelines/asr/utils.py |
| Still needed? | Yes, permanent. Language-specific numeral systems. |
| Tests | Parameterized tests for English, Spanish, Chinese |
Retirement Assessment
Testable with Current Stanza
These workarounds address specific Stanza model bugs that may have been fixed. Each should be tested by running the trigger input through current Stanza without the workaround:
| ID | Workaround | Test Method |
|---|---|---|
| E3 | English GUM MWT package | Compare default vs GUM package on contractions |
| F3 | French “au” MWT forcing | Check if Stanza recognizes “au” as contraction |
| I1 | Italian “l’” suppression | Check if Stanza still over-expands “l’” |
| I2 | Italian “lei” merge | Check if Stanza still splits “lei” → “le” + “i” |
| P1 | Portuguese “d’água” | Check if Stanza recognizes as contraction |
| J1 | Japanese verb form overrides | Test the current rule set against a curated trigger corpus |
Permanent (CHAT conventions or architectural requirements)
These encode CHAT-specific conventions or language requirements that are independent of Stanza model quality:
| ID | Workaround | Reason |
|---|---|---|
| E1 | Irregular verb database | CHAT %mor -PAST/-PASTP convention |
| F1 | French pronoun case | CHAT %mor case feature convention |
| F2 | French APM nouns | CHILDES French plural convention |
| J3 | Japanese comma → cm | CHAT punctuation convention |
| C1 | Cantonese normalization | Regional dialect convention |
| C2 | Jyutping for FA | Model architecture requirement |
| X1 | MWT dispatch table | Pipeline architecture |
| X2 | ISO code mapping | Code system interop |
| X3 | Number expansion | Language-specific numeral systems |
Mixed (partly convention, partly bug workaround)
| ID | Workaround | Analysis |
|---|---|---|
| E2 | English contraction MWT | Convention (contractions should expand) + bug (Stanza misses some) |
| F3 | French elision/multi-clitic | Convention (elision rules) + patches (Stanza-specific) |
| D1 | Dutch possessive ’s | Convention (not a contraction) + bug (MWT over-expands) |
Recommended Verification Tests
To systematically determine which workarounds are still needed, create a test fixture that:
- Loads a Stanza pipeline for each language
- Runs a curated input through Stanza without workarounds
- Runs the same input with workarounds
- Asserts they differ (proving the workaround is still needed)
These tests should be golden model tests (skipped when models are unavailable) and re-run whenever Stanza is upgraded. If a test passes (outputs agree), the workaround can be investigated for retirement.
Example test structure:
#![allow(unused)]
fn main() {
#[test]
#[ignore] // Requires Stanza models
fn verify_italian_lei_split_still_needed() {
// 1. Run "lei" through Italian Stanza without lei-merge workaround
// 2. Check if Stanza splits it into "le" + "i"
// 3. If it does: workaround still needed
// 4. If it doesn't: mark for retirement
}
}
These tests should be added under crates/batchalign/tests/ (e.g., the
ml_golden test binary) since they require real Stanza inference.
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
%gra Structural Safeguards
Status: Current implementation behavior Last updated: 2026-05-19 22:51 EDT
This page describes the current structural safeguards around generated %gra
tiers in batchalign3 morphotag.
The important implementation fact is narrower than a blanket linguistic “guarantee”:
- generated
%grarelations are validated before they are returned frommap_ud_sentence() - invalid generated structures return
MappingError - the caller skips that utterance instead of serializing the invalid
%gra
That protects against known structural failures such as missing roots, multiple
roots, cycles, invalid head references, and %mor/%gra chunk-count mismatch.
It does not claim that every utterance will successfully map, or that every
successful %gra is linguistically correct. The safeguard is structural.
Where the validation happens
Current implementation (post crate-split):
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs:map_ud_sentence()at line 81 builds%morand%gra.crates/batchalign-transform/src/morphosyntax/gra_validate.rs:validate_generated_gra(&gras)?at line 20 runs before the mapping is returned.- The chunk-count alignment check happens in the same module before
Ok((mors, gras))is returned. MappingError(withInvalidRoot,CircularDependency,InvalidHeadReferencevariants) is the error type, defined in the parenttalkbank_transform::morphosyntaxmodule.
If validation fails, the function returns Err(MappingError) and the
caller can log and skip the utterance rather than writing broken
output. The batchalign side only carries the focused validation
tests at
crates/batchalign/src/chat_ops/nlp/mapping/tests/core_mapping.rs.
What is validated
validate_generated_gra() currently checks:
- there is exactly one non-terminator root
- the generated graph is acyclic
- every head reference points to an existing relation or root
map_ud_sentence() also checks:
%morchunk count matches%grarelation count
These checks are aimed at structural correctness of generated output, not at rating the underlying dependency parse.
Failure behavior
The current contract is:
- valid generated relations continue through injection and serialization
- invalid generated relations fail early with
MappingError - the utterance can be skipped without writing a malformed
%gratier
This is intentionally different from silently emitting structurally broken relations.
Focused test coverage
The current crate has six focused validation tests for generated %gra
structures:
test_validate_generated_gra_accepts_validtest_validate_generated_gra_accepts_head_zerotest_validate_generated_gra_rejects_no_roottest_validate_generated_gra_rejects_multiple_rootstest_validate_generated_gra_rejects_cycletest_validate_generated_gra_rejects_invalid_head
You can list or run that group with:
cargo test -p batchalign validate_generated_gra
Scope
These safeguards cover the %gra structures generated by the current Rust
mapping code.
They do not by themselves guarantee:
- that an utterance will always produce output
- that upstream NLP parses are linguistically correct
- that pre-existing
%gratiers in old data are valid
For release-facing documentation, the accurate claim is:
batchalign3 validates generated %gra structure before emission and skips
invalid generated relations instead of serializing them.
This page last changed: 2026-07-30 (commit 5157a549). The whole book last changed: 2026-09-16 (commit 34d249d8).
Python Version Support
Status: Current Last verified: 2026-08-30 21:00 EDT
Current policy
Standard CPython 3.13 and 3.14 are supported. The package declares
requires-python = ">=3.13", uses PyO3’s abi3-py313 boundary, and CI builds
and runs the Python suite on both versions. Installers and current deployments
default to 3.13 because one explicit operational baseline is easier to
reproduce than whichever compatible interpreter happens to be newest.
Free-threaded Python (3.14t) is not a supported install or deployment
target. Do not infer free-threaded support from the standard 3.14 classifier or
from runtime code that can detect a disabled GIL.
flowchart TD
P["Requested Python runtime"] --> V{"Version and ABI"}
V -->|"CPython 3.13"| S["Supported; deployment default"]
V -->|"CPython 3.14"| S2["Supported and CI-tested"]
V -->|"3.14t / free-threaded"| X["Research only; not supported"]
V -->|"older than 3.13"| R["Rejected by package metadata"]
S --> W["Install platform abi3 wheel"]
S2 --> W
Why free-threaded Python remains research-only
The attraction is real: shared Stanza models can sharply reduce the memory cost of parallel morphotag and utseg work. Earlier measurements on the former pipeline architecture found approximately the same throughput with much less resident memory:
| Scenario | Peak RSS | Files/hour |
|---|---|---|
| GIL enabled, four processes | 13.5 GB | 10,069 |
| GIL disabled, four threads | 3.0 GB | 10,158 |
Those measurements motivate continued research; they do not prove that the current complete BA3 stack is safe on a free-threaded interpreter. The earlier soaks did not cover every supported diarization backend, multi-day idle, interpreter-shutdown stress, signal handling, or the current Rust/Python worker architecture. A prior production kernel-panic precursor also remains a counterexample even though later 75-minute VM and bare-metal soaks did not reproduce it.
The required standard installation includes local Pyannote and its runtime dependencies as well as the default pyannoteAI path. Free-threaded support must therefore cover the complete dependency and command surface; a reduced install is not an alternate supported BA3 edition.
Historical wheel probes
Older reports recorded missing onnxruntime and other ML wheels for then-new
interpreter ABIs. Those tables are historical evidence, not current policy.
The current lock contains ordinary CPython 3.13 and 3.14 artifacts, and CI is
the authority for those two supported interpreter lines. Wheel availability in
a lockfile alone is not enough to promote a free-threaded ABI.
Groundwork retained in the codebase
The following future-facing pieces intentionally remain:
- runtime detection of a free-threaded interpreter;
- distinct memory-budget tables for process and threaded serving;
- thread-safe tokenizer realignment state; and
- harness cleanup of inherited
PYTHON_GILsettings.
They let controlled experiments continue without making an installation or deployment promise.
Promotion criteria for free-threaded Python
Promote a free-threaded interpreter only when all of these are true:
- Every required dependency resolves as a wheel on the platforms we support.
- All released command paths, including local speaker diarization, pass on that interpreter.
- Long-running end-to-end ML soaks show stable memory, shutdown, exception, signal, and idle behavior on isolated hosts.
- The normal CI and release workflows intentionally build, test, and smoke that ABI.
- The deployment runbook selects it explicitly and records the runtime identity; no implicit interpreter upgrade is allowed.
Until all five gates pass:
- use standard Python 3.13 or 3.14 for development;
- use 3.13 for canonical installation and deployment; and
- treat free-threaded Python as a separate experiment whose result cannot alter supported production state.
The existing
freethreaded-danger-probe
repository remains the dedicated soak harness for that research.
This page last changed: 2026-08-30 (commit 0964e762). The whole book last changed: 2026-09-16 (commit 34d249d8).
Rust→Python IPC Type Sync
Status: Current Last updated: 2026-05-19 22:53 EDT
Problem
Rust structs and Python Pydantic models at the worker IPC boundary are
defined independently. Mismatches only surface at runtime, Pydantic
validation errors deep in the pipeline with no indication of which field
changed. This has caused production bugs (e.g., MorphosyntaxBatchItem. special_forms serialization mismatch).
See also: INTERFACE_MAP.md section “10. Worker V2 IPC Schema” for the unified reference to all schema, generated, and conformance-test locations.
Solution: One Source of Truth
Rust types are the source of truth. The JSON Schema is generated from them, and the hand-written Python models are conformance-tested against that schema.
flowchart LR
rust["Rust structs\n(schemars::JsonSchema)"] --> schema["JSON Schema\n(ipc-schema/)"]
schema --> test["Conformance tests\n(test_ipc_type_conformance.py)"]
test --> handwritten["Hand-written Pydantic\n(_types_v2.py, inference/*.py)"]
Both arrows are gated. check_ipc_type_drift.sh fails when the committed
schema no longer matches the Rust types, and the conformance test fails when a
Python model no longer matches the schema. Neither is optional: they run in
make batchalign-ci-python and in CI.
Pipeline
# Step 1: Generate JSON Schema from Rust types
cargo run -p batchalign -- ipc-schema --output ipc-schema/
# Or via the script, which is the same command with the paths filled in
bash scripts/generate_ipc_types.sh
# Step 2: Check for drift (runs in `make batchalign-ci-python` and CI)
bash scripts/check_ipc_type_drift.sh
# Step 3: Check the hand-written Python models against the schema
uv run pytest batchalign/tests/test_ipc_type_conformance.py
What lives where
| Layer | Source of truth | Files |
|---|---|---|
| Rust types | Canonical definitions | crates/batchalign-types/src/worker_v2/, re-exported by crates/batchalign/src/types/worker_v2.rs, plus crates/batchalign/src/morphosyntax/mod.rs |
| JSON Schema | Generated from Rust | ipc-schema/worker_v2/*.json, ipc-schema/batch_items/*.json |
| Hand-written Python | Conformance-tested | batchalign/worker/_types_v2.py, batchalign/inference/*.py |
| Conformance tests | Validates hand-written against schema | batchalign/tests/test_ipc_type_conformance.py |
The worker_v2 layer name is still intentional. V1 remains in-tree as the
frozen worker / _types.py compatibility surface, so the schema directory
and the typed Python overlays keep the versioned namespace until that older
contract is retired together.
Why the Python models are hand-written
A generated Pydantic layer existed under batchalign/generated/ until
2026-08-14, produced by datamodel-codegen from the same schema, with the
stated plan of replacing the hand-written models by adding validators as thin
subclass overlays. It was removed, having been imported by nothing for three
months, and the plan was abandoned rather than deferred, because carrying it
out would have made the Python boundary WORSE:
- Domain types would be lost. Codegen emits
strandfloat. The hand-written models carryLanguageCode,Terminator,WorkerRequestIdV2andFiniteNonNegativeFloat, and this codebase does not accept a bare primitive at a stable boundary. - Validators cannot be generated. Several models enforce relationships a
schema cannot state, such as
end_s >= start_sonWhisperChunkSpanV2and the parallel-array lengths onFaInferItem. extra="allow"onUdWord, which lets unknown Stanza fields through, has no schema expression either.
What the generated layer was for, keeping the two sides in step, is done by the two gates above, and they are cheaper: one representation per language, neither of which is a mirror of the other.
Adding a New IPC Type
When you add a Rust type that will cross the Python boundary:
-
Derive
schemars::JsonSchemaon the Rust struct/enum:#[derive(Serialize, Deserialize, schemars::JsonSchema)] pub struct MyNewPayloadV2 { ... } -
Register it in
crates/batchalign/src/cli/ipc_schema.rs(theipc-schemaCLI subcommand is wired throughcli/mod.rsandcli/args/commands.rs::IpcSchemaArgs):register!(v2, MyNewPayloadV2); -
Add the Python model in the appropriate file (or use generated):
class MyNewPayloadV2(BaseModel): # Fields matching Rust struct ... -
Add a conformance test in
test_ipc_type_conformance.py:def test_my_new_payload(self) -> None: from batchalign.worker._types_v2 import MyNewPayloadV2 schema = _load_schema("worker_v2", "MyNewPayloadV2") _assert_fields_match(schema, MyNewPayloadV2) -
Regenerate schemas:
bash scripts/generate_ipc_types.sh
For talkbank-model types
Types from talkbank-model (e.g., FormType, LanguageResolution) don’t
derive JsonSchema because schemars isn’t a dependency of talkbank-tools.
Use #[schemars(with = "...")] to override the schema with the wire format:
#[schemars(with = "String")]
pub lang: talkbank_model::model::LanguageCode,
#[schemars(with = "Vec<(Option<String>, Option<String>)>")]
pub special_forms: Vec<(Option<FormType>, Option<LanguageResolution>)>,
For types with custom serialization
When a field has #[serde(serialize_with = "...")], the schemars derive
won’t know the wire format. Always pair it with #[schemars(with = "...")]
to describe the JSON shape:
#[serde(serialize_with = "serialize_special_forms")]
#[schemars(with = "Vec<(Option<String>, Option<String>)>")]
pub special_forms: ...
Adding a New Engine
When adding a new ASR/FA/NLP engine to batchalign3, the IPC type sync system helps ensure the Python worker types match:
- Define request/result types in Rust with
JsonSchemaderive - Register them in
ipc_schema.rs - Generate schemas → see the exact field shapes Python must implement
- Write the Python Pydantic model matching the schema
- Add conformance test
This is significantly easier than the previous approach of manually keeping Rust and Python types in sync by reading both codebases.
CI Integration
Wired into CI as:
- name: Verify IPC schema matches the Rust types
run: bash scripts/check_ipc_type_drift.sh
This is wired into the typecheck job of batchalign-python.yml and into
make batchalign-ci-python. It exits non-zero if any Rust type has changed
without the schema being regenerated; the conformance tests catch Python-side
drift. Until 2026-08-14 the script existed and ran nowhere, so a schema stale
against its own source could sit in the tree indefinitely, and the conformance
test would go on checking Python against yesterday’s contract without a word.
Retired: full generation
This page used to end with a plan to replace every hand-written Python IPC type with an import from a generated package, and to delete the conformance tests as redundant once that landed. The plan is retired; the reasons are in “Why the Python models are hand-written” above.
What generalises beyond this case: generation removes drift by removing one of two representations, which is the right instinct. It was the wrong trade HERE because the representation it would have removed is the one carrying the domain types and the validators, and the one it would have kept cannot express either. When the two sides of a boundary are two languages, a conformance test is not a confession that one representation should not exist. It is the only thing that can hold two type systems to one contract.
This page last changed: 2026-09-10 (commit 423245db). The whole book last changed: 2026-09-16 (commit 34d249d8).
Tracing and Debugging
Status: Current Last updated: 2026-09-15 07:21 EDT
This document describes the tracing and debugging strategy across the batchalign3 stack: Rust (batchalign-core PyO3 bridge), Rust (CLI and server control plane), and Python (pipeline engines and worker process).
Server Log File
The server writes its own log file directly, like Nginx or Apache.
When running in --foreground mode (which is how both the daemon
spawn and Ansible start the server), stderr is redirected to
~/.batchalign3/server.log via dup2(2). All tracing output
(WARN and above by default) is captured in this file regardless of
how the process was started.
Log location: ~/.batchalign3/server.log (append mode)
Default level: WARN: captures pipeline timing, cache metrics,
heartbeat warnings, worker crashes, slow queries. Does NOT capture
per-file progress, worker spawn/ready, or routine lifecycle events.
For debugging: Run with -v to get INFO level (worker spawns,
job lifecycle, per-file progress) or -vv for DEBUG (full payload
details). Or set RUST_LOG=info environment variable.
# Normal operation (WARN only):
batchalign3 serve start
# Debugging session (INFO: shows worker spawns, job lifecycle):
batchalign3 -v serve start --foreground
# Deep debugging (DEBUG: shows payloads, IPC):
batchalign3 -vv serve start --foreground
# Override per-module:
RUST_LOG=batchalign::morphosyntax=debug batchalign3 serve start
Log rotation: Not implemented. The log file grows unbounded. For long-running production servers, periodically truncate:
: > ~/.batchalign3/server.log # truncate without restarting
Verbosity Levels
A single -v / -vv / -vvv flag on the CLI controls both Rust tracing and
Python logging across the entire stack.
| Level | Rust (tracing) | Python (logging) | When to use |
|---|---|---|---|
| 0 (default) | WARN | WARNING | Normal operation |
1 (-v) | INFO | INFO | Server start/stop, job lifecycle |
2 (-vv) | DEBUG | DEBUG | Per-file progress, engine boundary data |
3 (-vvv) | TRACE | DEBUG | Full payload dumps (truncated) |
How verbosity propagates
CLI (main.rs)
│
├─ init_tracing(verbose) ← sets Rust filter level
│
└─ serve_cmd::start(args, verbose)
│
└─ PoolConfig { verbose, .. }
│
└─ WorkerConfig { verbose, .. }
│
└─ python3 ... --verbose N ← forwarded to batchalign.worker
│
└─ logging.basicConfig(level=...)
In background mode (batchalign3 serve start without --foreground), the
-v flags are forwarded to the re-exec’d background process.
Engine Boundary Tracing
The highest-risk surface in the stack is the Rust-Python boundary where data crosses serialization layers. This boundary is instrumented at three points:
1. Morphosyntax batch orchestrator (Rust side)
The batchalign-side orchestrator at
crates/batchalign/src/morphosyntax/worker.rs instruments the
extract → infer → inject sequence with debug! traces at each stage
boundary: utterance + word counts going in, response item counts
coming back from the worker, and injection-time counts going out.
(The previous PyO3 ParsedChat callback path
add_morphosyntax_batched_inner in pyo3/src/morphosyntax_ops.rs
was retired in the 2026-03-21 PyO3 slimdown; worker-runtime pyo3
today is worker_protocol.rs + worker_*_exec.rs only.)
2. Python inference module (batchalign/inference/morphosyntax.py)
The batch_infer_morphosyntax function logs:
- Item count and elapsed time at
INFOlevel on completion - Sentence count mismatch warnings at
WARNINGlevel - Stanza batch failure warnings at
WARNINGlevel
3. Worker IPC (crates/batchalign/src/worker/handle/)
Worker spawn, shutdown, health checks, and IPC dispatch are logged
at info! and debug! levels across the
crates/batchalign/src/worker/handle/ submodules (mod.rs,
config.rs, ipc.rs, lifecycle.rs, spawn.rs, protocol.rs).
Worker stderr is captured for crash diagnostics.
Performance
The tracing crate’s debug! and trace! macros cost ~1-5 ns when the
corresponding level is filtered out (the default level is WARN). All
instrumented functions are per-file or per-utterance, never per-word. There is
no measurable performance impact during normal operation.
Python logging.debug() calls are similarly inexpensive when the logger level
is WARNING.
Safe AST Construction
The problem
Raw text from NLP engines (Stanza, Whisper) must be converted to CHAT AST
nodes. Directly constructing AST nodes with Word::new_unchecked bypasses the
lexical validation that the parser would normally enforce, allowing malformed
words into the AST. These silently propagate until pre-serialization validation,
at which point the error is far from the root cause.
Policy
- Always try
DirectParser::parse_word()first: if the text is valid CHAT syntax, the parser returns a properly validatedWord. - Only fall back to
new_uncheckedwhen the input is genuinely unparseable (e.g., ASR returned non-CHAT characters). Log awarn!when this happens. - Never fall back to
new_uncheckedin retokenization: if a Stanza-split token can’t be parsed, keep the original CHAT word unchanged.
Implementation
Three categories of new_unchecked usage have been addressed:
A. ASR transcript construction
(crates/batchalign-transform/src/build_chat/):
ASR engines return raw text that must become CHAT words. The code
tries DirectParser::parse_word() first and only falls back to
new_unchecked with a warn! if parsing fails. Entry points:
build_chat() in build_chat/mod.rs:41 and build_chat_from_json()
in build_chat/bridge.rs:10.
B. Retokenization fallback
(crates/batchalign-transform/src/retokenize/):
When Stanza splits a CHAT word into MWT sub-tokens, each sub-token
must be parsed back into a CHAT Word. try_parse_token_as_word()
at crates/batchalign-transform/src/retokenize/parse_helpers.rs:108
returns Option<Word> instead of always succeeding. On parse
failure, the original word is preserved (no invalid content enters
the AST).
C. Temporary scaffolding: a temporary word is used only as input
to resolve_word_language()
(../chatter/crates/talkbank-model/src/validation/word/language/resolve.rs:137)
and never injected into the AST. This is a documented acceptable use
of new_unchecked.
Injection-time alignment check (crates/batchalign-transform/src/morphosyntax/injection.rs)
Before injecting MOR/GRA tiers into an utterance, the code validates that the number of MOR items matches the number of alignable words extracted from the AST. A mismatch is a bug, it means the extraction or NLP mapping is wrong.
// crates/batchalign-transform/src/morphosyntax/injection.rs: count alignment check
let word_count = extracted.len();
let mor_count = mors.len();
if word_count != mor_count {
tracing::warn!(word_count, mor_count, ...);
return Err(format!("MOR item count ({mor_count}) does not match ..."));
}
This catches problems at the point of injection (close to root cause) rather than deferring to the pre-serialization validation pass.
Debugging Workflows
Diagnosing a morphosyntax failure
-
Run with
-vvto see per-utterance word counts and Stanza I/O:batchalign3 -vv morphotag input/ output/ -
If a specific utterance fails, the
warn!frominject.rswill report the exact word count mismatch and utterance text. -
Run with
-vvv(trace) to see the full JSON payload sent to Stanza and the JSON response (truncated to 500 chars).
Diagnosing a retokenization issue
When Stanza splits a word into MWT sub-tokens and one sub-token is unparseable:
- A
warn!is logged:"Token is not valid CHAT syntax; keeping original word". - The original word is preserved in the AST.
- The MOR cursor advances past the sub-token indices to stay in sync.
Diagnosing an ASR construction issue
When ASR returns text that isn’t valid CHAT:
- A
warn!is logged:"ASR word is not valid CHAT syntax; using unchecked fallback". - The unchecked word enters the AST, this is expected for non-CHAT characters.
- Pre-serialization validation will catch any downstream issues.
Checking worker verbosity
To verify that verbosity reaches Python workers:
batchalign3 -vv serve start --foreground
Worker stderr will show DEBUG-level messages from batchalign.worker and
batchalign.inference.morphosyntax.
Debug Artifact Pipeline (--debug-dir)
The --debug-dir PATH flag (or BATCHALIGN_DEBUG_DIR env var) writes
structured CHAT/JSON artifacts at pipeline stages that are currently wired.
align and transcribe have those producers; accepting the global option on
another command is not a promise that the command emits artifacts. When
--debug-dir is not set, all dump operations are zero-cost no-ops.
The directory is interpreted by the server process. The CLI converts a relative value to an absolute client path before submission, which is correct for direct mode, a loopback server, or an explicitly shared filesystem. It is not a remote artifact-transfer protocol: on a different host the same absolute path may be absent, unwritable, or name unrelated storage. For a remote server, pass an absolute path that is meaningful on that server and retrieve it through normal host access. A future server-owned artifact store should replace this cross-host path convention; current behavior must not be described as copying debug files back to the client.
# Alignment with debug artifacts
batchalign3 align input/ output/ --lang eng --debug-dir /tmp/ba3-debug
# Transcription with debug artifacts
batchalign3 transcribe audio/ output/ --lang eng --debug-dir /tmp/ba3-debug
# Via environment variable (useful for server-side debugging)
BATCHALIGN_DEBUG_DIR=/tmp/ba3-debug batchalign3 transcribe audio/ output/
Architecture
The debug artifact pipeline is built around the DebugDumper struct in
crates/batchalign/src/runner/debug_dumper.rs. It follows a zero-cost
abstraction pattern: when constructed without a directory, every method is an
immediate no-op. When constructed with a directory, methods write artifacts to
disk at each pipeline stage.
graph TD
CLI["CLI: --debug-dir PATH<br>(global_opts.rs)"]
ENV["ENV: BATCHALIGN_DEBUG_DIR"]
CLI --> CO["CommonOptions.debug_dir<br>(options.rs)"]
ENV --> CO
CO --> |"job submission"| JOB["Job.dispatch.options.common().debug_dir"]
JOB --> |"per-file task"| DD["DebugDumper::new(debug_dir)"]
DD --> |"Some(path)"| WRITE["Write artifacts to disk"]
DD --> |"None"| NOOP["Zero-cost no-op"]
How DebugDumper threads through each pipeline
Each pipeline creates its own DebugDumper at the per-file dispatch level,
extracting debug_dir from the job options. The dumper is then threaded through
the pipeline context and called at stage boundaries.
flowchart TB
subgraph "Align Pipeline (fa_pipeline.rs)"
FA_DISPATCH["dispatch_fa_infer()"] --> FA_CTX["FaFileContext { dumper }"]
FA_CTX --> FA1["dump_utr_input()"]
FA1 --> FAR["Rev UTR → dump_rev_evidence()<br>(one per raw evidence key)"]
FAR --> FA2["dump_utr_tokens()"]
FA2 --> FA3["dump_utr_output()"]
FA3 --> FA4["dump_fa_grouping()"]
FA4 --> FA5["dump_fa_group_result() x N"]
FA5 --> FAE["dump_fa_evidence()\n(versioned, fail-closed)"]
FAE --> FA6["dump_fa_output()"]
end
subgraph "Transcribe Pipeline (pipeline/transcribe.rs)"
TX_DISPATCH["dispatch_transcribe_infer()"] --> TX_CTX["TranscribePipelineContext { dumper }"]
TX_CTX --> TX0["Rev stage_asr_infer → dump_rev_evidence()<br>(versioned, fail-closed)"]
TX0 --> TX1["stage_asr_infer → dump_asr_response()"]
TX1 --> TXS0["stage_speaker_diarization<br/>(when dedicated backend is needed)"]
TXS0 --> TXS1["dump_speaker_evidence()<br>(versioned, fail-closed)"]
TXS1 --> TXS2["dump_speaker_turns()<br>(exact consumed turns, fail-closed)"]
TXS2 --> TX2["stage_asr_postprocess<br/>speaker projection + pre-CHAT utseg"]
TX2 --> TXP["UtsegEvidenceSink.write(pre_chat)<br/>(model-backed path, fail-closed)"]
TXP --> TX3["stage_build_chat → dump_post_asr_chat()"]
TX3 --> TX4["stage_run_utseg → dump_pre_utseg_chat()"]
TX4 --> TXE["UtsegEvidenceSink.write(post_chat)<br/>(fail-closed)"]
TXE --> TX5["stage_run_utseg → dump_post_utseg_chat()"]
TX5 --> TX6["stage_run_morphosyntax → dump_pre_morphosyntax_chat()"]
end
Artifact directory layout
For a transcribe job on sample.wav with --debug-dir /tmp/debug:
/tmp/debug/
# Transcribe pipeline artifacts
sample_rev_evidence.json # Rev media/request/cache/projection causal record
sample_speaker_evidence.json # Speaker request/cache/projection causal record
sample.turns.json # Exact normalized dedicated-speaker segments
sample_asr_response.json # ASR tokens + timestamps as the server received them
sample_pre_chat_utseg_evidence.json
# Exact words, assignments, model, policy evidence
sample_post_asr.cha # CHAT after assembly (before utseg)
sample_pre_utseg.cha # CHAT entering the post-CHAT utseg pass
sample_post_chat_utseg_evidence.json
# Post-CHAT words, assignments, model, policy evidence
sample_post_utseg.cha # CHAT after the post-CHAT utseg pass
sample_pre_morphosyntax.cha # CHAT entering morphosyntax
# Align pipeline artifacts (for a file sample.cha)
sample_utr_input.cha # CHAT before UTR injection
sample_utr_tokens.json # ASR timing tokens fed to UTR
sample_utr_output.cha # CHAT after UTR injection
sample_utr_result.json # UTR injection statistics
*-utr-rev-*_rev_evidence.json # Rev UTR media/request/cache/projection records
sample_fa_input.cha # CHAT before FA (after UTR)
sample_fa_grouping.json # FA group plan (time windows, words)
sample_fa_group_0.json # Per-group words + timings
sample_fa_group_1.json
sample_fa_evidence.json # Versioned score/provenance/decision evidence
sample_fa_output.cha # Final aligned CHAT
sample_fa_evidence.json differs from the older best-effort stage dumps: if
artifact collection was requested and this versioned evidence cannot be
serialized or written, the align job fails with a persistence error. Schema
version 2 records group identity/source/cache keys and pre-injection timing,
model score, complete boundary provenance, and the exact typed decisions that
later altered or removed timing. Decisions remain in the sidecar while CHAT
contains no review-tier projection. The post_injection_timings field is
intentionally empty until the complete typed per-word post-processing identity
path is connected.
The fail-closed FA evidence and same-job speaker-turn artifacts are fully
materialized before a temporary file is opened, synchronized, and atomically
renamed into place. On Unix, the containing directory is synchronized too.
When the submitted filename includes directories, the evidence filename adds
a short digest of that full identity after sample; this keeps equal basenames
from distinct corpus branches separate.
What “raw” means for ASR artifacts
*_asr_response.json records the tokens as the server received them. For
engines hosted in the Python worker (FunASR SenseVoice and Paraformer,
Tencent, Aliyun, Qwen) that is AFTER the worker’s provider adapter, which has
already removed markup, dropped punctuation units and paired each unit with
its own timestamp (see the FunASR section of the ASR token pipeline page). It
is not the engine’s untouched output. Rev differs: its provider-shaped
transcript is retained in the Rev evidence cache (see the transcribe guide).
Environment-variable ASR dumps
Two older diagnostics are enabled by environment variables instead of
--debug-dir. The transcribe pipeline reads them in the process that runs
it, which is the server, so set them in the server’s environment rather than
the client shell.
| Variable | Writes | On failure |
|---|---|---|
BA3_DUMP_ASR_PIPELINE=/path/file.json | AsrPipelineTrace: the worker’s tokens, the words after each post-processing stage, and the final utterances. A raw token’s ts and end_ts are written as null when the provider supplied no such endpoint, so a consumer has to handle a missing bound rather than reading a zero that was never reported | Serialization and write failures are ignored |
BA3_DUMP_UTTERANCES=/path/file.json | The post-processed utterances handed to CHAT construction | The file fails with a typed diagnostic error |
Each names a single path, so a multi-file job overwrites it once per file
and only the last file’s dump survives. Prefer --debug-dir, which names
artifacts per input.
Always-on error logging (no --debug-dir needed)
Even without --debug-dir, certain failure modes automatically log diagnostic
data at WARN level. These are zero-cost in the happy path and fire only when
something goes wrong:
| Failure | What is logged | Where |
|---|---|---|
| Utseg pre-validation fails (parse error in CHAT) | Full CHAT text + error details | utseg.rs |
| Whisper returns inverted timestamps | Warning with start/end values | inference/asr.py |
| MOR item count mismatch | Word count + MOR count + utterance text | inject.rs |
| Stanza sentence count mismatch | Expected vs actual sentence counts | morphosyntax.py |
The utseg CHAT dump is particularly important for transcribe pipelines: if ASR post-processing produces CHAT that doesn’t parse cleanly, the full CHAT text is logged so you can see exactly which token caused the parse error, without needing to reproduce the run.
Example: Diagnosing a transcribe-to-utseg failure
This workflow illustrates the debugging path for a job where transcription
succeeds but utseg rejects the CHAT output (like job 696870c7-02b,
maria18.wav).
sequenceDiagram
participant ASR as ASR Inference
participant PP as Rust Post-Processing
participant BC as Build CHAT
participant DD as DebugDumper
participant UT as Utseg
participant LOG as Server Logs
ASR->>PP: raw tokens + timestamps
PP->>BC: utterances
BC->>DD: dump_post_asr_chat(chat_text)
DD-->>DD: write sample_post_asr.cha
BC->>UT: chat_text
UT->>DD: dump_pre_utseg_chat(chat_text)
DD-->>DD: write sample_pre_utseg.cha
UT->>UT: parse_lenient(chat_text)
Note over UT: parse error detected!
UT->>LOG: warn!(chat_text, errors)
UT-->>BC: Err("utseg pre-validation failed")
Without --debug-dir: check server logs for the warn! containing the
full CHAT text and error details.
With --debug-dir: inspect sample_post_asr.cha to see the exact CHAT
that was produced by the transcribe stage. Feed it to the parser locally:
# Reproduce the parse error offline
cargo run -p talkbank-cli -- validate /tmp/debug/sample_post_asr.cha
Example: Diagnosing an FA grouping issue
# 1. Run alignment with debug artifacts
batchalign3 align input/ output/ --lang eng --debug-dir /tmp/ba3-debug
# 2. Inspect the UTR input and tokens
cat /tmp/ba3-debug/sample_utr_input.cha
jq . /tmp/ba3-debug/sample_utr_tokens.json
# 3. Write a test that loads the fixtures and calls inject_utr_timing directly
# (no ML model needed, the tokens are already captured)
Implementation details
DebugDumper struct (runner/debug_dumper.rs):
new(dir: Option<&Path>): enabled dumper or zero-cost no-opdisabled(): test helper, always no-opensure_dir(): lazily creates the directory on first writestem(filename): extracts file stem for artifact naming- Each dump method follows the pattern: check
ensure_dir()→ serialize →fs::write()→ log on failure (never panics)
Threading pattern:
- Job options carry
debug_dir: Option<String>inCommonOptions - Per-file dispatch extracts it:
job.dispatch.options.common().debug_dir - Creates
DebugDumper::new(debug_dir.as_deref().map(Path::new)) - Passes the dumper into the pipeline context struct
- Stage functions call dump methods at transition points
Fine-Grained Cache Overrides (--override-media-cache-tasks)
For experiment-grade control, --override-media-cache-tasks bypasses cache only for
specific NLP tasks:
# Skip UTR ASR cache but keep the FA cache
batchalign3 align input/ output/ --override-media-cache-tasks utr_asr
# Skip both cache-backed align tasks
batchalign3 align input/ output/ \
--override-media-cache-tasks utr_asr,forced_alignment
# Refresh Rev words while reusing paid pyannote evidence
batchalign3 transcribe input/ output/ --diarization enabled \
--override-media-cache-tasks rev_asr_evidence
# Refresh pyannote evidence while reusing Rev words
batchalign3 transcribe input/ output/ --diarization enabled \
--override-media-cache-tasks speaker_diarization_raw_evidence
The cache-backed task names currently exposed by this selective CLI are
utr_asr, forced_alignment, rev_asr_evidence, and
speaker_diarization_raw_evidence. Legacy text-task names are accepted with a
warning but do nothing because morphosyntax, utterance segmentation, and
translation are not cached.
The existing --override-media-cache continues to skip all cache domains.
Internally, CacheOverrides::Tasks(BTreeSet<CacheTaskName>) resolves per-task
via policy_for(CacheTaskName). The typed FaDispatchPlan deliberately keeps
FA and UTR policies in distinct fields; both the initial UTR pass and the retry
fallback consume the UTR field rather than borrowing the FA policy. Transcribe
likewise carries a named TranscribeCachePolicies record, so Rev and speaker
refresh authority cannot be swapped or coupled through one generic policy.
For replay-only experiments, --require-media-cache resolves every
cache-backed task to CachePolicy::RequireCache. Missing evidence becomes an
error before an inference capability is constructed. Clap and HTTP admission
both reject combinations with either refresh flag, so a job cannot carry
contradictory replay and refresh instructions.
Stanza Anomaly Detection
The morphosyntax inference module (batchalign/inference/morphosyntax.py)
detects several classes of Stanza misbehavior:
| Anomaly | Detection |
|---|---|
| Bogus lemma | Lemma is pure punctuation for a word with letters (e.g. 哎呀 → 》) |
| Sentence count mismatch | Stanza returned a different number of sentences than input utterances |
| Batch failure | Stanza raised an exception on a batch of items |
When detected, these are logged at WARNING level. The bogus-lemma check is
in _is_bogus_lemma() and triggers substitution with a "?" lemma rather
than propagating the bad value.
Debugging Async Dispatch with tokio-console
Symptoms this tool answers: the Rust dispatch chain is stuck,
batchalign3 is parked at 0% CPU after a Starting ASR inference
log line, no progress, no errors, and the question is “which
async task is blocked, on which resource, for how long?”
tokio-console shows the live state of every Tokio task plus the
synchronization primitive each task is waiting on. It complements
py-spy (which covers the Python worker side); see
CPU Profiling for the Python-side recipes.
Build the debug-runtime binary
console-subscriber is gated behind a debug-runtime cargo
feature and requires --cfg tokio_unstable at rustc time
(the Tokio runtime instrumentation hooks are unstable APIs).
Production binaries built without this feature carry zero cost:
the dep is not linked and no gRPC server starts.
RUSTFLAGS="--cfg tokio_unstable" \
cargo build -p batchalign --bin batchalign3 --features debug-runtime
Build time on first run includes downloading and compiling the
console-subscriber + tonic + prost dep tree (~3-5 min on a
fast machine, cached thereafter).
Run the workload and attach
# Terminal 1: run any batchalign3 command with the debug-runtime binary.
# The console gRPC server starts on 127.0.0.1:6669 at process startup.
./target/debug/batchalign3 transcribe input/ -o out/ --lang yue \
--asr-engine qwen --engine-overrides '{"qwen_model": "Qwen/Qwen3-ASR-0.6B-hf"}' \
--sequential --no-server -vv
# Terminal 2: install (once) and attach the TUI client.
cargo install tokio-console
tokio-console http://127.0.0.1:6669
What to look for
The TUI has four primary views:
| View | Use for |
|---|---|
Tasks (default t) | List of all live async tasks with state (RUNNING / IDLE / SCHEDULED), tracing::span name, busy / idle / poll counts. Look for a task labeled with the request_id of the stuck operation. |
Resources (r) | Every tokio::sync::* primitive in use: Mutex, Notify, Semaphore, oneshot::Sender/Receiver, mpsc, Barrier. Each row shows how many tasks are waiting on it and for how long. |
Task detail (Enter on a task) | Backtrace of the most recent poll, which resource the task is waiting on, what woke it last. |
Resource detail (Enter on a resource) | Waiter list, exactly which tasks are blocked on this primitive. |
Built-in lints fire automatically in the bottom pane: “task
has been blocked on the same resource for > N seconds”, “task is
busy-polling without yielding”, “many tasks waiting on a single
Mutex”. For the qwen dispatch hang investigation, the relevant
lint signature was “task blocked on oneshot::Receiver for > 30s”
, would fire within the first minute of any reproduction.
Span naming convention
For tokio-console to label tasks meaningfully, the async functions
on the dispatch chain are annotated with #[tracing::instrument]:
| Span name | File | Carries |
|---|---|---|
dispatch_execute_v2_with_progress | worker/pool/dispatch.rs | request_id |
dispatch_gpu_execute_v2 | worker/pool/dispatch.rs | target, lang, request_id |
get_or_create_gpu_worker | worker/pool/mod.rs | target, lang |
execute_v2 | worker/pool/shared_gpu/stdio.rs | pid, request_id |
shared_gpu_reader_loop | worker/pool/shared_gpu/stdio.rs | pid |
write_request / read_response | worker/handle/ipc.rs | pid |
When adding new dispatch surface, add #[instrument(skip_all, fields(...))] with the same convention so the TUI labels stay
useful. skip_all is important, the default #[instrument]
captures every argument’s Debug impl, which is too noisy for
large request payloads.
Attaching from a different host
The gRPC server binds to 127.0.0.1:6669 by default, localhost
only. To attach from a workstation to a fleet host’s batchalign3
process, SSH-forward the port:
ssh -L 6669:127.0.0.1:6669 operator@server
# then locally:
tokio-console http://127.0.0.1:6669
When NOT to use it
- Production binaries. The
tokio_unstablecfg couples to non-stable Tokio APIs; the gRPC server adds a real dep tree; neither is appropriate for production observability. Use the existing OpenTelemetry /tracing-appenderserver-log surface for production. - Memory or UB bugs. Wrong category. Use
memray/dhat-rs/miriinstead. - Subprocess IPC bugs in isolation.
tokio-consolesees the Rust side only. A bug that crosses into the Python worker also needspy-spy dumpon the worker pid, both views together cover the dispatch chain end-to-end.
Debugging Python workers with py-spy
See CPU Profiling for the full reference. Quick recipes:
sudo py-spy dump --pid <worker-pid> # one-shot stack of every thread
sudo py-spy top --pid <worker-pid> # live top
sudo py-spy record --native --subprocesses \
--pid $(pgrep -f batchalign3) -o flame.svg # flame graph
py-spy dump is the first thing to try when a Python worker is
hung at 0% CPU, replaces the old “tail the log and guess”
pattern.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
CPU Profiling
Status: Current Last updated: 2026-09-16 08:18 EDT
How to profile CPU usage across batchalign’s two languages, Python (worker process: ML inference, audio decoding, transcript postprocessing) and Rust (dispatch, FA orchestration, cache, server). Pick the tool that matches the side and shape of the question; both can run on the same host without conflict.
Separate worker startup from inference and shutdown
Measure the ready handshake before blaming test counts or process teardown.
On the development M3 Ultra with CPython 3.13.12, a fresh GPU echo worker
with --force-cpu took 6.15 seconds to become ready at 050225f0.
-X importtime attributed 5.64 seconds to the eagerly imported BERT
utterance module, including Torch and Transformers. After moving model
imports into the loading operation and removing the eager package re-export,
the same probe took 0.88 seconds. Shutdown after its acknowledgement took
0.60 seconds before and 0.21 seconds afterward. These are single local
observations under concurrent corpus work, not a benchmark distribution or
a measured CI speedup.
The lightweight models.utterance.evidence types are available without
loading the model runtime. Model consumers now import
batchalign.models.utterance.infer explicitly; the former package-level
BertUtteranceModel and normalize_utterance_words re-exports are removed,
along with a per-language model-id resolver that no longer exists on the Python
side at all (Rust pins the boundary model and sends it with the worker spawn).
Forced CPU serving returns
before importing Torch to probe CUDA. Ordinary GPU device detection and real
model loading retain their existing behavior.
Reproduce the measurement from the repository root with the same environment and source revision on both sides:
uv run --no-sync python - <<'PY'
import json
import subprocess
import sys
import time
with open("worker-imports.log", "w") as imports:
started = time.monotonic()
with subprocess.Popen(
[sys.executable, "-X", "importtime", "-m", "batchalign.worker",
"--test-echo", "--profile", "gpu", "--force-cpu"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=imports,
text=True,
) as worker:
assert worker.stdin is not None and worker.stdout is not None
try:
ready = json.loads(worker.stdout.readline())
assert ready["ready"] is True
ready_at = time.monotonic()
worker.stdin.write('{"op":"shutdown"}\n')
worker.stdin.flush()
assert json.loads(worker.stdout.readline()) == {"op": "shutdown"}
acknowledged = time.monotonic()
worker.wait(timeout=15)
print({"ready_seconds": ready_at - started,
"ack_seconds": acknowledged - ready_at,
"exit_seconds": time.monotonic() - acknowledged,
"exit_code": worker.returncode})
finally:
if worker.poll() is None:
worker.kill()
worker.wait()
PY
test_worker_import_boundary.py runs in the ordinary Python suite: three
fresh echo subprocesses reject Torch, Transformers, and Stanza imports while
exercising ready, health, and shutdown. It asserts the import boundary rather
than a machine-dependent timing threshold. It loads no models and requires
no network. The separate utterance-loader and serving-mode tests verify that
actual model requests and CUDA/CPU policy still select their intended paths.
Python: py-spy
py-spy is a Rust-implemented sampling profiler that attaches to a
running Python process by PID. It reads interpreter state directly
across process boundaries, requires no code changes, and adds < 5%
CPU overhead on the target.
Install (macOS): brew install py-spy. Cross-platform via
uv pip install py-spy or cargo install py-spy.
py-spy requires sudo on macOS to read another process’s
memory, unless the binary is code-signed for ptrace. For local-
process diagnostics during development, sudo is fine.
The three commands you’ll actually use
# 1. ONE-SHOT STACK DUMP, what's every thread doing right now?
# First thing to try for a hung worker. Replaces "tail the log
# and guess." Returns Python frames + thread names instantly.
sudo py-spy dump --pid <worker-pid>
# 2. LIVE TOP: per-function CPU% updated continuously, like `top`
# but for Python frames. Useful when CPU is high but you don't
# know which path is hot.
sudo py-spy top --pid <worker-pid>
# 3. FLAME GRAPH: record a sampling session and write SVG. The
# canonical answer to "where is time being spent over a sustained
# workload." `--native` includes C/C++ frames (PyTorch, Whisper,
# Stanza native ops). `--subprocesses` follows forked children
# (matters for our parallel morphotag dispatch).
sudo py-spy record -o profile.svg \
--pid $(pgrep -f batchalign3) \
--native --subprocesses
# Open profile.svg in any browser; click frames to zoom.
When to use which
| Symptom | Command |
|---|---|
| Worker hung at 0% CPU; daemon log is silent | py-spy dump: get the Python stack instantly |
| Worker is using 100% CPU but you don’t know why | py-spy top: watch per-function CPU live |
| Sustained slow performance; want to optimize | py-spy record --native: flame graph the workload |
| Multi-worker job; want to see which child is busy | py-spy record --subprocesses: follows forks |
Speedscope alternative
For interactive flame-graph exploration in the browser, use
--format speedscope and open the JSON file at https://speedscope.app.
Doesn’t replace SVG output; complements it for deep dives.
Rust: samply / flamegraph-rs
For the Rust side (dispatch chain, server, CLI), use a Rust-native sampling profiler. Both tools share the perf / dtrace backends and produce roughly equivalent output.
cargo flamegraph (flamegraph-rs)
# Build release binary then profile it under a workload
cargo install flamegraph
cargo flamegraph --release --bin batchalign3 -- \
transcribe input/ -o out/ --lang eng
# Produces flamegraph.svg in the current directory.
samply (interactive)
samply is a samply-rs profiler that opens the trace in the
Firefox profiler UI for interactive exploration. Better for digging
into specific call paths.
cargo install samply
samply record ./target/release/batchalign3 transcribe input/ -o out/
# Opens Firefox profiler with the trace.
Both tools require elevated permissions on macOS (sudo) the same
way py-spy does, for the same reason.
When BOTH halves are slow / stuck
The batchalign dispatch chain crosses a subprocess boundary, Rust parent owns the orchestration, Python workers own ML inference. A “slow transcribe” or “hung pipeline” can be either side. The right move is profile both halves concurrently:
# Terminal 1: launch the workload
batchalign3 transcribe input/ -o out/ --lang yue --engine-overrides '{...}'
# Terminal 2: find the worker pid and dump Python stack
sudo py-spy dump --pid $(pgrep -f batchalign.worker)
# Terminal 3: profile the Rust parent
WORKER_PARENT=$(pgrep -f "batchalign3 transcribe")
samply record --pid $WORKER_PARENT # samply attaches by pid
If the Rust side shows tasks parked on oneshot::Receiver waiting
for a worker response, switch from samply to tokio-console for
the async-task view, see
Tracing and Debugging for the
tokio-console setup and workflow.
What this doc does not cover
- Memory profiling. Use
memray(Python) ordhat-rs(Rust) , separate “How to profile memory” doc when those land. - Allocation hotpath in PyTorch / Whisper / Stanza native code.
Use
py-spy record --nativeto get frames into C/C++ ops; full symbol resolution requires the native libraries’ debug info. - Distributed tracing across processes. That’s OpenTelemetry
via the existing
BATCHALIGN_OTLP_ENABLEenv-var hook inbatchalign3; see Tracing and Debugging.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Debugging Infrastructure
Status: Current Last updated: 2026-08-30 19:35 EDT
Release note: this document describes the current source tree. The fail-closed FA/speaker evidence and content-addressed worker identity added in the same local batch are not present in the currently running Ming servers.
Design Goal
Every worker pipeline failure should be diagnosable from a single error message and a dump file, without requiring ad-hoc probing, injected print statements, or multiple reproduction attempts. The system is designed so that an engineer (or an AI assistant reading error logs) can identify the root cause in one step.
Architecture: Three Layers
Layer 1: Enriched Error Messages (always on)
When a worker request fails, the error message itself carries enough context to identify the problem:
Failed to parse raw Stanza output for item 4
(words: ["euh", "Lisa", "est", "au", "Mexique", ...]):
sentence 0 word 3: missing field `lemma`.
Diagnostics: sentence 0 word 3: field 'upos', field absent
(keys present: ["end_char", "id", "start_char", "text"]).
Stanza's processor likely failed silently for this token.
This tells you:
- Which batch item failed (item 4)
- What words were sent (so you can reproduce with the worker directly)
- Which word in the Stanza output is broken (word 3)
- What fields are present vs missing
- A likely cause (“Stanza’s processor failed silently”)
Implementation: morphosyntax/worker.rs calls diagnose_parse_failure()
from stanza_raw.rs on the error path and includes the diagnostics in both
the tracing::warn! and the user-facing error message.
Layer 2: Always-On Failure Dumps (~/.batchalign3/debug/)
Critical failures write structured JSON dumps to ~/.batchalign3/debug/
regardless of whether --debug-dir was specified:
| Dump file | Trigger | Contents |
|---|---|---|
failed_ipc_{timestamp}.json | Any worker IPC failure (timeout, crash, protocol) | Full request JSON, error type/message, worker PID + label, response fragment |
These dumps are small (word lists, not audio) and the failure rate is low, so disk impact is negligible. They accumulate until manually cleaned.
Why always-on: The cost of NOT having the dump (hours of ad-hoc debugging) far exceeds the disk cost (~10 KB per failure). A production morphotag incident motivated this: the failure was deterministic but required manual package patching to capture the payload.
Layer 3: Opt-In Debug Artifacts (--debug-dir)
When --debug-dir /path/to/dir is passed (or BATCHALIGN_DEBUG_DIR is set),
the DebugDumper writes detailed pipeline artifacts:
Today those stage producers are wired for align and transcribe. The global
CLI option being accepted by another processing command does not mean that
command has a DebugDumper integration. The path is a server-filesystem path;
it is reliable across direct/loopback/shared-filesystem execution, but a remote
server does not copy the resulting files back to the client.
| Artifact | Pipeline | Contents | DebugDumper method |
|---|---|---|---|
{stem}_pre_morphosyntax.cha | Morphotag | CHAT before morphosyntax injection | dump_pre_morphosyntax_chat |
{stem}_utr_input.cha | Align | CHAT before UTR timing injection | dump_utr_input |
{stem}_utr_tokens.json | Align | ASR timing tokens | dump_utr_tokens |
{stem}_fa_input.cha | Align | CHAT before FA | dump_fa_grouping (writes _fa_input.cha) |
{stem}_fa_grouping.json | Align | FA group structure | dump_fa_grouping |
{stem}_fa_group_{n}.json | Align | Per-group FA timings | dump_fa_group_result |
{stem}_fa_evidence.json | Align | Versioned group source/cache identity, pre-injection timing score/provenance, and typed post-inference decisions; fail-closed when requested | dump_fa_evidence |
{identity}_rev_evidence.json | Rev transcribe / align UTR | Versioned provider-media/request/cache/projection identity; collision-resistant and fail-closed | dump_rev_evidence |
{stem}_asr_response.json | Transcribe | Raw ASR output | dump_asr_response |
{stem}_post_asr.cha | Transcribe | CHAT after ASR assembly | dump_post_asr_chat |
These are zero-cost when disabled: DebugDumper methods return
immediately without allocation when constructed without a directory.
The FA and Rev evidence artifacts use a collision-resistant stem: bare input filenames
keep the ordinary stem, while nested submitted identities append a short
BLAKE3 digest of the full filename. Older best-effort artifacts retain their
historical basename-only convention.
Structured Diagnostics (talkbank_transform::morphosyntax::stanza_raw::diagnose_parse_failure)
Instead of relying on raw serde deserialization errors (“missing field
lemma”), the diagnostics function scans Stanza’s raw to_dict()
output and produces structured, actionable reports. Source:
crates/batchalign-transform/src/morphosyntax/stanza_raw.rs:50 for the
StanzaWordDiagnostic struct, :76 for diagnose_parse_failure,
:217 for normalize_word_dict. Consumed by the batchalign-side
worker at crates/batchalign/src/morphosyntax/worker.rs:15,358.
#![allow(unused)]
fn main() {
pub struct StanzaWordDiagnostic {
pub sentence_idx: usize,
pub word_idx: usize,
pub field: String,
pub issue: String,
}
}
Checks performed:
- Missing required fields:
text,lemma,upos,deprel - Null values: Stanza can emit
"lemma": nullwhen a processor fails - MWT Range tokens:
id: [start, end]tokens are expected to lack annotation fields, the diagnostics skip lemma checks for these <pad>sentinels: Stanza emits"deprel": "<pad>"for padding tokens- Type mismatches:
idas unexpected type, string fields as non-string
Each diagnostic includes a human-readable explanation of the likely cause, not just the symptom. This is designed for an engineer (or AI) reading the log to immediately understand what went wrong and where.
How This Maximizes AI-Assisted Debugging
The debugging infrastructure is specifically designed for the scenario where an AI assistant (Claude Code or similar) is analyzing a failure:
1. Single-Message Root Cause
The enriched error message contains all the information needed to identify the root cause without any follow-up queries:
words: ["euh", "Lisa", "est", "au", ...]
sentence 0 word 3: field 'upos' absent (keys: ["end_char", "id", "start_char", "text"])
An AI can immediately determine: “Word 3 is au, which in French triggers
MWT expansion (à + le). The range token has only positional fields.
The English MWT pipeline is being used for French text.”
2. Machine-Readable Dump Files
The JSON dumps are structured, not log-grepped text. An AI can:
- Parse the dump
- Extract the exact batch items
- Construct a reproduction command
- Compare dumps across machines
- Track which items fail consistently
3. Reproducibility Without the Server
The failed_ipc_{timestamp}.json dump includes the full request JSON.
An AI can:
- Read the dump
- Extract the request
- Pipe it to
python -m batchalign.worker --task morphosyntax --lang eng - Compare the output to the dump
This is the foundation for the planned batchalign3 replay tool.
4. Pattern Recognition Across Failures
Because dumps accumulate in ~/.batchalign3/debug/, an AI can scan all
failure dumps to identify patterns:
- “All failures are on
fralanguage items with wordau” - “All failures have keys
[end_char, id, start_char, text]: MWT range tokens” - “Failures only occur in batches >50 items”
Auto-Fix Potential
What Can Be Auto-Fixed Today
The normalize_word_dict() function in stanza_raw.rs already auto-fixes
several Stanza output issues:
| Issue | Auto-Fix | When |
|---|---|---|
"lemma": null | Default to surface text | Always |
"lemma": "" | Default to surface text | Always |
"lemma" absent | Default to surface text | Non-Range tokens |
"upos": null | Default to "X" (unknown) | Always |
"deprel": null | Default to "dep" | Always |
"deprel": "<pad>" | Replace with "dep" | Always |
| Bogus lemma (punct for word) | Replace with surface text | When text has letters |
"id": [n] (single tuple) | Unwrap to n | Always |
What Could Be Auto-Fixed Next
Based on the MWT range token issue discovered on a worker machine:
| Issue | Proposed Auto-Fix | Risk |
|---|---|---|
| Range token missing annotation fields | Insert defaults (upos="X", deprel="dep", lemma="") | Low, range tokens are display-only in CHAT |
| Non-Range token missing ALL annotation fields | Default all fields from surface text | Medium, may mask a deeper Stanza misconfiguration |
What Should NOT Be Auto-Fixed
- Wrong language pipeline used: If French text is processed by the English pipeline, auto-fixing the output hides the routing bug. The correct fix is to route to the right pipeline.
- Worker crash: A crashed worker needs investigation, not retry masking.
- Model version mismatch: If Stanza 2.0 changes output format, patching individual fields hides the real problem.
The principle: auto-fix known engine quirks, but surface routing and
configuration bugs as errors. diagnose_parse_failure() distinguishes
between the two: known quirks (null lemma, pad deprel) produce diagnostics
with “Stanza’s processor likely failed silently”, while structural problems
(missing id, non-object word) produce diagnostics that indicate a deeper
issue.
Planned Extensions
batchalign3 doctor
Pre-flight diagnostic that validates the worker pipeline on the current machine. Sends known test inputs through the actual worker and validates output structure. Catches machine-specific issues (stale models, wrong pipeline, missing processors) before they become production failures.
batchalign3 replay
Takes a failed_ipc_{timestamp}.json dump and replays the exact request
against a fresh worker. Enables:
- Reproduction without the server
- Cross-machine comparison
- Post-fix verification
Trace Store Integration
The FA pipeline already writes structured traces to an ephemeral trace store
accessible via GET /jobs/{id}/traces. The morphosyntax pipeline should
follow the same pattern: collect extraction items, UD responses, and
diagnostics into a MorphosyntaxTrace that the dashboard can display.
File Reference
| File | What |
|---|---|
crates/batchalign-transform/src/morphosyntax/stanza_raw.rs | normalize_word_dict(), diagnose_parse_failure(), StanzaWordDiagnostic |
crates/batchalign/src/morphosyntax/worker.rs | Enriched error path with diagnostics (imports diagnose_parse_failure from talkbank_transform::morphosyntax) |
crates/batchalign/src/morphosyntax/batch.rs | Error-path batch handling |
crates/batchalign/src/runner/debug_dumper.rs | DebugDumper and per-artifact dump methods (see Layer 3 table) |
crates/batchalign/src/worker/handle/protocol.rs:155 | dump_failed_ipc_request() for all IPC failures (called from worker/handle/ipc.rs) |
This page last changed: 2026-08-30 (commit 02214ccc). The whole book last changed: 2026-09-16 (commit 34d249d8).
CHAT Validation Failures
Status: Current Last updated: 2026-09-07 19:45 EDT
This document catalogs how CHAT validation failures arise, how they are handled in BA3 vs BA2, and what the correct behavior should be. It is the reference for any future changes to the pre-validation and post-validation gates.
Background: BA2 Had No Validation Gates
Batchalign2 (baseline 84ad500b) had zero CHAT validation
anywhere in the pipeline:
-
No pre-validation: raw Whisper output was converted directly to a
Document(Pydantic model) with no structural checks. If Whisper returned inverted timestamps, they were silently propagated. If the CHAT parser hit aCHATValidationExceptionduring input parsing, the exception propagated to the dispatch loop, which caught it, logged the traceback, and continued to the next file. -
No post-validation: after morphosyntax/utseg/FA, the output was serialized and written with no roundtrip check. Malformed output was silently written to disk.
-
No intermediate validation: no stage-to-stage validation between ASR assembly, utseg, and morphosyntax.
The result: BA2 almost always produced some output, even when that output was wrong. Users discovered problems only when they opened the file in CLAN.
BA3 Validation Architecture
BA3 introduced two validation gates:
flowchart LR
INPUT["Input CHAT"] --> PRE["Pre-Validation Gate"]
PRE -->|"pass"| PIPELINE["Pipeline Stage<br>(utseg, morphosyntax, etc.)"]
PRE -->|"fail"| HARD_ERR["Hard Error<br>File fails, no output"]
PIPELINE --> POST["Post-Validation Gate"]
POST -->|"pass"| OUTPUT["Write Output"]
POST -->|"fail"| POST_ERR["Hard Error<br>File fails, no output"]
Pre-Validation (hard error)
- Where:
pipeline/text_infer.rs:91-98(single-file path),utseg.rs:129-139(batch path) - What it checks:
validate_to_level(file, parse_errors, level), typicallyValidityLevel::StructurallyComplete(L1) - On failure: returns
Err(ServerError::Validation(...)): the file fails with no output - Rationale: running NLP (Stanza, etc.) on structurally broken CHAT would produce garbage and waste GPU time
Post-Validation (hard error)
- Where:
pipeline/post_validate.rs. Every route from a finished model to bytes a command may write goes throughPostValidated, and possession of one is the proof; the writer seams take the proof rather than aString. - What it checks: whichever judgement the command’s INPUT entitles it to.
A command that admitted its input at a
ValidityLevelis held tovalidate_to_levelat that level plusvalidate_output(file, command). A command that admitted its input at no level (compare, benchmark) is held to PRESERVATION instead: its output is compared against a census of the document it descends from and refused only for what it destroyed, so the input’s own faults travel through and are not charged to the command. - On failure: the file fails with no output.
- This section said “warn-only … writes the output anyway” until
2026-09-07, with the rationale that output is more useful than no output.
That was the behaviour and it was the defect: a
warn!is where lost information goes to look like it was handled, so a file whose%morhad drifted or whose terminator a transform had eaten landed on disk and reported success.
The Problem: Pre-Validation in the Transcribe Pipeline
The transcribe pipeline has an asymmetry:
- ASR inference produces raw tokens
- Rust post-processing converts tokens to utterances
build_chat()assembles a CHAT AST and serializes it- Utseg pre-validates the serialized CHAT: and if it fails L0 (parse errors), the file is a hard error with no output
This means: if build_chat() or to_chat_string() produces CHAT text that
doesn’t roundtrip cleanly through the parser, the entire file fails silently.
The CHAT text that would explain the problem is discarded.
sequenceDiagram
participant ASR as ASR Inference
participant PP as Rust Post-Processing
participant BC as build_chat()
participant SER as to_chat_string()
participant UT as Utseg Pre-Validation
participant OUT as Output
ASR->>PP: raw tokens
PP->>BC: utterances
BC->>SER: ChatFile AST
SER->>UT: chat_text (String)
UT->>UT: parse_lenient(chat_text)
Note over UT: 1 parse error!
UT--xOUT: Hard error, no output written
Note over OUT: CHAT text discarded!
Real-world incidents
| Job | File | Error | Root Cause | Output? |
|---|---|---|---|---|
696870c7 | maria18.wav | [L0] File has 1 parse error(s) | Unknown, CHAT discarded | No |
696870c7 | sastre02.wav | [L0] File has 1 parse error(s) | Unknown, CHAT discarded | No |
696870c7 | maria16.wav | end_s must be >= start_s | Whisper hallucinated inverted timestamps | No |
696870c7 | maria27.wav | end_s must be >= start_s | Whisper hallucinated inverted timestamps | No |
Validity Levels
The validation system uses three levels, each including all checks from lower levels:
| Level | Name | Checks |
|---|---|---|
| L0 | Parseable | No parse errors (clean tree-sitter CST) |
| L1 | StructurallyComplete | L0 + participants, languages, speaker codes, terminators |
| L2 | MainTierValid | L1 + well-formed words, valid timing bullets |
Pre-validation gates use L1 (StructurallyComplete) for text-infer commands
(morphosyntax, utseg, translate, coref). The transcribe pipeline’s utseg stage
inherits this behavior.
What Should Happen
The correct behavior depends on context:
For externally-supplied CHAT (align, morphotag, utseg commands)
Pre-validation is correct as a hard error. The user supplied CHAT that doesn’t meet structural requirements. They should fix their input before spending GPU time on it.
For internally-generated CHAT (transcribe pipeline)
Pre-validation as a hard error is wrong. The CHAT was generated by our own
code, if it has parse errors, that’s a bug in build_chat() or
to_chat_string(), not bad user input. The correct behavior is:
- Always write the output: even with validation warnings, the CHAT is the most valuable diagnostic artifact
- Log the validation errors: so they can be investigated
- Skip the failing stage: if utseg pre-validation fails, skip utseg and write the pre-utseg CHAT as the output
This matches BA2’s behavior (always produce output) while adding the diagnostic logging that BA2 lacked.
Current Mitigations
Two mitigations are in place:
-
Always-on error logging: when utseg pre-validation fails, the full CHAT text is logged at
warn!level (utseg.rs). This makes failures diagnosable from server logs without--debug-dir. -
Debug artifact dumps: with
--debug-dir, the transcribe pipeline writes intermediate CHAT at every stage boundary. Thesample_post_asr.chaartifact captures the CHAT that utseg would have rejected.
Construction-Time Validation as the Primary Guard
The current pipeline guards against the “serializer produces unparseable CHAT” class in three layers:
-
ASR request configuration. For each ASR engine BA3 supports, the request options are chosen to return spoken-form text when possible, matching CHAT’s semantics. For Rev.AI on English and Spanish this means
skip_postprocessing=true(skip Inverse Text Normalization), so numerals /%/ written-form date tokens never appear in the response for those languages. Seecrates/batchalign/src/revai/preflight.rs::skip_postprocessing_hint. -
Fallible
ChatWordTextconstruction, with two distinct outcomes.transcript_from_asr_utterancescallsChatWordText::try_from_langper word, under the utterance’s own language, and admits the word as one of two things:- Language-proved: legal as a word AND legal in that language.
- Structurally-only legal: it parses as a word but breaks a
language-level rule (E220, a digit inside a word; E241, a
reserved untranscribed marker written in the wrong case). The
provider’s surface is emitted verbatim, deliberately: what
the speaker actually said is a human’s call, not the pipeline’s,
and substituting
xxxwould corrupt that marker’s meaning (“a transcriber listened and could not make it out”) across the corpus.
A word that does not parse as a word at all is still a hard
Err(TranscriptBuildError)naming the offending utterance / speaker / language / token.The second outcome is returned, not merely logged:
AsrTranscript::language_invalidcarries every such word with its position, surface, language and chatter error codes. The transcribe pipeline warns with the count and the distinct codes, and writes<stem>_language_invalid_words.jsoninto the run’s debug artifacts. Before 2026-09-03 this fact went only to atracing::warn!, so a knowingly-invalid transcript was indistinguishable at the type level from a clean one, and two tokens (Www, E241;b2, E220) reached a corpus with no record that the gate had already caught them.Language- and engine-agnostic: for languages where Rev.AI ignores
skip_postprocessing(everything other than en/es), or for a different ASR provider with different behavior, the pipeline either produces valid CHAT, or says exactly which tokens it could not prove legal, or fails loudly at an unparseable token. -
Rich L0 validation-gate error message.
validate_to_level(&ChatFile, parse_errors: &[ParseError], level)surfaces the first error’s code, byte span, and offending text excerpt, not just a count. Any residual malformation that does slip through still surfaces actionable diagnostics.
These layers are complementary. Layer 1 makes the common English/Spanish path Just Work without any post-processing round trip. Layer 2 prevents a silent regression if anything about layer 1 changes, different engine, different flag, different language. Layer 3 ensures that if layer 2 does fail, the user sees exactly what broke.
Future Work
-
Transcribe pipeline resilience: change the transcribe pipeline’s utseg and morphosyntax stages to catch validation errors, log them, and emit the pre-stage CHAT as the output instead of failing the file. This is the single most impactful change, it converts hard failures into degraded-but-usable output.
-
Validation error classification: distinguish between “structurally broken input” (user’s fault, hard error) and “roundtrip failure in our serializer” (our bug, should always emit output).
-
Dashboard error display: surface the CHAT text and validation errors in the dashboard job detail view, not just the error message string.
File Reference
| File | Role |
|---|---|
../chatter/crates/talkbank-model/src/pipeline.rs | ValidityLevel enum |
../chatter/crates/talkbank-transform/src/validate.rs | validate_to_level(), validate_output() |
crates/batchalign/src/pipeline/text_infer.rs | Single-file cached text pipeline with pre/post gates |
crates/batchalign-transform/src/utseg.rs | Batch utseg path with pre-validation + CHAT dump |
crates/batchalign/src/pipeline/transcribe.rs | Transcribe pipeline stages (ASR → utseg → morphosyntax) |
crates/batchalign/src/runner/debug_dumper.rs | DebugDumper: zero-cost artifact writer |
batchalign/inference/asr.py | Whisper timestamp clamping |
This page last changed: 2026-09-09 (commit 8f4ed0f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Apple MPS Workarounds
Status: Current Last updated: 2026-07-17 09:12 EDT
MPS Exclusion Policy
MPS is excluded from ALL model loaders. All GPU-profile workers (ASR, FA, Speaker) run on CPU when CUDA is unavailable. MPS is never used, on any fleet machine.
Why: confirmed unsafe behavior
FA workloads on MPS can produce AGXG14X shutdown stalls on Apple
Silicon hardware. The failure path goes through
batchalign_core::worker_fa_exec::run_wave2vec_like →
at::mps::MPSStream::executeMPSGraph, with very large memory
footprint, low free disk, ffmpeg No space left on device failures
while building media-cache WAVs and FA temp PCM, and follow-on shared
GPU worker crashes.
That is enough to treat MPS as unsafe by default.
There is no user-space mitigation for the AGX deadlock path. You cannot:
- Time out the operation (kernel mutex, not user-space)
- Kill the stuck process (unkillable zombie in kernel sleep)
- Break the compute into smaller chunks (deadlock can trigger on normal 5-30s chunks)
- Use a subprocess watchdog (PyTorch MPS silently hangs or SIGSEGVs in forks: pytorch/pytorch#178037)
MPS exclusion alone is not sufficient: long FA jobs can fill both
~/Library/Application Support/batchalign3/media_cache/ and
/var/folders/.../fa_v2/audio/ if internal free space is already low.
Apple-performance work must therefore include cache cleanup and
temp-space admission control, not just device selection.
Ecosystem evidence
No major ML project defaults to MPS:
| Project | MPS status |
|---|---|
| OpenAI Whisper | PR #382 to enable MPS was never merged, users reported crashes, MPS slower than CPU |
| faster-whisper | MPS not supported at all (ValueError: unsupported device mps) |
| pyannote-audio | MPS produces wrong timestamps (#1337, closed wontfix); kernel crashes on M4 (#1886) |
| whisper.cpp | Uses Metal API directly, bypassing PyTorch MPS entirely |
| PyTorch Lightning | MPS marked “experimental” |
Representative open PyTorch MPS issues:
- #178497:
sum,mean,count_nonzerogive wrong results, confirmed by PyTorch team as originating in Apple’s MPS framework. Closed 2026-05-14 as completed; fix landed onmainafter the v2.12.0 branch cut, so it ships in v2.13, not v2.11 or v2.12. - #179352:
scaled_dot_product_attentionincorrect for large batches (cosine similarity 0.49 vs CPU) - #154329: memory leak (~1 MB/sec) confirmed on M4 Max/Studio
- #144634:
torch.mps.synchronize()hangs on error, Apple engineer acknowledged, still open
Apple has not publicly acknowledged the AGXG14X kernel deadlock.
Deadlock and hang categories
Two PyTorch issues carry the dual labels module: mps + module: deadlock:
-
pytorch/pytorch#144634,
torch.mps.synchronize()hangs on shader fault. Filed by PyTorch maintainer@malfet: “fewattempt[s]to reproduce the same resulted in system hang.” Apple engineer acknowledged, still open. -
pytorch/pytorch#162872,
Event.synchronize()deadlocks beforeelapsed_time(). Reproduced on M4 Pro. Simple timing code hangs permanently.
Additionally:
-
pytorch/pytorch#178037, MPS silently hangs or SIGSEGVs in forked subprocesses. Unlike CUDA, MPS had no
_lazy_init()check in forked children. This rules out the “watchdog subprocess” mitigation strategy. -
The MLX project documented macOS GPU watchdog kills at ml-explore/mlx#3267: the error
kIOGPUCommandBufferCallbackErrorImpactingInteractivityfires when a Metal command buffer blocks WindowServer compositing for too long. An undocumented env varAGX_RELAX_CDM_CTXSTORE_TIMEOUT=1exists but is unsupported; MLX maintainers marked this “wontfix.”
Silent correctness failures
The most insidious MPS problem is not crashes but silently wrong results:
- A class of issues are labeled
module: mps+module: correctness (silent). - Reductions (
sum,mean,nansum,trace,count_nonzero) give wrong results when called in rapid succession, confirmed by PyTorch team as originating in Apple’s MPS framework itself, not PyTorch (#178497) scaled_dot_product_attentionreturns incorrect output for large batch × sequence products, cosine similarity drops to 0.49 vs CPU (#179352)- “Catastrophically wrong gradients” (1,000×-100,000× too large) when total elements exceed 32K (#177116)
torch.multinomialcrashes with SIGSEGV on MPS for larger tensors (#178579, closed 2026-04-16 as completed; fix landed onmainafter the v2.12.0 branch cut, so it ships in v2.13, not v2.11 or v2.12)uint16/uint32/uint64binary ops produce garbage valuesComplexFloatdtype not supported at all
For an ASR/FA pipeline, silent attention bugs mean wrong transcriptions and wrong alignments with no error. This is arguably worse than a crash.
Performance on Apple Silicon
Academic benchmarking (arxiv:2511.05502) found that for LLM inference on Apple Silicon:
- MLX achieves highest throughput (~230 tok/s)
- llama.cpp excels for single-stream inference
- PyTorch MPS ranked last among 5 frameworks tested (~7-9 tok/s)
- PyTorch MPS “remains limited by memory constraints on large models”
For Whisper specifically, OpenAI Whisper PR #382 found MPS was slower than
CPU on Apple Silicon (5.25s vs 3.26s). The PYTORCH_ENABLE_MPS_FALLBACK
path was “20× slower than CPU alone” because unsupported ops bounce between
GPU and CPU with expensive data transfers.
Apple’s response
- Apple engineer
@jhavukainenis tagged on PyTorch MPS deadlock issues (#144634, #162872) but responses have been limited to “I’ll need to consult a colleague.” - Apple’s own
tensorflow-metalplugin had GPU hangup issues; v0.5.1 fixed “multiple memory leak issues leading to GPU hangups.” Users reportedIOGPUDevice::new_resource: PID likely leaking IOGPUResource (count=200000). - macOS Sequoia added native non-contiguous tensor support in Metal,
fixing a class of silent correctness bugs. Later macOS releases
introduced Metal 4 with
MTLTensor, but no stability improvements for ML compute workloads were announced. - The GPU watchdog timer that kills long compute is by design; there is no official mechanism to disable it.
Current device selection per module
| Module | Device order | Dtype |
|---|---|---|
fa.py (Whisper FA) | CUDA → CPU | CUDA: float16; CPU: float32 |
fa.py (Wave2Vec FA) | CUDA → CPU | float32 |
asr.py (Whisper ASR) | CUDA → CPU | CUDA: float16; CPU: float32 |
speaker.py | CUDA → CPU | , |
_main.py (serving) | : | GPU profile = concurrent on CUDA only; sequential on CPU |
Worker concurrency impact
GPU-profile workers previously used ThreadPoolExecutor(gpu_thread_pool_size)
for concurrent inference, relying on PyTorch releasing the GIL during GPU
kernels. On CPU, this causes thread oversubscription: each thread’s PyTorch ops
use all cores via OpenMP, so 4 threads × 24 cores = 96 threads
fighting for 24 cores on an M3 Ultra-class CPU.
Fix: GPU-profile workers now serve sequentially on CPU (one request at a time,
all cores per request). gpu_thread_pool_size in server.yaml takes effect
only when CUDA is available.
Implications for platform strategy
MPS exclusion means Apple Silicon machines run all ML inference on CPU. On Apple Silicon (e.g. an M3 Ultra Mac Studio), Whisper, Wav2Vec2, and Stanza all run on CPU; cache/temp-space pressure can dominate host behavior on long jobs.
The code is CUDA-ready: all model loaders select CUDA first when
available, gpu_thread_pool_size activates, and the worker profiles
are designed for GPU concurrency. A Linux + NVIDIA-GPU deployment
restores GPU acceleration, enables concurrent GPU serving, supports
float16/bfloat16 inference, runs Pyannote speaker diarization on GPU,
and eliminates the MPS-related complexity below.
Hardware Limitations
Metal (Apple’s GPU framework) does not support:
| Type | Status | PyTorch behavior |
|---|---|---|
| bfloat16 | Not in Metal spec | Crashes, wrong results, or TypeError depending on operation |
| float64 | Not in Metal spec | TypeError: Cannot convert Double to MPS |
| int64 | Not in Metal spec | Crashes on some ops (e.g. abs_out_mps) |
| complex128 | Not in Metal spec | Conversion failure |
These are hardware/framework limitations, not PyTorch bugs. No fix is expected.
Per-Module Workarounds
ASR: Whisper (inference/asr.py)
if device.type == "mps":
asr_dtype = torch.float32 # not bfloat16
Whisper ASR uses bfloat16 on CUDA for speed. On MPS, this crashes with Metal
assertion failures. We force float32. A second fallback path also forces
float32 on MPS for older transformers versions that don’t accept bfloat16
at all.
The HuggingFace Transformers Whisper pipeline requires
attn_implementation="eager" on MPS, the SDPA attention path broke MPS in
transformers v4.40.0.
Forced Alignment: Whisper FA (inference/fa.py)
if device.type == "mps":
torch_dtype = torch.float32 # not float16
Same pattern as ASR. Whisper FA uses float16 on CUDA, float32 on MPS/CPU.
Forced Alignment: Wave2Vec FA (inference/fa.py)
model = bundle.get_model()
if device.type == "mps":
model = model.float() # Force float32
model = model.to(device)
The torchaudio MMS_FA bundle’s default parameters can include bfloat16 ops
on MPS. Under concurrent load with large audio files (200+ MB video → WAV →
inference), this causes worker crashes that surface as Broken pipe (os error 32). The .float() call converts all parameters to float32 before moving to
device.
Speaker Diarization (inference/speaker.py)
return "cuda" if torch.cuda.is_available() else "cpu"
MPS is excluded from diarization because:
- Pyannote on MPS produces wrong timestamps (pyannote/pyannote-audio#1337, closed as wontfix). Kernel crashes also reported on M4 (#1886).
- NeMo is CUDA-only by design, no MPS support at all.
The device selector (_device_for_speaker_runtime) returns "cuda" or
"cpu", never "mps".
Device Policy (batchalign/device.py)
The BATCHALIGN_FORCE_CPU environment variable (or DevicePolicy(force_cpu=True))
forces all model loaders onto CPU. This is the escape hatch when MPS causes
problems that dtype coercion alone can’t fix.
The MPS opt-in (--allow-mps, added 2026-07-17)
MPS is excluded by default (the April 2026 AGX kernel deadlock hard-stalled machines, with no known driver fix), but the failure proved RARE in a year of fleet use, so the brave can opt in with a real CLI flag:
batchalign3 --allow-mps align ...
The flag threads end to end exactly like --force-cpu: CLI to dispatch to
the daemon (whose daemon.json records the resolved value, so a running
daemon restarts when the flag changes) to each worker’s own --allow-mps
argument to the typed DevicePolicy. allow_mps: true in server.yaml
sets it for a standing server. (The BATCHALIGN_ALLOW_MPS=1 environment
variable exists only as the library-level seam for embedding callers; the
flag is the interface.)
Selection order becomes CUDA > MPS > CPU; --force-cpu conflicts with and
overrides it. What the opt-in does and does not change:
- Model dtypes stay float32 on MPS (every loader’s non-CUDA branch): fp16-on-MPS caused the Feb/Mar 2026 corruption incidents and remains off.
- The speaker (diarization) stage never uses MPS, opt-in or not: Pyannote emits wrong timestamps on MPS (upstream wontfix), which is a correctness bug, not a stability trade.
- A one-per-process warning is logged when MPS actually engages, naming the rare-stall risk, so a wedged machine is not a mystery.
- The flag is per-process and inherited by spawned workers, same as
BATCHALIGN_FORCE_CPU. Do not set it on unattended fleet servers; it is for interactive/supervised runs where a reboot is cheap.
Memory Issues on MPS
MPS has well-documented memory management problems:
- Memory leaks during inference: usage climbs steadily, eventually OOM (pytorch/pytorch#154329, #145374)
- OOM with memory available: MPS cache doesn’t release when it should (pytorch/pytorch#105839)
sysinfo::available_memory()on macOS undercounts, reports only free + purgeable, missing reclaimable file cache. On a Fleet-tier host (≥ 256 GB RAM, heavy I/O), this can underreport by tens of GB. No fix exists because macOS doesn’t expose aMemAvailableequivalent like Linux.
Mitigations:
torch.mps.empty_cache(): call periodically during long-running inferencePYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0: disables MPS memory limit (risks system instability, not recommended for production)- Our Rust server’s memory gate uses
sysinfo::available_memory()with a configurable threshold (default 2048 MB,0to disable). Idle worker bypass prevents deadlock when loaded workers hold RAM.
Upstream Issues to Track
Check these periodically. If an issue is resolved, we may be able to remove the corresponding workaround.
bfloat16
| Issue | Status | What to do if fixed |
|---|---|---|
| pytorch/pytorch#141864 | Closed (won’t fix) | N/A, Metal lacks native bfloat16. Would require Apple hardware/firmware change. |
| pytorch/pytorch#136624 | Closed | Specific to torch.arange; the broader bfloat16 gap remains. |
| pytorch/pytorch#104191 | Closed | Specific to torch.embedding. |
Verdict: bfloat16 on MPS will not be fixed. Our float32 workarounds are permanent.
Memory
| Issue | Status | What to do if fixed |
|---|---|---|
| pytorch/pytorch#105839 | Open | MPS OOM with memory available. If fixed, we could remove empty_cache() calls. |
| pytorch/pytorch#154329 | Open | MPS memory leak during inference. Critical for long-running server. |
| pytorch/pytorch#145374 | Open | MPS memory leak in LSTM iterations. |
| pytorch/pytorch#114096 | Open | Leak when converting device+type simultaneously via .to(). |
Whisper
| Issue | Status | What to do if fixed |
|---|---|---|
| huggingface/transformers#31408 | Closed | SDPA broke MPS in v4.40.0. Our attn_implementation="eager" workaround is for this. Check if later versions fixed SDPA on MPS. |
| pytorch/pytorch#141774 | Closed 2024-11-29 (completed) | Autocast fails for scaled_dot_product_attention on MPS. Fixed upstream; our attn_implementation="eager" workaround may no longer be required when MPS is re-evaluated. |
| pytorch/pytorch#162092 | Open | Voxtral (Whisper variant) produces gibberish on MPS. |
Speaker Diarization
| Issue | Status | What to do if fixed |
|---|---|---|
| pyannote/pyannote-audio#1337 | Closed (wontfix) | Wrong timestamps on MPS. If reversed, we could enable MPS for diarization. |
| pyannote/pyannote-audio#1886 | Open | Kernel crash on M4 with MPS. |
MPS Correctness
| Issue | Status | What to do if fixed |
|---|---|---|
| pytorch/pytorch#134534 | Open | Model returns wrong tokens on MPS vs CPU. Broad correctness concern. |
Checklist for New Model Loaders
When adding a new inference module that loads a PyTorch model:
- Never use MPS. Our standard device selection is CUDA > CPU. MPS is permanently excluded due to kernel-level deadlocks.
- Use
force_cpu_preferred()as the first check: respect the operator’s CPU override. - Test device selection: add a parametrized test with
(force_cpu, cuda_available, mps_available)that verifies MPS availability is ignored and CPU is selected when CUDA is unavailable. - Use
float16on CUDA,float32on CPU: unless the model specifically requires a different dtype.
Test Coverage
Device selection is covered by parametrized tests that verify MPS is ignored and CPU is selected when CUDA is unavailable:
| Test | File | What it verifies |
|---|---|---|
test_load_whisper_fa_selects_device_and_dtype | tests/pipelines/fa/test_fa_inference.py | Whisper FA: CPU when MPS-only, float16 on CUDA, float32 on CPU |
test_load_wave2vec_fa_selects_expected_device | tests/pipelines/fa/test_fa_inference.py | Wave2Vec FA: CPU when MPS-only |
test_load_wave2vec_fa_forces_float32_on_mps | tests/pipelines/fa/test_fa_inference.py | Wave2Vec FA: no MPS-specific .float() needed (MPS excluded) |
test_load_whisper_asr_ignores_mps_and_applies_cantonese_overrides | tests/pipelines/asr/test_asr_inference.py | ASR: CPU when MPS available, Cantonese config still applied |
TestGpuHasCudaDevice (4 tests) | tests/test_worker_serving_mode.py | CUDA detection helper: force_cpu interaction |
TestServingModeSelection (6 tests) | tests/test_worker_serving_mode.py | GPU profile: sequential on CPU, concurrent on CUDA only |
| Speaker device selection test | tests/pipelines/speaker/test_speaker_inference.py | Speaker: CUDA > CPU, MPS never selected |
This page last changed: 2026-07-29 (commit f4f12680). The whole book last changed: 2026-09-16 (commit 34d249d8).
Arena Allocators (Evaluation: Not Used)
Status: Current
Status: Evaluated and rejected (2026). We evaluated
bumpaloarenas at several allocation hot spots and concluded the simpler patterns below (scratch buffers, flat tables, dense Vecs) provide equivalent savings with less API complexity. Arena allocators are not used in this codebase.
Why Not bumpalo
- Lifetime rigidity: Arena references can’t escape the owning function, but our processing pipelines return intermediate results across function boundaries.
- Marginal gains: The targeted patterns below already eliminate hot spots.
Adding
bumpaloon top yielded < 2% additional improvement in benchmarks. - API friction:
bumpalo::collections::VecandStringare different types fromstd, requiring conversion at every boundary.
Patterns We Use Instead
Scratch Buffer Reuse
Pre-allocate and reuse buffers instead of allocating fresh each iteration:
let mut prev: Vec<usize> = (0..=pay_len).collect();
let mut cur = Vec::with_capacity(pay_len + 1);
for ref_item in reference {
cur.clear();
// ... fill cur ...
std::mem::swap(&mut prev, &mut cur);
}
Used in dp_align/mod.rs (Hirschberg alignment).
Prepare Fuzzy Comparison Inputs Once
Word alignment admits fuzzy inputs as PreparedFuzzyWord, which retains the
original spelling and computes its Unicode lowercase form once. The DP core
accepts the comparison policy associated with its element type: unprepared
strings and characters cannot request fuzzy comparison. Exact and ASCII-only
paths retain their allocation-free comparisons. Result keys use the original
spelling, and the existing ASCII equality fast path remains authoritative.
This trades storage proportional to the input text for eliminating repeated lowercase allocations inside DP cells. It does not change the quadratic comparison count or the alignment tie-breaking rules. Mostly matching inputs can have little repeated work to save, so this is not a universal speedup.
The ignored fuzzy_comparison_performance_probe in the existing library test
binary compares prepared and legacy comparisons in alternating order. Run it
with cargo test -p batchalign-transform --lib fuzzy_comparison_performance_probe -- --ignored --nocapture. It reports time
inside the test, excluding compilation, and has no CI timing threshold. The
separate differential test compares complete plans, including Unicode cases,
original spellings, and threshold edge behavior.
Flat Table Instead of Vec-of-Vec
// 1 allocation instead of rows + 1
let mut dp = vec![(0usize, Action::Start, 0, 0); rows * cols];
let idx = |r: usize, c: usize| r * cols + c;
Dense Index Vec Instead of HashMap
When keys are dense integers 0..N, a Vec is faster than a HashMap:
let mut mapping: Vec<SmallVec<[usize; 4]>> = vec![SmallVec::new(); num_words];
mapping[word_idx].push(token_idx);
Avoiding Allocation Entirely
The character explosion in retokenization uses &[char] directly instead of
converting each character to a String for DP alignment. The DP aligner
accepts &[char] via the Alignable trait.
Guidelines
- Start with the cheapest fix. Reuse a buffer, use a flat table, avoid the allocation entirely.
- Don’t add an arena for < 10 allocations per call.
- Benchmark with realistic inputs. The allocator is rarely the bottleneck, I/O, parsing, and NLP inference dominate wall-clock time.
This page last changed: 2026-09-06 (commit 9496fe2d). The whole book last changed: 2026-09-16 (commit 34d249d8).
Maturin Build and PyO3 Dependency Surface
Status: Current Last updated: 2026-08-30 21:00 EDT
Overview
The batchalign_core Python extension is built by maturin from crates/batchalign-pyo3/Cargo.toml.
The crate has exactly one feature gate: extension-module (required by PyO3 for
cdylib linking). No other features exist, the extension is always slim.
Dependency Graph
batchalign-pyo3 (the .so)
|
+-- batchalign-types (newtypes, worker IPC types)
+-- talkbank-transform (Cantonese ASR projection, Cantonese normalization,
| tokenizer realignment, asr_postprocess,
| morphosyntax, text task normalization)
+-- pyo3, numpy, serde, serde_json, tracing, tracing-subscriber
That’s it. ~319 crates in the full dependency tree. No server, no CLI, no Rev.AI, no talkbank-model, no talkbank-parser.
Why each dependency exists
| Crate | Used by pyo3 for | Could be removed? |
|---|---|---|
batchalign-types | Domain newtypes, worker IPC types (ExecuteRequestV2, etc.) | No, core shared types |
talkbank-transform | Cantonese ASR projection, Cantonese normalization, tokenizer realignment, coref types, text result normalization, morphosyntax sentence mapping (post-crate-split home of all the formerly-batchalign-side worker logic) | No, worker-side Rust logic |
pyo3 / numpy | PyO3 bridge, NumPy array handling for audio | No, fundamental |
serde / serde_json | JSON serialization for IPC | No, fundamental |
tracing / tracing-subscriber | Worker-process logging (env-filtered) | No, required for diagnostics |
What was removed
| Removed dep | Why |
|---|---|
batchalign (+ transitive batchalign) | CLI binary shipped as package data instead of compiled into .so |
batchalign-revai | Dead code, server uses Rev.AI directly |
talkbank-model | Only used by deleted ParsedChat class |
talkbank-parser | Only used by deleted parse helpers |
indexmap, thiserror | Only used by deleted standalone functions |
CLI Binary Distribution
The batchalign3 CLI is a standalone Rust binary (crates/batchalign).
It is not compiled into the .so extension. Instead:
- GitHub Release wheels: The binary is pre-built and included as package
data at
batchalign/_bin/batchalign3. The console-script entry point (batchalign/_cli.py) finds and execs it. BA3 itself is not published to PyPI. - Dev checkout:
_cli.pyfalls back totarget/debug/batchalign3orcargo run -p batchalign.
This eliminates the old cli-entry feature gate that dragged 741 extra crates
(the entire server stack) into the extension build.
The wrapper is intentionally thin, but it does carry two load-bearing runtime handoffs into the Rust binary:
BATCHALIGN_PYTHON: preserve the interpreter/venv that owns the installed worker packageBATCHALIGN_SELF_EXE: preserve the actual packaged Rust binary path so server/daemon re-exec paths do not have to infer it from the Python console-script launcher
Build Commands
# Development rebuild (debug, fast, incremental)
uv run maturin develop -m crates/batchalign-pyo3/Cargo.toml \
-F pyo3/extension-module
# Or via the Makefile target chain (build wheel + install into the dev env):
make batchalign-build-wheel
make batchalign-python-prepare
# Release wheel for deployment
cargo build --release -p batchalign --bin batchalign3
cp target/release/batchalign3 batchalign/_bin/batchalign3
uv run maturin build --release \
-m crates/batchalign-pyo3/Cargo.toml \
-F pyo3/extension-module --out dist/
# Check compilation without building wheel
cargo check --manifest-path crates/batchalign-pyo3/Cargo.toml
What NOT to do
-
Do not add server deps to pyo3. The extension is for the worker process. If the server needs Rust functionality, use
batchalignorbatchaligndirectly, not through pyo3. -
Do not vendor types. Use path dependencies.
batchalign-typesis the single source of truth for domain newtypes and worker IPC types. -
Do not add feature gates. The extension should always build the same way. If something is optional, it probably doesn’t belong in pyo3.
-
Do not compile the CLI into the .so. The binary is shipped as package data. If you need to change how the CLI is invoked, modify
_cli.py. -
Do not move orchestration into
_cli.py. The wrapper may pass runtime hints into Rust, but the actual CLI/server behavior still belongs to the Rust binary.
Verification checklist
After any dependency change to crates/batchalign-pyo3/Cargo.toml:
cargo check --manifest-path crates/batchalign-pyo3/Cargo.toml
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
uv run maturin develop -m crates/batchalign-pyo3/Cargo.toml -F pyo3/extension-module
uv run batchalign3 --help
uv run pytest
This page last changed: 2026-08-30 (commit 0964e762). The whole book last changed: 2026-09-16 (commit 34d249d8).
Tauri + React Dashboard
Status: Current Last updated: 2026-07-30 18:21 EDT
Overview
Batchalign3 dashboard delivery uses:
frontend/as the canonical React UI.apps/dashboard-desktop/as the Tauri desktop shell.
The frontend serves two surfaces:
/process: End-user processing flow. Researchers pick a command, choose files via native folder picker, and watch SSE-driven progress. Default landing page in desktop mode./dashboard: Fleet monitoring for power users. Real-time job status, file progress, error grouping, algorithm visualizations.
The Tauri shell stays thin: a small set of custom commands for file discovery,
config I/O, and local server lifecycle plus two plugins (dialog for file
pickers, shell for opening folders). All shared UI logic lives in the React
frontend, which now consumes shell-only capabilities through an explicit
desktop runtime seam instead of scattering raw Tauri imports across hooks and
components.
The following diagram shows the component hierarchy across both surfaces and the Tauri backend IPC boundary.
flowchart TD
subgraph "React Frontend (frontend/src/)"
subgraph "/dashboard: Fleet Monitoring"
dash["DashboardPage"]
dash --> joblist["JobList"] & workers["WorkerProfilePanel"] & mem["MemoryPanel"] & vitals["VitalsRow"]
joblist --> jobcard["JobCard\n(status badge, progress)"]
detail["JobDetailPageView"]
detail --> filetable["FileTable\n(directory-grouped rows)"] & errors["ErrorPanel\n(error groups by code)"] & stage["PipelineStageBar\n(5 phases)"]
end
subgraph "/process: End-User Processing"
process["ProcessPage"]
process --> cmdpicker["CommandPicker\n(2x3 grid)"]
process --> folder["FolderPicker\n(native dialog)"]
process --> form["ProcessForm"]
process --> progress["ProcessingProgress\n(SSE-driven)"]
end
end
subgraph "Tauri Backend (src-tauri/src/)"
discover["discover_files()"]
start["start_server()"]
stop["stop_server()"]
status["server_status()"]
config["read_config() / write_config()"]
end
subgraph "React Hooks (frontend/src/desktop/)"
lifecycle["useServerLifecycle"]
health["useServerHealth"]
submit["useSubmitJob"]
stream["useJobStream\n(SSE EventSource)"]
end
lifecycle -->|"Tauri IPC"| start & stop & status
folder -->|"Tauri IPC"| discover
form -->|"HTTP POST /jobs"| submit
progress -->|"SSE /jobs/id/stream"| stream
Web Dashboard Development
The dashboard is a React SPA that proxies API calls to a running batchalign3 server. You need two terminals: one for the Rust server, one for the Vite dev server.
Terminal 1: Start the Rust server
# Debug build (faster compile, slower runtime: fine for dashboard dev)
cargo run -p batchalign -- serve start --foreground --port 8000
Or if you already have a release binary:
./target/release/batchalign3 serve start --foreground --port 8000
The server must be on port 8000, the Vite dev config in frontend/vite.config.ts
proxies /jobs, /health, and /ws to localhost:8000.
Terminal 2: Start the Vite dev server
cd frontend
npm ci # first time only
npm run dev
Vite starts on http://localhost:5173. Open:
http://localhost:5173/dashboard: fleet monitoring (job list, workers, memory, pipeline stages)http://localhost:5173/dashboard/jobs/<id>: job detail with file-level progresshttp://localhost:5173/dashboard/visualizations: algorithm trace visualizations (DP alignment, retokenization, FA timeline, ASR pipeline)http://localhost:5173/process: end-user processing flow (desktop-oriented, but works in browser for layout testing)
Changes to frontend/src/ hot-reload instantly. Changes to Rust types require
rebuilding the server and regenerating OpenAPI types
(bash scripts/generate_dashboard_api_types.sh).
Production mode (no dev server)
In production, the built SPA is served directly by the Rust server via
ServeDir with SPA fallback. After building:
scripts/build_react_dashboard.sh # builds to ~/.batchalign3/dashboard/
The dashboard is then available at http://localhost:8000/dashboard (same
origin as the API, no proxy needed).
Build production artifact:
scripts/build_react_dashboard.sh
By default, artifacts are copied to:
~/.batchalign3/dashboard/
Override target path:
scripts/build_react_dashboard.sh /tmp/batchalign-dashboard
Desktop Dashboard (Tauri)
Run in development mode:
cd apps/dashboard-desktop
npm ci
npm run dev
This starts the frontend dev server on :1420 with hot reload and opens the
Tauri webview at /process?server=http://127.0.0.1:18000.
The app auto-starts a batchalign3 server on port 18000. If batchalign3 is not
on PATH, the status bar shows install instructions.
Build desktop bundle:
cd apps/dashboard-desktop
npm run build
Default backend target for desktop mode:
http://127.0.0.1:18000
Override backend at runtime using query parameter:
?server=http://host:port
Tauri Commands
| Command | Signature | Purpose |
|---|---|---|
discover_files | (dir: String, extensions: Vec<String>) -> Vec<String> | Walk directory tree, return paths matching extensions. Bridges native folder picker to POST /jobs source_paths. |
start_server | () -> ServerStatusInfo | Spawn batchalign3 serve start --foreground --port 18000 as managed child and return the resulting { running, port, binary_path, pid } snapshot. |
stop_server | () -> ServerStatusInfo | Kill managed server process and return the resulting { running, port, binary_path, pid } snapshot. |
server_status | () -> ServerStatusInfo | Return { running, port, binary_path, pid }. |
get_batchalign_path | () -> Option<String> | Find batchalign3 on PATH. |
is_first_launch | () -> bool | True if ~/.batchalign.ini doesn’t exist (triggers setup wizard). |
read_config | () -> UserConfig | Read ~/.batchalign.ini (engine + Rev.AI key). |
write_config | (config: UserConfig) -> DesktopCommandAck | Write ~/.batchalign.ini. |
Tauri Events
| Event | Payload | Purpose |
|---|---|---|
desktop://server-status-changed | ServerStatusChangedEvent | Shell-owned server lifecycle updates consumed by the frontend server capability. |
Server Lifecycle
The Tauri setup hook auto-starts the server on app launch. The
on_window_event(Destroyed) callback auto-stops it on exit. The ServerProcess
managed state holds the child process handle so start/stop/status commands can
inspect and control it.
ServerProcess now keeps the raw child handle behind start(), stop(),
status(), and shutdown() methods. That keeps the shell-local synchronization
detail out of the architectural surface and gives the Rust tests a stable unit
boundary.
Port 18000 is used (not the default 8000) to avoid conflicts with manually started development servers.
Tauri Plugins
| Plugin | Permissions | Purpose |
|---|---|---|
dialog | dialog:allow-open, dialog:allow-save | Native file/folder picker dialogs |
shell | shell:allow-open | Open output folders in Finder/Explorer |
Desktop-Specific Frontend Code
Low-level Tauri API access remains isolated in frontend/src/lib/tauri.ts, but
the React tree now consumes it through a protocol/capability split:
frontend/src/desktop/protocol.tsinventories raw command/event identifiers and pairs them with request/response payload typesDesktopProviderwraps the app infrontend/src/main.tsxfrontend/src/desktop/DesktopContext.tsxexposes focused hooks:useDesktopEnvironment(),useDesktopFiles(),useDesktopConfig(), anduseDesktopServer()lib/tauri.tskeeps the dynamic imports and browser fallbacks for dialogs, config, server lifecycle, event subscription, and shell-open helpersruntime.tsstill owns environment detection only
New desktop-only features should extend one focused capability seam instead of
importing @tauri-apps/* or ad hoc command/event names directly in
components/hooks.
Process Flow Components
Desktop-specific processing UI lives in frontend/src/components/process/:
| Component | Role |
|---|---|
CommandPicker | 2×3 grid of command cards (transcribe, morphotag, align, translate, utseg, benchmark) |
FolderPicker | Native folder picker wrapping Tauri dialog plugin |
OutputModeSelector | Separate folder vs in-place output toggle |
ProcessForm | Main orchestrator: command → configure → processing |
ProcessingProgress | SSE-driven live file progress with completion actions |
RecentJobs | Compact recent jobs list for home screen |
Supporting hooks:
| Hook | Role |
|---|---|
useServerLifecycle | Auto-start server, subscribe to desktop://server-status-changed, track status (starting/running/stopped/not-found), expose start/stop |
useServerHealth | Poll GET /health every 5s, expose reachability + capabilities |
useSubmitJob | React Query mutation for POST /jobs with paths_mode: true |
useJobStream | SSE EventSource wrapper for /jobs/{id}/stream |
Supporting components:
| Component | Role |
|---|---|
ServerStatusBar | Green/yellow/red dot + label + manual start/stop button |
ErrorRecovery | Structured error messages grouped by category with suggested actions |
OnboardingOverlay | First-time 3-step guide (pick task → select files → watch progress) |
HelpPanel | Slide-out panel with command descriptions and FAQ |
First-Time Setup Wizard
On first launch (no ~/.batchalign.ini), the desktop app shows a multi-step
setup wizard before the main route tree loads. This matches batchalign2’s
behavior where config_read(interactive=True) triggers interactive_setup()
on first CLI invocation.
The wizard lives in frontend/src/components/setup/:
| Component | Role |
|---|---|
SetupWizard | Multi-step flow: welcome → engine selection → API key → done |
EngineCard | Rev.AI vs Whisper card with pros/cons |
The CLI has the same gate: processing commands check for ~/.batchalign.ini
and auto-trigger batchalign3 setup if missing (interactive terminal) or
error with instructions (non-interactive).
Desktop Shell Tests
Run the shell-focused Rust tests with:
cargo test --manifest-path apps/dashboard-desktop/src-tauri/Cargo.toml
These tests intentionally cover the native shell contracts only:
src-tauri/src/protocol.rs: protocol identifier stability and event payload serializationsrc-tauri/src/main.rs:discover_files_in_dir()recursion/filter/sortsrc-tauri/src/config.rs: config roundtrip and Windows home-dir fallbackssrc-tauri/src/server.rs:ServerProcessempty/running/exited child states
Keep new shell logic behind pure helpers or small state wrappers so this suite can stay fast without booting the full webview.
Focused frontend seam checks live in
frontend/e2e/tests/mock-server.spec.mjs, which fakes the Tauri runtime to
exercise first-launch config flow, file discovery, and server status event
wiring.
API Contract Discipline
Rust OpenAPI remains canonical. Regenerate dashboard artifacts with:
scripts/generate_dashboard_api_types.sh
Verify no drift (CI gate):
scripts/check_dashboard_api_drift.sh
Dashboard Component Map
Pages
| Route | Component | Purpose |
|---|---|---|
/dashboard | DashboardPage | Two-column: job list + system panels |
/dashboard/jobs/:id | JobPage → JobDetailPageView | Full job detail with file table |
/dashboard/visualizations | VisualizationsIndex | Algorithm trace landing |
/dashboard/visualizations/:type | DPAlignmentPage, etc. | Individual visualizations |
/process | ProcessPage | Desktop processing flow |
Job Display Components
| Component | File | Role |
|---|---|---|
JobList | JobList.tsx | Renders sorted list of JobCards |
JobCard | JobCard.tsx | Single job summary: command badge, status, progress bar, metadata |
JobDetailPageView | JobDetailPageView.tsx | Full detail: metadata grid, progress, file table, errors |
FileTable | FileTable.tsx | Directory-grouped file rows with status, stage, progress |
PaginatedFileList | PaginatedFileList.tsx | Wraps FileTable with pagination controls |
FilterTabs | FilterTabs.tsx | Status filter tabs (All/Processing/Done/Error/Queued) + search |
ProgressBar | ProgressBar.tsx | Animated fill bar with striped/indeterminate modes |
PipelineStageBar | PipelineStageBar.tsx | 5-segment phase indicator (Read/Transcribe/Align/Analyze/Finalize) |
StatusBadge | StatusBadge.tsx | Colored status pill |
ErrorPanel | ErrorPanel.tsx | Error groups by code with expandable file lists |
ErrorCodeGroup | ErrorCodeGroup.tsx | Single error code bucket |
StatusSummaryStrip | StatusSummaryStrip.tsx | Inline done/error/active counts |
ActionButtons | ActionButtons.tsx | Cancel/restart/delete controls |
System Health Components (Right Column)
These panels live in the dashboard right column and consume HealthResponse
data from the Zustand healthMap:
| Component | File | Data Fields Used |
|---|---|---|
WorkerProfilePanel | WorkerProfilePanel.tsx | live_worker_keys, live_workers |
MemoryPanel | MemoryPanel.tsx | system_memory_total_mb, system_memory_available_mb, system_memory_used_mb, memory_gate_threshold_mb, memory_gate_aborts |
VitalsRow | VitalsRow.tsx | worker_crashes, forced_terminal_errors, memory_gate_aborts, attempts_started, attempts_retried, deferred_work_units |
The DashboardPage wires these by reading the first server’s health from
useStore((s) => s.healthMap). When a server filter is active, it uses that
server’s health.
Pipeline Phase Mapping
PipelineStageBar groups the 23 FileProgressStage enum variants into 5
visual phases. The mapping is defined in PHASES at the top of the component:
| Phase | Color | Stages |
|---|---|---|
| Read | zinc | reading, resolving_audio, checking_cache |
| Transcribe | emerald | transcribing, recovering_utterance_timing, recovering_timing_fallback |
| Align | indigo | aligning, applying_results |
| Analyze | violet | analyzing_morphosyntax, segmenting_utterances, translating, resolving_coreference, segmenting, analyzing, comparing, benchmarking |
| Finalize | amber | post_processing, building_chat, finalizing, writing |
When adding a new FileProgressStage variant in Rust, add it to the
appropriate phase set in PipelineStageBar.tsx and to PROGRESS_STAGE_LABELS
in frontend/src/utils.ts.
Worker Key Parsing
WorkerProfilePanel parses the live_worker_keys strings from the health
endpoint. The format is:
profile:<gpu|stanza|io>:<lang>[:<engine_overrides>] (<N> total, <M> idle|shared)
Examples:
profile:gpu:eng (1 total, shared): one shared GPU worker for Englishprofile:stanza:eng (2 total, 1 idle): two Stanza workers, one currently checked outprofile:io:fra:{"translate":"seamless"} (1 total, 1 idle): IO worker with engine override
The regex parser in parseWorkerKey() handles these formats. If the server
changes the key format, the parser must be updated to match.
Memory Panel Thresholds
MemoryPanel uses three proximity levels relative to the gate threshold:
- safe (green):
available > threshold × 4 - warning (amber):
threshold × 2 < available ≤ threshold × 4 - danger (red):
available ≤ threshold × 2
The gate threshold marker on the gauge bar is positioned at
(total - threshold) / total × 100%: the point where available memory
equals the threshold.
Dashboard State Boundary
The current dashboard intentionally splits client state by responsibility:
- Zustand owns fleet summary state such as job rows, health, connection status, and filter UI state.
- React Query owns REST-shaped detail payloads such as one fully resolved job record or trace download response.
The job detail page should surface the original submitted command options from the REST payload. That operator-facing context is important for debugging engine selection, incremental flags, and rerun behavior without reconstructing the submission from logs.
Per-file progress data now also follows the same typed-boundary rule:
progress_stageis the stable machine-readable stage code from Rust.progress_labelis the derived operator-facing text shown in the UI.
Dashboard code should prefer progress_stage for client logic and treat the
label as a rendering convenience rather than a parsing target.
That split matters because the WebSocket reconciliation layer can patch the same React Query cache entries that the detail route reads, instead of copying one detailed job payload through a second global store slot.
Dashboard E2E
Run dashboard Playwright smoke tests:
cd frontend
npm run e2e:install
npm run test:e2e
Run real-server dashboard E2E:
BATCHALIGN_REAL_SERVER_E2E=1 scripts/run_react_dashboard_smoke.sh
E2E Testing Entry Points
The dashboard has a unified e2e testing strategy across local development and CI. All paths
route through the same Playwright test files under frontend/e2e/tests/.
Test Modes
| Mode | Entry Point | Use Case | Requirements |
|---|---|---|---|
| Mock server | npm run test:e2e (default) | Fast local iteration, no deps | None (mock server built-in) |
| Real server | make batchalign-dashboard-e2e-real | Full integration, real Batchalign binary | Rust binary, Python installation |
| CI canonical | dashboard-e2e job in batchalign-python.yml | Pre-merge gate | Same as real-server |
| Desktop app | npm run test:e2e from apps/dashboard-desktop/ | Tauri webview integration | Tauri + all deps |
Local Development
Quick smoke test (no build, no binary required):
cd frontend
npm ci && npm run test:e2e
Full integration with real Batchalign (optional, slow):
make batchalign-dashboard-e2e-real
Headed mode (watch tests in browser):
cd frontend
npm run test:e2e:headed
CI Workflow
The dashboard-e2e job in .github/workflows/batchalign-python.yml:
- Builds the wheel from the Batchalign Rust/Python stack
- Sets
BATCHALIGN_REAL_SERVER_E2E=1andBATCHALIGN_PLAYWRIGHT_WITH_DEPS=1 - Runs
bash scripts/run_react_dashboard_smoke.shwhich orchestrates:- API type generation
- Frontend build
- E2E environment setup (Playwright browsers + dependencies)
- Playwright tests against a real
batchalign3server instance
This job only runs on main and manual workflow_dispatch (not on all PRs due to performance).
Orchestration Scripts
scripts/run_react_dashboard_smoke.sh: canonical orchestration script:
- Generates dashboard API types from Rust OpenAPI spec
- Builds frontend bundle
- Sets up Playwright environment
- Runs tests in mock or real server mode (via
BATCHALIGN_REAL_SERVER_E2Eenv var) - Used by both local developers and CI
scripts/build_react_dashboard.sh: deployment script:
- Generates API types
- Builds frontend
- Copies built artifacts to target directory (default:
~/.batchalign3/dashboard)
scripts/check_dashboard_api_drift.sh: validation gate:
- Ensures
openapi.jsonandfrontend/src/generated/api.tsare in sync - Fails if generated artifacts are stale
- Part of both Makefile and CI gates
Data Flow
Dashboard (fleet monitoring)
- Init: Resolve the server URL from runtime config
- WebSocket: Connect to the server and receive snapshot + real-time updates
- REST: React Query fetches job lists and details
- State: Zustand store tracks dashboard and connection state
- Updates: WebSocket patches store + query cache in real time
Detail pages follow the same split:
pages/JobPage.tsxis the route shellhooks/useJobPageController.tsowns job lookup, server resolution, and store synccomponents/JobDetailPageView.tsxowns detail presentation and file filters
Process flow (desktop)
- Setup:
AppchecksuseDesktopConfig().isFirstLaunch()and showsSetupWizardbefore routes load - Server:
useServerLifecyclereadsuseDesktopServer().serverStatus(), subscribes todesktop://server-status-changed, and auto-starts viauseDesktopServer().startServer() - Health:
useServerHealthpollsGET /healthon a fixed interval - Command: User picks from the command card grid
- Files:
useDesktopFiles().pickFolder()→ native dialog →useDesktopFiles().discoverFiles()→ file list - Submit:
useSubmitJobsendsPOST /jobswithpaths_mode: true - Progress:
useJobStreamopens SSE to/jobs/{id}/streamfor live updates - Complete:
useDesktopFiles().openPath()reveals the output folder in Finder/Explorer
This page last changed: 2026-07-31 (commit 8e3b0e23). The whole book last changed: 2026-09-16 (commit 34d249d8).
Memory Safety: Preventing Kernel OOM Crashes
Status: Current Last updated: 2026-09-05 03:20 EDT
The Problem
Each Python ML worker loads 2-15 GB of models (Whisper, Stanza, etc.). When multiple workers spawn concurrently, from parallel test binaries, per-job pre-scaling, or job dispatch, they collectively exceed physical RAM and trigger a kernel-level OOM panic that crashes the entire machine. This is not a process-level OOM kill; it is a Jetsam-triggered kernel panic that requires a hard reboot.
Sample observed failure modes:
- 5 python3.12 workers × ~13-15 GB each = ~70 GB on a 64 GB machine
- A multi-file transcription run with the default auto-tuner exhausted the machine’s 64 GB
Architecture
flowchart TD
A[Test / Job / Pre-scale<br>wants capacity] --> B{Host memory coordinator}
B -->|startup lease| C[One worker/model startup window]
B -->|job execution lease| D[Clamp per-job file parallelism]
B -->|ML test lock| E[Allow one real-model test owner]
C --> F{Local spawn guard}
F -->|permit + local RAM check| G[Spawn Python worker]
G --> H[Worker loads ML models<br>2-15 GB]
H --> I[Drop startup lease<br>keep only job/runtime leases]
style B fill:#ff9,stroke:#cc0,color:#000
style G fill:#9f9,stroke:#0c0,color:#000
style E fill:#f99,stroke:#c00,color:#000
Defense Layers
Layer 0: Live admission gates at the worker-pool spawn seam
worker/pool/{cpu_gate,memory_gate}.rs are the cheapest, earliest
admission predicates. They run inside try_claim_spawn_slot before
any permit acquisition or lease reservation, so a saturated host
doesn’t burn through the global-permit semaphore on doomed spawns.
The gates run two distinct policies, named explicitly per
PoolGateState (derived in worker/pool/lifecycle.rs):
- ColdStart: first worker for a
(profile, lang, engine)class. Both gates bypass unconditionally: back-pressure has nothing to push against on an empty pool, and refusing here leaves the pool dead-on-arrival on memory-tight hosts (the laptop-class failure mode that motivated the split). - Warm: N+1 worker for a class with existing workers. Both gates run their projection.
| Gate | Warm predicate | Source |
|---|---|---|
| CPU loadavg | getloadavg(3).one < available_parallelism() | cpu_gate.rs |
| Memory floor + projection | available_mb − new_worker_estimate_mb > host_min_free_mb_threshold_for_tier(tier) | memory_gate.rs |
The Warm-gate floor is tier-scaled: 1024 MB Small / 2048 MB
Medium / 4096 MB Large / 4096 MB Fleet. The historical fixed
MIN_FREE_MEMORY_MB = 2048 constant is now the Medium-tier value
and the JobStore-level gate’s default; it is no longer the single
admission floor.
The new_worker_estimate_mb is the average RSS of same-profile
idle peers (Mode B, rss_observer.rs) when peers exist; otherwise
falls back to the canonical per-tier MemoryTier::*_startup_mb
(Mode A fallback), tier.gpu_startup_mb, tier.stanza_startup_mb,
tier.io_startup_mb. By design (Principle 1), MemoryTier is the sole
canonical source of these
values; an architectural-invariant test in types/runtime.rs
prevents reintroduction of parallel constants.
The Mode-A fallback is further engine-aware for the IO profile,
via memory_gate::engine_aware_startup_reservation_mb. The IO
baseline (tier.io_startup_mb: 2 GB Small/Medium, 4 GB Large/Fleet)
is correct for engines that are thin API clients (Google Translate
via googletrans) but under-reserves for engines that load large
local models in the worker process, SeamlessM4T (~2.4 GB resident)
and NLLB-200-distilled-1.3B (~5 GB resident). The helper takes the
MAX of the profile baseline and the engine’s resident footprint as
declared by TranslateEngineName::resident_memory_mb, so the
admission gate refuses to spawn an NLLB worker on a Medium-tier
host that doesn’t have ~5 GB of headroom. Workers running the
lightweight Google engine continue to pay the IO baseline.
Admission is back-pressure, not safety (Principle 5). The
correctness floor is worker/memory_guard.rs (per-spawn host-memory
reservation + RSS observation + kill on overrun) plus the OS OOM
killer. An over-permissive admission means a worker may die at
spawn, bounded cost. An over-strict admission means the host
can’t run at all, unbounded cost (jobs queue forever). The bias
is toward over-permissive; ColdStart bypass implements the bias.
The eviction-side counterpart: worker/pool/idle_eviction.rs runs as
a pre-pass in run_health_check and evicts idle workers
largest-RSS first when available_mb falls at or below
EVICTION_PRESSURE_THRESHOLD_MB = 4096 MB (= 2× the admission
floor). There is no idle_timeout_s knob, eviction is purely
pressure-driven.
The host’s available-memory reading is shared across all five
sysinfo-touching paths (admission gate, eviction pre-pass, in-spawn
guard, host-facts probes, info logs) via the TTL-cached
host_memory::system_memory_snapshot: at most one
/proc/meminfo (Linux) / host_statistics64 (macOS) read per
second across the whole pool.
Layer 1: Host-wide coordinator (prevents cross-process overcommit)
A machine-local JSON ledger guarded by an exclusive file lock coordinates memory
across local batchalign3 processes on the same host. This covers:
- multiple server ports,
- CLI auto-daemons,
- pre-scaled vs foreground jobs,
- independent Rust test binaries.
The coordinator tracks three lease types:
- worker startup leases for the model-loading spike,
- job execution leases for in-flight file parallelism,
- machine-wide ML test locks so real-model test runs do not stampede the host.
The reserve/headroom policy comes from ServerConfig.memory_gate_mb, which now
means “keep at least this much RAM free after reservations” rather than a
standalone job gate. The default is MIN_FREE_MEMORY_MB = 2048 (the
JobStore-gate constant). The previous tier-derived per-host default
(Small=2 GB, Medium=4 GB, Large/Fleet=8 GB) was retired on 2026-05-08
for this knob; workload-sized headroom now comes from Layer 0’s
per-process RSS observation. Note: this is independent of the
Layer-0 Warm-gate floor host_min_free_mb_threshold_for_tier, which
is tier-scaled (1024/2048/4096/4096) and protects the per-spawn
admission decision rather than the per-job admission decision.
Layer 2: Spawn semaphore (prevents in-process TOCTOU race)
A process-global tokio::sync::Semaphore serializes all worker spawns. This
still matters even with the host-wide coordinator because one server process can
otherwise race with itself:
sequenceDiagram
participant T1 as Test Binary 1
participant T2 as Test Binary 2
participant Sem as Spawn Semaphore
participant Mem as Memory Check
participant Py as Python Worker
Note over T1,T2: WITHOUT semaphore (old behavior - CRASHES)
T1->>Mem: Check: 20 GB free ✓
T2->>Mem: Check: 20 GB free ✓
T1->>Py: Spawn worker (loads 15 GB)
T2->>Py: Spawn worker (loads 15 GB)
Note over Py: 30 GB loaded on 20 GB free → OOM PANIC
Note over T1,T2: WITH semaphore (new behavior - SAFE)
T1->>Sem: Acquire permit
Sem-->>T1: Granted
T1->>Mem: Check: 20 GB free ✓
T1->>Py: Spawn worker (loads 15 GB)
T2->>Sem: Acquire permit
Sem-->>T2: Wait (T1 holds permit)...
T1->>Sem: Release permit
Sem-->>T2: Granted
T2->>Mem: Check: 5 GB free ✗
Note over T2: MemoryGuardError returned, no spawn
Location: crates/batchalign/src/worker/memory_guard.rs
Layer 3: Explicit startup reservations (before every worker spawn)
Worker startup budgets are now tier-adaptive, scaled by a MemoryTier
derived from total system RAM:
| Tier | Total RAM | GPU Startup | Stanza Startup | IO Startup | Headroom |
|---|---|---|---|---|---|
| Small | < 24 GB | 6 GB | 3 GB | 2 GB | 2 GB |
| Medium | 24-48 GB | 3 GB (LazyProfile) | 6 GB | 3 GB | 4 GB |
| Large | 48-128 GB | 16 GB | 12 GB | 4 GB | 8 GB |
| Fleet | ≥ 128 GB | 16 GB | 12 GB | 4 GB | 8 GB |
These values are defined exactly once in
MemoryTier::from_total_mb() in crates/batchalign/src/types/runtime.rs
, the sole canonical source per Principle 1. Operator overrides
flow in via RuntimeOverridesConfig.{gpu,stanza,io}_startup_mb,
which override the tier-derived values. The Medium tier uses
LazyProfile for GPU: the worker starts with only process
overhead and loads model weights on demand. A lazy worker key still includes
the selected engine recipe; two engines cannot share one task-only process.
The host permit gate and idle-worker eviction bound how many recipe-specific
workers remain resident. This trades a small amount of process overhead for a
hard correctness guarantee: an already-loaded model cannot silently satisfy a
request for another engine. The startup reservations are intentionally
more conservative than the per-command execution budgets. They
protect the model-loading spike where Whisper, Stanza, or related
engines can temporarily consume far more memory than steady-state
request handling.
Note: On macOS, sysinfo::available_memory() undercounts because it only
reports free + purgeable pages, not inactive pages. The kernel can reclaim
inactive pages, so the real headroom is larger. We use the conservative number.
Layer 4: Job execution reservations (before a job starts running)
The runner no longer uses a separate memory_gate() plus independent
memory-based auto-tune formula. Instead it:
- computes a requested worker count from file count, CPU, and category caps,
- asks the host coordinator for a job execution plan,
- receives a granted worker count plus a lease held for the job lifetime,
- re-queues the job if the host cannot safely fit that plan.
This makes worker startup and job execution share one memory story instead of two unrelated heuristics.
Layer 5: Machine-wide ML test lock
The live ML fixture now acquires a machine-wide test lock before preparing warm
workers. This prevents concurrent cargo test or IDE runs from each
building their own model pool on the same machine.
This lock complements, rather than replaces:
RUST_TEST_THREADS=1,- the ML golden suite,
- the single-binary
ml_goldenlayout.
Layer 6: SIGKILL Follow-Through in Drop
Both WorkerHandle::Drop and SharedGpuWorker::Drop now send SIGTERM, wait
200ms, then send SIGKILL if the worker is still alive. This prevents zombie
Python processes when the worker is stuck in a C extension (PyTorch, NumPy)
that ignores SIGTERM.
Layer 7: Periodic Orphan Reaping
The health check background task now calls reap_orphaned_workers() on every
tick (default: 30s). This catches orphaned workers from server crashes without
waiting for the next server restart. Previously, orphans only got cleaned up
when a new server instance started.
Layer 8: Test-level skip (bail out before any setup)
Every test file that spawns workers has a require_python!() macro that checks
available memory BEFORE attempting to spawn:
#![allow(unused)]
fn main() {
macro_rules! require_python {
() => {{
let available_mb = batchalign::worker::memory_guard::available_memory_mb();
if available_mb < 4096 {
eprintln!("SKIP: insufficient memory ({available_mb} MB)");
return;
}
// ... resolve python path ...
}};
}
}
Layer 9: Test isolation and explicit ML opt-in
The default Rust suite includes test-echo integration tests, which may spawn lightweight Python workers but do not load ML models. Related tests share a small number of executables and warmed fixtures. ML model tests are gated behind their own per-host opt-in feature and must only be run on a Fleet/Large-tier host with ≥ 256 GB RAM:
make test # Default suite, no ML models
cargo test -p batchalign --test worker_integration_suite worker_integration:: -- --test-threads=1
# ML golden tests: Fleet/Large-tier hosts only
Environment Variables
| Variable | Default | What it does |
|---|---|---|
BATCHALIGN_SPAWN_MIN_MEMORY_MB | 4096 | Minimum free RAM (MB) to allow a worker spawn |
BATCHALIGN_MAX_CONCURRENT_SPAWNS | 1 | Max concurrent worker spawns (semaphore size) |
BATCHALIGN_HOST_MEMORY_LEDGER | temp-dir path | Override the shared host-memory ledger path |
RUST_TEST_THREADS | Rust harness default | Optional cap on parallel test functions; pass --test-threads=1 for isolated worker probes |
Key config knobs
| Setting | Default | What it does |
|---|---|---|
memory_gate_mb | 2048 MB | Host reserve/headroom preserved after reservations. Same constant the worker-pool admission gate enforces; operator override accepted but rarely needed. |
max_concurrent_worker_startups | 1 | Host-wide limit for simultaneous worker/model startups |
gpu_thread_pool_size | 4 | In-process GPU request concurrency, now forwarded into Python |
How to Run Tests Safely
On a developer machine (≤ 64 GB)
# Default suite: Rust plus test-echo workers, no ML models
make test
# Worker integration tests (test-echo mode, no ML models): safe with
# the memory guard; spawn real Python workers in test-echo mode
# without model loading
cargo test -p batchalign --test worker_integration_suite worker_integration:: -- --test-threads=1
# NEVER run ML golden tests on a 64 GB machine; they will OOM.
On a Fleet/Large-tier host (≥ 256 GB RAM, e.g. an M3 Ultra Mac Studio)
# Default suite + focused worker integration + ML golden tests
make test
cargo test -p batchalign --test worker_integration_suite worker_integration:: -- --test-threads=1
cargo test -p batchalign --test ml_golden -- --test-threads=1
Running a specific integration test
# One module within the shared worker suite, single thread, memory guard active
cargo test -p batchalign --test worker_integration_suite worker_integration:: -- --test-threads=1
# Run only ignored tests (if any)
cargo test -p batchalign --test worker_integration_suite worker_integration:: -- --ignored --test-threads=1
What requires explicit opt-in
# Loads real ML models and may use hosted services; large hosts only
cargo test -p batchalign --features ml-golden --test ml_golden -- --test-threads=1
Plain Cargo runs integration executables sequentially; each executable’s Rust
test harness may run its test functions concurrently. The shared fixtures and
memory gates make the default suite safe without depending on a third-party
runner. Use a focused module filter and --test-threads=1 when diagnosing
ordering or resource-admission behavior.
Implementation Files
| File | What |
|---|---|
crates/batchalign/src/worker/pool/cpu_gate.rs | Layer 0 admission: getloadavg(3) vs available_parallelism() |
crates/batchalign/src/worker/pool/memory_gate.rs | Layer 0 admission: available − reservation > host_min_free_mb_threshold_for_tier(tier) (Warm); ColdStart bypasses. Tier-scaled floor: 1024/2048/4096/4096 by tier |
crates/batchalign/src/worker/pool/rss_observer.rs | Per-process RSS sampling for the Mode B admission estimate |
crates/batchalign/src/worker/pool/idle_eviction.rs | Pressure-driven idle-worker eviction (largest-RSS first when available <= 4096 MB) |
crates/batchalign/src/host_memory.rs | Host-wide ledger, startup leases, job execution leases, ML test lock; TTL-cached system_memory_snapshot shared by every memory poll |
crates/batchalign/src/worker/memory_guard.rs | Local spawn semaphore plus host-memory startup reservation |
crates/batchalign/src/worker/handle/mod.rs and crates/batchalign/src/worker/handle/spawn.rs | WorkerHandle::spawn() (mod.rs:73) and spawn_tcp_daemon() (spawn.rs:135) both call acquire_spawn_permit() |
crates/batchalign/src/runner/mod.rs | Coordinator-backed job execution planning and requeue |
crates/batchalign/tests/common/mod.rs | Machine-wide ML fixture lock |
crates/batchalign/tests/worker_integration.rs | require_python! macro with memory check |
crates/batchalign/tests/gpu_concurrent_dispatch.rs | Same |
crates/batchalign/tests/worker_protocol_matrix.rs | Same |
.cargo/config.toml | RUST_TEST_THREADS = "1" |
Makefile | Tiered test targets: test-rust, test-workers, test-ml |
This page last changed: 2026-09-05 (commit 7f5e86f1). The whole book last changed: 2026-09-16 (commit 34d249d8).
Release Checklist
Status: Current Last updated: 2026-09-06
This is the required checklist for a public batchalign3 release. The
supported distribution channel is a GitHub Release; BA3 is not published to
PyPI. A failed or unknown gate blocks the tag.
Review dependency changes and queued advisory remediation before building or pushing the candidate. Settle that scope before the final gates so a known cleanup does not force another candidate and release rehearsal.
Release artifact topology
flowchart LR
S["Reviewed main commit"] --> D["Prospective-tag dry run"]
D --> W["Five platform wheels"]
D --> SD["Source distribution"]
W --> SM["Clean wheel smoke<br/>Linux, macOS, Windows"]
SM --> T["Annotated vX.Y.Z tag"]
SD --> T
T --> R["GitHub Release"]
R --> I["Shell and PowerShell installers"]
R --> C["SHA-256 manifest"]
R --> V["Download and install verification"]
The dry run and tagged release use the same workflow. Dry run permits a
prospective version identity but never creates a release. A publishing run
requires the tag to exist and match pyproject.toml exactly.
Pre-release gates
1. Release identity
-
pyproject.toml,[workspace.package].version, andcrates/batchalign/Cargo.tomlmatch the target. -
batchalign/versionnames the same version. The Python regression test compares it with the installed wheel metadata. -
batchalign3 versionreports the target from the packaged Rust binary. - The release remains below 1.0 unless the release contract and versioning policy are promoted in the same reviewed change.
- Desktop metadata changes only if a desktop release is explicitly in scope; the normal BA3 release excludes it.
Internal crates do not all share the product version. Do not mechanically bump
batchalign-types, batchalign-fa-core, batchalign-core, or
batchalign-pyo3; their manifests intentionally retain independent internal
versions.
2. Source and CI
- The release commit is on
main, reviewed, and the worktree is clean. - All required GitHub checks for that exact commit pass.
-
make ci-fullpasses locally. It includesmake gateand writes its push receipt; do not run the same gate again if the source tree is unchanged. - The release wheel passes the Python pytest, Ruff, formatting, drift, and
mypy gates (
make batchalign-ci-python). -
make book-check,make lint-shell, andmake lint-actionlintpass. - Generated IPC, OpenAPI, dashboard, and book artifacts are current.
Regenerate and stage changed OpenAPI output before the final source gate:
its drift check compares generated bytes with the index. Shell, actionlint and
book checks run through ci-full’s gate. The optimized wheel and Python gate
remain the separate make batchalign-ci-python step.
3. Artifact dry run
- Dispatch
Batchalign Releasemanually for prospectivevX.Y.Zwithdry_run=trueagainst the release commit. - Preflight accepts the version identity.
- Five wheels build: macOS ARM and Intel, Linux x86_64 and ARM64, and Windows x86_64.
- The source distribution builds.
- Clean-wheel
--helpandversionsmoke tests pass on Linux, macOS, and Windows. - Packaged server health smoke passes on Linux and macOS.
- The assembly job stages both installers and computes
sha256.sumbut does not create a GitHub Release.
4. Product and documentation
- The README and installation guide describe GitHub Release installation, not PyPI.
- The release contract and platform matrix match the artifacts and tested behavior.
- User and developer documentation cover new commands, flags, cache and replay behavior, evidence formats, observability, and limitations without overclaiming empirical quality.
- Mermaid diagrams render and describe the implemented state boundaries.
- Generated release notes have been reviewed before they are sent to collaborators; GitHub may generate the initial notes at publication time.
5. Dependencies and security
- Cargo and uv lockfiles validate and are included when dependencies changed.
- Known advisories are reviewed and recorded. An advisory is a release blocker when it is reachable in the shipped product or violates a configured CI policy; the checklist does not make the false claim that every ecosystem scanner must report zero findings.
- GitHub Actions and installer changes receive focused security review.
- License and classifier metadata match the public-preview state.
Release procedure
- Complete the gates above on the reviewed
maincommit. - Run and verify the prospective-tag dry run.
- Create an annotated tag:
git tag -a vX.Y.Z -m "batchalign3 vX.Y.Z". - Push only that tag. The tag-triggered workflow builds, smokes, checksums, and creates the GitHub Release.
- Verify the release contains five wheels, one source distribution, both
installers, and
sha256.sum. - Download from the published release and perform a clean installer or wheel smoke outside the source checkout.
- Deploy the exact released identity where an operational deployment is in
scope, then verify
/healthand its build/runtime identities.
No release branch or immediate development-version bump is required. The next release receives its version when that release is prepared.
Failed publication and replacement
If a published release is defective:
- Mark the GitHub Release as a prerelease or remove it from public discovery, depending on severity, and record which artifacts are affected.
- Fix and review the defect on
main. - Choose a new patch version. Never move or reuse a published tag.
- Repeat the complete dry-run and release process.
There is no PyPI release to yank.
This page last changed: 2026-09-06 (commit 9bfd98f2). The whole book last changed: 2026-09-16 (commit 34d249d8).
Release Contract
Status: Current Last updated: 2026-09-05 22:04 EDT
This page defines the compatibility promises for the public batchalign3
product. It describes the current 0.x public-preview line; it is not a promise
that research-quality output is universally better than another system.
Release state
Public preview (0.x). Releases are usable by external researchers, but CLI, wire, cache, and evidence schemas may still change between minor versions. Breaking changes must be described in release notes and migration docs.
stateDiagram-v2
[*] --> SourceCandidate
SourceCandidate --> ArtifactCandidate: local gates and exact-commit CI pass
ArtifactCandidate --> ReleaseCandidate: five-wheel dry run and smoke pass
ReleaseCandidate --> PublishedPreview: immutable annotated tag
PublishedPreview --> Superseded: newer version published
ReleaseCandidate --> SourceCandidate: defect found
PublishedPreview --> Withdrawn: release defect recorded
Withdrawn --> SourceCandidate: new patch version prepared
Supported public surfaces
- CLI (
batchalign3): the documented commands and flags. - Local server (
batchalign3 serve): REST/WebSocket execution used by the CLI and browser dashboard. Its schema is documented but not frozen during preview. - Browser dashboard: the UI embedded in the released CLI/server.
- GitHub Release installers and wheels: the supported installation path. There is no PyPI distribution.
- CHAT output and debug/evidence artifacts: supported as documented for the release, but cache, replay-manifest, and evidence schema revisions remain preview surfaces unless a page explicitly promises compatibility.
The wheel contains Python model-worker code, the batchalign_core extension,
and a platform-specific Rust batchalign3 executable. It is a distribution
artifact, not a supported import-level Python API. Python integrations should
invoke the CLI or local server.
Experimental or internal surfaces
- Batchalign Desktop (Tauri): an in-repo shell, not released.
- Direct Python imports: internal implementation detail.
- Internal Rust crates: workspace implementation details, not separately published by the BA3 release workflow.
- Staged or remote multi-host execution: experimental.
- Cache database internals: replaceable implementation detail. Durable raw evidence and fingerprinted replay artifacts have explicit schemas; a cache row itself is not a preservation format.
Evidence and quality claims
Architectural guarantees and empirical quality claims are different:
- Types, validation, fingerprints, and fail-closed writes can guarantee which evidence was admitted and which algorithm produced an output.
- They cannot by themselves guarantee optimal words, speakers, utterance
boundaries, or
%wortimings. - Comparative claims about the upstream BA3 fork, earlier BA3, Whisper, Rev, or a published corpus require controlled clips, identical inputs and provider requests, preserved raw outputs, and recorded human adjudication.
The product may therefore claim replayability and stronger correctness boundaries when the code proves them, while quality claims remain scoped to their experiment reports.
Workspace dependency
BA3 lives in the talkbank-tools Cargo workspace and consumes Chatter’s CHAT
model, parsers and generic transforms through public git dependencies pinned
to a released Chatter tag. Cargo.lock records the exact source commit. A
clean checkout builds without a sibling Chatter checkout or local path patch.
When adopting a Chatter release, review its API and validation changes and
update the workspace tags and lockfile together before the BA3 release gates.
The BA3 product version appears in pyproject.toml, the workspace package
version, and crates/batchalign/Cargo.toml. Several internal helper crates keep
independent 0.1.x versions and must not be mechanically changed to match the
product.
Platform support
The release workflow builds five wheels:
| Platform | Artifact / test promise |
|---|---|
| Linux x86_64 | Wheel build, clean CLI smoke, server-health smoke; full Linux CI elsewhere |
| Linux ARM64 | Native wheel build; no release-workflow execution smoke |
| macOS ARM64 | Wheel build, clean CLI smoke, server-health smoke |
| macOS x86_64 | Wheel build; no release-workflow execution smoke |
| Windows x86_64 | Wheel build and clean CLI smoke; server lifecycle is not supported |
See Platform Support for operational limitations.
Distribution and signing
An immutable vX.Y.Z tag triggers the GitHub Release workflow. The release
contains five wheels, one source distribution, shell and PowerShell installers,
and a SHA-256 manifest. Artifacts are not currently code-signed or notarized;
the checksums provide download-integrity evidence, not publisher identity.
License
BSD-3-Clause.
This page last changed: 2026-09-05 (commit db553757). The whole book last changed: 2026-09-16 (commit 34d249d8).
Long-Term Reliability Program
Status: Current Last updated: 2026-05-21 15:15 EDT
This document defines the ongoing reliability practices for batchalign3. It covers corpus-level regression testing, failure tracking, stress/recovery testing, and a release readiness scorecard. These practices ensure that regressions are caught early, failures are classified and root-caused, and every release meets a minimum quality bar.
1. Corpus-Level Regression Runs (T130)
Schedule regular regression runs against the full TalkBank corpus data.
What to run
batchalign3 morphotagagainst reference CHAT files (no audio needed).- Compare output against golden baselines stored in version control.
- Track error rates, timing, and output drift across runs.
Cadence
| Trigger | Scope | Where |
|---|---|---|
| After every release (mandatory) | Full corpus | Fleet/Large-tier host (≥ 256 GB RAM) |
Weekly on main (future scheduled CI) | Full corpus | Fleet/Large-tier host |
| On demand for significant changes | Targeted subset | Fleet/Large-tier host or developer machine (small subset only) |
Infrastructure
- Execution host: a Fleet/Large-tier server (Mac Studio M3 Ultra class, ≥ 256 GB RAM) for full corpus runs. Developer machines may run small targeted subsets but must never attempt the full corpus (OOM risk).
- Golden baselines: stored in
batchalign/tests/golden/. Each baseline is a deterministic snapshot of pipeline output for a fixed set of input files. (batchalign3is the CLI name; the package directory isbatchalign/.) - Comparison tool: diff golden output against new run output. Any difference is either an intentional change (update the baseline with a commit message explaining why) or a regression (file a bug).
Baseline update policy
- Run the regression suite and capture output.
- Diff against the committed baseline.
- If differences exist and are intentional, update the baseline in a dedicated commit with a clear explanation of what changed and why.
- If differences are unintentional, file a GitHub Issue, classify per Section 2, and fix before releasing.
2. Failure Classification Tracking (T131)
Track failure classes reported by researchers in a structured format so that patterns become visible and root causes are addressed systematically.
Categories
| Category | Label | Examples |
|---|---|---|
| ASR accuracy | failure/asr | Wrong words, missing segments, language detection failures |
| Alignment | failure/alignment | Timing drift, bullet placement errors, silence misattribution |
| Morphosyntax | failure/morphosyntax | Wrong POS tag, missing lemma, MWT expansion failure |
| Infrastructure | failure/infra | Crashes, hangs, OOM, worker spawn failures, deploy errors |
| CHAT format | failure/chat-format | Parse failures, serialization bugs, roundtrip drift |
Process
- Each reported failure gets a GitHub Issue with the appropriate
failure/*label. - The issue body includes: reproduction steps, input file (or trimmed fixture), observed vs. expected output, and the failure category.
- Root cause is documented in the issue before closing.
- Every fix includes a regression test that reproduces the original failure (red/green TDD: write the failing test first, then fix).
- Periodically review open failure issues to identify systemic patterns (e.g., recurring alignment drift in a specific language family).
Metrics to track
- Mean time to root-cause (from report to documented root cause).
- Failure category distribution over time (are ASR failures trending down? are infra failures trending up?).
- Recurrence rate (failures in categories that already have fixes, indicates insufficient regression coverage).
3. Stress and Recovery Test Suites (T132)
These tests verify that the system degrades gracefully under load and recovers correctly from crashes. They are resource-intensive and run only on the server or on explicit request.
Worker stress tests
| Test | What it verifies |
|---|---|
| Concurrent spawn/kill cycles | Workers start and stop cleanly under rapid cycling |
| Crash during inference (simulated via signal) | Server detects worker death and reports error, does not hang |
| Worker timeout with pending results | Timeout fires, partial results are discarded, job is marked failed |
| Memory pressure during model loading | OOM is caught and reported, server remains operational |
| Multiple simultaneous jobs contending for workers | Job queue drains correctly, no deadlock or starvation |
Recovery tests
| Test | What it verifies |
|---|---|
| Server restart with in-flight jobs | Jobs are marked as failed or retried; no silent data loss |
| Worker crash recovery | Server spawns replacement worker, next job succeeds |
| Cache corruption recovery | Corrupted cache entries are detected and evicted, not served |
| Network interruption during remote execution | Client receives a clear error; server-side state is consistent |
Implementation
- Invoke
cargo test -p batchalign --test <stress-test>directly. (This said “usecargo nextestwith a[profile.stress]” until 2026-07-30; that runner is banned and uninstalled here, so the direct form is now the only form.) - Not part of default CI. Triggered manually or as part of the release process.
- Run on the server only: these tests consume significant memory and CPU. Never run on developer machines or production workers.
- Each stress test must have a timeout (no infinite hangs) and must clean up all spawned processes on completion or failure.
4. Release Readiness Scorecard (T133)
A living scorecard reviewed before every release. All blockers must be resolved before tagging a version.
Scorecard dimensions
| Dimension | Metric | Target | Notes |
|---|---|---|---|
| CI green rate | % of main branch builds passing | 100% | Any red build blocks release |
| Clippy clean | Warnings on cargo clippy --all-targets | 0 | Includes all workspace members |
| Wheel smoke | Clean pip install + batchalign3 --help passes | Pass | Covers sdist and wheel paths |
| Cross-platform | Smoke tests on macOS (ARM) + Linux (x86_64) | 2/2 | CI matrix or manual verification |
| Version consistency | Cargo.toml, pyproject.toml, CLI --version agree | Match | Checked by release script |
| License consistency | All surfaces show BSD-3-Clause | Match | Cargo.toml, pyproject.toml, LICENSE |
| Audit clean | pip-audit + cargo-deny report no critical vulns | 0 critical | Advisory-only items documented |
| Test coverage | Integration + unit + golden tests pass | >80% line coverage | Measured via cargo llvm-cov |
| Regression baseline | Corpus regression run shows no unintentional drift | No drift | Per Section 1 |
| Open blockers | GitHub Issues labeled release-blocker | 0 | All must be closed or deferred with justification |
Process
- Before tagging: run the scorecard checks (automated where possible, manual where not yet automated). Record results.
- Blockers: any dimension marked as failing blocks the release. Resolve or explicitly defer with a documented justification and a follow-up issue.
- Historical tracking: record each release’s scorecard in the release
notes or a dedicated
docs/release-scores/directory so that trends are visible over time. - Automation goal: progressively move scorecard checks into CI so that
the scorecard is computed automatically on every push to
main, not just at release time.
This page last changed: 2026-07-30 (commit 5157a549). The whole book last changed: 2026-09-16 (commit 34d249d8).
Models Training Runtime ADR
Status: Accepted Last updated: 2026-05-01 05:19 EDT
Context
batchalign3 models ... currently delegates to the Python training runtime:
python -m batchalign.models.training.run ...
The CLI/server control plane has migrated to Rust, but model training still depends on Python-first ML stacks and training code paths.
Decision
Keep models as a Python bridge for now, with explicit boundaries:
- Rust owns argument parsing, UX, and process orchestration.
- Python owns training/inference library integration for model training.
- Interpreter resolution must remain uv-friendly:
BATCHALIGN_PYTHON->VIRTUAL_ENV->python3.
Rationale
- Training-specific dependencies are Python-native and already production validated.
- Rewriting training loops in Rust now would be high-risk, low-ROI versus finishing CLI/server/runtime migration.
- The bridge keeps migration momentum while avoiding duplicate training stacks.
Consequences
- Shipping still requires a compatible Python runtime for
models. - CLI/server/runtime operations remain Rust-first.
- Migration accounting treats
modelsas an intentional Python-core island rather than accidental legacy code.
Exit Criteria For Future Rust Port
Revisit only when all are true:
- A Rust training stack is selected and benchmarked with parity targets.
- Feature parity test corpus exists for training outputs.
- Operational benefits (startup, packaging, observability, maintenance) clearly exceed migration cost.
This page last changed: 2026-05-03 (commit e8235c13). The whole book last changed: 2026-09-16 (commit 34d249d8).
Lenient Parsing
Status: Current decision and current parser behavior
Last verified: 2026-03-05
Decision
Batchalign keeps parsing and validation as distinct concerns:
- the parser should recover as much CHAT structure as it safely can
- validation should decide whether the recovered structure is acceptable for a given command or workflow
This keeps messy real-world CHAT files processable without pretending malformed content is valid.
Current behavior
The parser currently has three resilience layers:
- tree-sitter grammar catch-alls for unknown headers and unsupported lines
- Rust-side recovery/reporting for localized parse failures that escape the grammar
- strict versus lenient entrypoints, depending on caller needs
Current parse/validate split
Strict parsing
Used where the command expects structurally clean CHAT input or is checking its own output:
- structural extraction paths
- morphosyntax writeback paths
- round-trip or post-write validation
Lenient parsing
Used where the command must accept messy corpus input and preserve as much signal as possible:
- alignment-oriented paths
- translation and other workflows that may receive imperfect source CHAT
- pipeline entrypoints that need best-effort recovery
Current resilience summary
Works well now
- unknown
@Header:lines recover as structured unknown headers - junk non-CHAT lines are localized instead of causing broad parse collapse
- many malformed dependent tiers stay localized to the affected utterance/tier
Known current limits
- missing
@UTF8or@Beginare still structurally significant because they sit deep in the grammar shape - malformed content can still taint the affected utterance or dependent tier even when the rest of the file parses
- lenient parsing is recovery-oriented, not a promise that every malformed file will become fully usable for every command
Why this remains the right boundary
This decision supports the broader BA3 architecture:
- parse broadly enough to preserve recoverable structure
- validate explicitly at command boundaries
- avoid flattening CHAT to strings and trying to reconstruct intent later
For migration history or branch-by-branch parser evolution, use the migration book. This page describes the current rule and the current parser boundary only.
This page last changed: 2026-05-03 (commit e8235c13). The whole book last changed: 2026-09-16 (commit 34d249d8).
Trait-Based Dispatch
Status: Current decision Last updated: 2026-05-21 15:05 EDT
Decision
Use traits for pluggable algorithm implementations where we have or plan multiple Rust-side implementations of the same operation and need controlled experiments. Do not use traits for language-specific dispatch or engine selection where simpler mechanisms already work.
Context
Batchalign3 has several dispatch dimensions:
- Algorithm strategies: different implementations of the same operation (e.g., global vs. per-speaker UTR alignment).
- Engine selection: choosing which ML backend to use (Whisper vs. Rev.AI for ASR, Wave2Vec vs. Whisper for FA, Pyannote vs. NeMo for diarization).
- Language-specific processing: per-language rules for morphosyntax, number expansion, text normalization.
- Command-level pipelines: the top-level structure of each command (transcribe, align, morphotag, etc.).
Each dimension has different characteristics that call for different dispatch mechanisms.
Where Traits Are the Right Choice
Algorithm strategies with multiple Rust implementations
The motivating case is UTR alignment, where we plan three implementations:
| Strategy | Description | Status |
|---|---|---|
| Global UTR | Current single-stream monotonic DP | Implemented |
| Backbone UTR | Strip &* segments before DP | Proposed |
| Per-speaker UTR | Diarize, then per-speaker DP | Proposed |
These share the same inputs (CHAT file + audio + ASR tokens), produce the same outputs (timing injected into the CHAT file), and need to be compared head-to-head on the same corpus. A trait makes this clean:
/// Strategy for recovering utterance timing from ASR output.
///
/// Implementations own the full UTR pass: reference extraction,
/// ASR token acquisition (possibly per-speaker), DP alignment,
/// and timing injection. The orchestrator calls `run()` and
/// gets back a coverage result.
pub trait UtrStrategy: Send + Sync {
/// Run the full UTR pass, modifying `chat_file` in place.
fn run(
&self,
ctx: &UtrPassContext<'_>,
chat_file: &mut ChatFile,
audio_path: &Path,
) -> impl Future<Output = Result<UtrResult>> + Send;
}
The trait surface is deliberately wide, run() owns the entire UTR pass,
not just the DP step, because per-speaker UTR needs to control ASR calls
(per-speaker segments rather than one global call). A narrower trait that
only covered reference extraction and DP injection would force per-speaker
UTR to work around the trait boundary.
Selection is now via a visible CLI flag for the validated overlap-aware UTR surface:
/// UTR strategy selection.
///
/// Visible from --help. Default is auto.
#[arg(long, value_enum, default_value_t)]
pub utr_strategy: UtrOverlapStrategy,
#[derive(Clone, Copy, ValueEnum)]
pub enum UtrOverlapStrategy {
Auto,
Global,
TwoPass,
}
The earlier broader experiment sketch (Global / Backbone / PerSpeaker)
did not ship as the public surface. The validated trait boundary stayed smaller:
the current strategies are GlobalUtr and TwoPassOverlapUtr.
Why a trait, not just an enum match:
- Each strategy is 100-500 lines with its own internal state (per-speaker UTR holds diarization results, speaker mappings, per-speaker caches). Putting all of that behind a match arm in a single function would produce a 1000+ line function.
- The trait enforces that all strategies have the same contract: same inputs, same output type, same error handling. A match arm doesn’t enforce this, it’s easy for one branch to silently return a different result shape.
- Strategies are independently unit-testable. Each impl gets its own test module without coupling to the others.
- Future strategies (non-monotonic local alignment, etc.) can be added without modifying existing code.
Other candidates
If we later build multiple Rust-side implementations for other operations, the same pattern applies. Plausible future candidates:
- FA grouping strategies: different ways to partition utterances into FA windows (current fixed-window, adaptive window, trouble-window).
- Monotonicity enforcement strategies: strip timing (current), reorder utterances, accept non-monotonic (if CLAN ever supports it).
These are speculative. Do not pre-build trait abstractions for them.
Where Traits Are Not the Right Choice
Engine selection (ASR, FA, Speaker)
The engine enums (AsrBackendV2, FaBackendV2, SpeakerBackendV2) select
which Python worker to talk to. The Rust side doesn’t contain alternate
implementations, it builds a request, sends it to the worker, and parses
the response. The variation is in the request format and the Python-side
model, not in Rust logic.
Current mechanism: enum match in the dispatch layer.
match backend {
AsrBackendV2::LocalWhisper => build_whisper_request(...),
AsrBackendV2::Revai => build_revai_request(...),
AsrBackendV2::HkTencent => build_hk_tencent_request(...),
...
}
This is the right level of abstraction. A trait AsrEngine would add
indirection (vtable dispatch, boxed futures) for no benefit, the match arms
are 5-10 lines each and the “polymorphism” is just selecting request
parameters.
When to reconsider: If we ever bring an ASR or FA engine fully into Rust (e.g., a Rust CTC decoder), that engine would be a genuine alternate implementation and a trait would make sense. Until then, enum match is simpler.
Language-specific processing
The current language dispatch uses three mechanisms, all appropriate for their scale:
1. Single conditional (ASR post-processing)
// crates/batchalign-transform/src/asr_postprocess/prepare.rs: one branch for Cantonese
if lang == "yue" {
words = normalize_cantonese_words(words)?;
}
One language has special handling. A trait LanguagePostProcessor with
methods like normalize_asr_words() would require a default no-op impl for
every other language, a registry to look up the trait impl by language code,
and a dynamic dispatch call, all to replace a one-line conditional.
2. Per-language modules (morphosyntax)
crates/batchalign-transform/src/morphosyntax/lang_en.rs : English irregular verbs
crates/batchalign-transform/src/morphosyntax/lang_fr.rs : French-specific rules
crates/batchalign-transform/src/morphosyntax/lang_ja.rs : Japanese verb form patterns
Called from if lang2(&ctx.lang) == "ja" checks in
crates/batchalign-transform/src/morphosyntax/mor_word.rs (and similar
language gates in features.rs). This is already the right shape,
each language’s rules are isolated in their own module, the dispatch
point is obvious, and adding a new language means adding a module and
a conditional. A trait would formalize the interface but wouldn’t
reduce code or improve safety.
3. Table-driven lookup (number expansion)
// crates/batchalign-transform/src/asr_postprocess/num2text.rs: codegenned + hand-curated language tables
static NUM2LANG: LazyLock<BTreeMap<String, BTreeMap<String, String>>> = ...;
Adding a language is adding a table entry. This is more flexible than a trait (data-driven, no code change) and has zero dispatch overhead.
When to reconsider: If we reach 6+ languages with distinct post-processing logic (not just table entries), the conditionals would become unwieldy and a trait registry would be cleaner. We currently have 1 (Cantonese) for ASR post-processing and 3 (English, French, Japanese) for morphosyntax. We are not close to that threshold.
Command-level pipelines
Each command (transcribe, align, morphotag, translate, utseg, coref) has a distinct pipeline structure with different stages, different worker interactions, and different output shapes. They share infrastructure (worker dispatch, caching, progress reporting) but not control flow.
A trait Command with a single run() method would be a false
abstraction, the implementations would share nothing beyond the method
signature. The current explicit pipeline functions (run_transcribe_pipeline,
run_fa_pipeline, infer_batched) are clearer because each pipeline’s
structure is visible in one place.
Exception: If we add a “pipeline combinator” that chains commands (e.g., transcribe → morphotag → align in one pass), a shared trait for pipeline stages would help. This is not currently planned.
Implementation Guidelines
For the UTR strategy trait
The trait and its shipped implementations now live at
crates/batchalign/src/chat_ops/fa/utr.rs and the chat_ops/fa/utr/
submodule (drift_scenarios.rs, overlap_markers.rs, two_pass.rs).
The shipped strategies are GlobalUtr and TwoPassOverlapUtr,
selected via --utr-strategy (UtrOverlapStrategy::{Auto, Global, TwoPass}), with dispatch wired in
crates/batchalign/src/runner/dispatch/utr.rs.
If a new strategy is added later:
- Implement it as a sibling file under
crates/batchalign/src/chat_ops/fa/utr/next to the existing strategy files. - Reuse the
UtrStrategytrait fromchat_ops/fa/utr.rs; do not widen the trait surface unless absolutely required. - Wire selection in
crates/batchalign/src/runner/dispatch/utr.rs, following the existing pattern forGlobalandTwoPass.
The earlier “backbone” and “per-speaker” sketches did not ship and should not be reintroduced as templates without a fresh design pass.
For future trait candidates
Before introducing a new trait:
- Confirm there are at least 2 concrete implementations that exist or are being built in the same change. Do not create a trait for a single implementation with a vague plan for a second.
- Confirm the implementations share the same input/output contract. If the “alternate” implementation needs different inputs, it’s not the same trait, it’s a different operation.
- Prefer the simplest mechanism that works: conditional → enum match → module-per-variant → trait. Escalate only when the simpler mechanism becomes unwieldy.
Hidden experimental flags
Use clap’s hide = true for experimental strategy flags:
#[arg(long, hide = true, default_value = "global")]
pub utr_strategy: UtrStrategyChoice,
Promotion path:
- Hidden flag, default = current behavior. Used only in development and corpus experiments.
- Visible flag, default = current behavior. Documented in
--helpwith a note that the alternate strategies are experimental. - Change the default if the new strategy proves better on real data.
- Remove the flag if the old strategy is no longer needed.
Never skip step 3 → 4. Keep the old strategy available until the new one has been validated on production data by users, not just by developers.
Summary
| Dispatch dimension | Mechanism | Why |
|---|---|---|
| Algorithm strategies (UTR, future FA grouping) | Trait | Multiple Rust impls, need controlled comparison, independently testable |
| Engine selection (ASR, FA, Speaker) | Enum match | Variation is in Python worker, not Rust logic; match arms are trivial |
| Language processing (morphosyntax, ASR post) | Conditional + module + table | 1-3 languages with special handling; below threshold for trait overhead |
| Command pipelines | Explicit functions | Pipelines share infrastructure, not control flow; false polymorphism |
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).
Release Readiness State Machine
Status: Current Last updated: 2026-08-30 21:00 EDT
This document defines the authoritative release-readiness states for TalkBank’s public-facing projects. Every release claim (in reviews, docs, package metadata, or conversations) must reference one of these states. Contradictory or informal readiness language (“almost ready”, “basically done”, “should be releasable”) is not acceptable, use the state name.
States
stateDiagram-v2
[*] --> InternalExperimental
InternalExperimental --> InternalReleasable: CI gates pass\nArtifact smoke OK
InternalReleasable --> PublicBeta: Release checklist complete\nDocs aligned
PublicBeta --> PublicStable: Stabilization period\nNo breaking changes
PublicBeta --> InternalReleasable: Critical bug found
InternalReleasable --> InternalExperimental: Architecture change needed
Internal Experimental
- Active development, architecture may change
- No stability promises
- Not suitable for external users
- CI may be incomplete
Internal Releasable
- CI gates pass consistently
- Artifacts build and smoke-test correctly
- Internal team can use reliably
- Not yet documented/polished for external users
Public Beta
- Release checklist complete
- Documentation aligned with actual capabilities
- External users can try it with expectation of rough edges
- Breaking changes possible but documented
- Preview/beta status in package metadata and installation docs
Public Stable (1.0)
- Semver enforced
- Deprecation policy active
- Breaking changes only in major versions
- Stable status in package metadata and installation docs
Current State
batchalign3: Public Beta
Evidence:
- CI gates pass (tests, typecheck, lint)
- Platform wheels embed the Rust CLI and are distributed through GitHub Releases
- The release workflow builds five wheels and performs clean-wheel CLI smokes on Linux, macOS, and Windows plus server-health smokes on Unix
- The Python/Rust runtime boundary, paid-evidence caches, offline replay, and debug evidence are documented preview surfaces
- The source lives in one Cargo workspace, so there is no floating cross-repo runtime dependency
Blockers to Public Stable:
- Public API/cache/evidence compatibility policy frozen for 1.0
- Supported platform tiers expanded and exercised continuously
- Code signing/notarization policy implemented where required
- Stabilization period with no breaking changes
talkbank-tools Rust libraries: Internal Releasable
Evidence:
- CI gates pass (clippy, tests, parser equivalence)
- Core crates (parser, model, transform, clan) well-tested
Blockers to Public Beta:
- Crates published to crates.io
- A separate crates.io release contract and end-to-end publication path
This page last changed: 2026-08-30 (commit 0964e762). The whole book last changed: 2026-09-16 (commit 34d249d8).