Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

TaskSurfaceSupport Status
Transcribe, align, or morphotag CHAT with audio and MLbatchalign3 CLI / server🔷 Public preview; wheels for Windows, macOS, Linux
Standalone desktop GUI for BatchalignBatchalign 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

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.

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 batchalign2 master-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; openapi is 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:

Quick delta map

Areabatchalign2 @ 84ad500batchalign3
Core CHAT handlingPython lexer/parser/generator + string transformsRust parser + typed AST + serializer + structured validation
Alignment remap strategyDP-heavy fallback remapping and post-hoc reconstructionIdentity/index-first deterministic mapping with narrower, explicit fallback policies
Runtime topologyPrimarily local CLI dispatchLocal daemon, HTTP server, jobs/logs tooling, contributor-facing OpenAPI export
ConcurrencySequential file processing (Jan 9); concurrent dispatch added in Feb 9 but job-scopedDaemon/server job lifecycle, persistent worker subprocesses, resumable state
ExtensibilityForking / custom branchesIn-tree engines only; no public Python API and no public plugin or entry-point loader
UI/opsTerminal-centricWeb dashboard plus health/jobs/log surfaces; desktop/Tauri launcher deferred from first release
Test postureLower coverage and fewer corpus gatesbroad golden/integration suites + policy guards

Comparison states and policy

This book now works from a dual-baseline policy:

StateWhat 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 batchalign3the present Rust-first control plane and worker architecture

The primary comparison is Jan 9 anchor → current BA3:

  • use the Jan 9 batchalign2-master anchor for core / non-HK migration claims
  • use the Jan 9 BatchalignHK anchor 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 stock batchalign.
  • 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 batchalign runner for batchalign2-master
  • HK / Cantonese: a pinned batchalignhk runner for BatchalignHK

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

  1. Start with User Workflow Migration for command/runtime behavior and release-surface deltas.
  2. Then read Developer Architecture Migration for control-plane, typed-contract, and codebase-structure changes.
  3. For algorithmic behavior (alignment, retokenization, multilingual/Japanese, DP), read Algorithms, Language, and Alignment Migration.
  4. 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-master baseline 84ad500b09e52a82aca982c41a8ccd46b01f4f2c for core / non-HK behavior,
  • the Jan 9 2026 BatchalignHK baseline 84ad500b09e52a82aca982c41a8ccd46b01f4f2c for HK / Cantonese behavior,
  • the later released batchalign2 master-branch point e8f8bfada6170aa0558a638e5b73bf2c3675fe6d (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, logs
  • cache

Command crosswalk (BA2 baseline -> BA3)

BA2 command @ 84ad500BA3 equivalentNotes
alignalignsame top-level purpose; newer runtime contracts and deterministic remap behavior
transcribetranscribesame top-level purpose; expanded engine/runtime routing
translatetranslatesame
morphotagmorphotagsame command name, stronger token/validation contracts, multilingual parallel dispatch, and default-on per-word L2 dispatch
corefcorefsame purpose; public in BA3, still English-only, and still local-oriented
utsegutsegsame
benchmarkbenchmarksame high-level goal
opensmileopensmilesame high-level goal
avqiavqisame high-level goal
(none)compareAdded to BA2 master post-Feb 9; present in both current BA2 and BA3
setupsetupstill initializes local config
versionversionstill available
(none)serveBA3-only server control surface
(none)jobs / logsBA3-only job/log operational UX
(none)openapiBA3-only contributor-facing API schema export tooling
(none)cacheBA3-only first-class cache management relative to the Jan 9 BA2 baseline
(none)benchNot in Jan 9 baseline; present in Feb 9 BA2 and BA3
modelsmodelsstill 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, cache and bench were already public and runtime support around --server had 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-master pinned to 84ad500...

  • HK / Cantonese: Jan 9 BatchalignHK pinned to 84ad500...

  • 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.py for curated benchmark cases
  • use scripts/compare_stock_batchalign.py for 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, --wav2vec
  • transcribe: --whisper, --whisperx, --whisper-oai, --rev, --diarize, --nodiarize
  • benchmark: --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 familyJan 9 BA2Feb 9 BA2Current BA3
setup, version, modelspresentstill presentstill present, but behind Rust CLI
cacheabsent as public CLIpublic commandpublic command with Rust CLI/server integration
benchabsentpublic commandpublic command
serve, jobs, logsabsentnot public CLI commands in released BA2 masterpublic BA3 utility/ops surfaces
openapiabsentnot public CLI command in released BA2 mastercontributor-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.ini and Rev.AI defaults
    • current BA3: same public purpose, but implemented in Rust with explicit interactive/non-interactive validation
  • 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 version subcommand with package version plus build hash
  • cache:
    • Jan 9 BA2: no public cache-management command
    • Feb 9 BA2: Python cache command for stats/clear/warm against Python-side cache-manager state
    • current BA3: Rust cache command for analysis/media cache inspection and clearing aligned to the current SQLite/media-cache runtime
    • practical delta: cache clear --all also removes permanent UTR cache entries, while BA2’s cache warm prewarm 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, and avqi; for these, an explicit --server flag is ignored in favor of the local daemon. (See command_prefers_local_daemon in crates/batchalign/src/cli/dispatch/mod.rs.) A separate sidecar daemon profile exists in cli/daemon.rs for 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 through serve lifecycle commands.

1.1) Biggest durable user-visible changes

If you are coming from BA2, the changes most likely to affect real corpus results are:

  • morphotag correctness is stronger: %mor/%gra generation 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, see morphosyntax/batch.rs in crates/batchalign/), and large single-language batches are split across multiple workers (up to DEFAULT_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, and logs.

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; openapi is 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 batchalign3 release 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/logs inspection 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 %wor and 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:

  • %gra root attachment now follows head=0,
  • invalid root/head structures are rejected instead of written out,
  • MWT and contraction handling are more stable,
  • special forms such as @c, @s, and xbxxx are handled more explicitly,
  • reflexive pronouns emit reflx,
  • retokenize=false preserves 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

AreaJan 9 BA2 -> Feb 9 BA2Feb 9 BA2 -> current BA3
alignreleased BA2 already improved cache use, failure handling, and runtime robustnessFA grouping, timing injection, %wor, monotonicity handling, and much of the parse/cache/infer/inject flow move into Rust orchestration
morphotagreleased 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

CommandJan 9 BA2 -> Feb 9 BA2Feb 9 BA2 -> current BA3
transcribePython pipeline becomes faster and more robust, especially in dispatch/startup/long-audio handlingPython stops owning transcript construction; Rust owns postprocess, CHAT assembly, and optional downstream stages
translatemostly lazy-load/runtime cleanup, not a major algorithm shiftRust takes over CHAT extraction, cache, validation, and %xtra injection; Python becomes pure text inference
utsegsame Python constituency + DP alignment algorithm, with cache/lazy-load cleanupPython returns raw trees; Rust computes assignments and mutates CHAT directly
corefessentially same document-level Python+DP remap pathPython returns structured chains; Rust injects sparse %xcoref and enforces output policy
benchmarkruntime/dispatch and benchmarking UX improve, but still Python-owned command flowRust now owns benchmark orchestration end to end; Python only contributes raw ASR inference when needed, and current BA3 still honors --wor / --nowor
opensmilemostly lazy-load/runtime cleanupstill pure feature extraction, but now behind typed prepared-audio V2 contracts with explicit non-CHAT output handling
avqimostly lazy-load/runtime cleanupstill 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:LANG routing is now default-on for morphotag; use --no-l2-morphotag if you need the older L2|xxx placeholder behavior for reproducibility
  • explicit @s:LANG still routes to LANG even when LANG is missing from @Languages, but validation emits warn-only E254 so the header mismatch is visible
  • whole-utterance same-language all-@s runs are no longer accepted as an utterance-language shorthand; BA3 validates them as E255 and expects [- lang] instead
  • chatter debug fix-s is the companion repair tool for migrated corpora: it rewrites qualifying whole-utterance @s runs, 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_dir is still the shape
  • avqi input_dir output_dir is 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.txt hunting during server/daemon runs
  • avqi no 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

  1. Update the binary name in your scripts: batchalignbatchalign3. Output is now an -o flag instead of a positional argument.
  2. 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, --diarize still work, they translate to current typed flags.)
  3. Validate your expected outputs against current golden behavior, especially if your corpus has overlap / retraces / repetitions.
  4. Rebaseline any %mor / %gra expectations that depended on BA2 bugs or unstable remap behavior. Compare against current intended outputs, not Jan 9 BA2 accidental ones.
  5. 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.
  6. (Reserved, was previously a note about opensmile CSV layout changing during the rewrite. That divergence has been corrected; opensmile output now matches the BA2 feature,value shape and no script changes are required.)
  7. Adopt jobs / logs / cache operational commands for repeatability.
  8. 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-master 84ad500b09e52a82aca982c41a8ccd46b01f4f2c for core / non-HK behavior
  • BatchalignHK 84ad500b09e52a82aca982c41a8ccd46b01f4f2c for HK / Cantonese behavior
  • later released batchalign2 master-branch point e8f8bfada6170aa0558a638e5b73bf2c3675fe6d where needed
  • current batchalign3

This page excludes transient unreleased migration-branch states.

See also:

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.py for curated benchmark cases
  • scripts/compare_stock_batchalign.py for 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 stock batchalign.
  • Preserved Jan 9 legacy runners should keep their native CLI shape: command inputfolder outputfolder.
  • When older legacy benchmark runs emit .asr.cha/.wer.txt/.diff rather than modern .compare.csv, the curated harness should normalize them by rescoring the emitted .asr.cha through current batchalign3 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): uses fork() to inherit parent’s loaded models. Linux-only, disabled on macOS+MPS, crash-prone.

Memory characteristics (from Feb 9 BA2 benchmarks):

WorkloadPer-worker peakWorkersTotal
align (Whisper+Wave2Vec)3.0-4.2 GB4~16 GB
morphotag (Stanza)1.1-2.5 GB4~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:

DimensionBA2BA3
Worker lifetimeJob-scoped (die after each job)Persistent (idle timeout 10 min)
Model loadingFresh per worker per jobLoad once at startup, reused
GPU model sharingFork-based (crash-prone) or noneThreadPoolExecutor inside one process (GIL-release)
CPU parallelismProcessPoolExecutor (N copies)Stanza profile: N persistent subprocesses, two-level parallelism (cross-language + intra-language chunking)
Concurrency controlAdaptive RSS monitoringAuto-tuned + memory gate + per-profile limits
Worker healthNoneHealth checks every 30s, auto-restart
Worker pre-spawnNone (cold start every file)Per-job pre-scaling before file dispatch
File orderingLargest-first sortingSubmission order (largest-first planned)

Memory comparison (mixed English workload, align + morphotag):

SystemGPU workersStanza workersTotal
BA2 (4 process workers)4 × ~4 GB = ~16 GB4 × ~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:

  1. GPU profile shares ASR, FA, and Speaker models in one process (vs 3 separate)
  2. Persistent workers eliminate per-job model reloading overhead

3) Codebase crosswalk for contributors

Legacy concern (BA2)Current concern (BA3)
batchalign/cli/cli.py command wiringRust CLI argument tree + command router (crates/batchalign)
local dispatch in batchalign/cli/dispatch.pyserver + local-daemon dispatch + job APIs (crates/batchalign)
Python CHAT parser/generator modulesRust CHAT crates + serializer/validator path in core
ad-hoc alignment remap gluecontract-driven UTR/FA handlers with deterministic fallback policies
monolithic Python command pipelinestask-local Python inference + Rust orchestration/injection/postprocess
provider-specific modifications in forksin-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:

  • %gra construction 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-@s is now a validation concern (E255), while explicit undeclared @s:LANG is 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 utseg and morphotag
  • 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 %xcoref injection, and validation
  • compare:
    • later batchalign2-master: Python owned morphosyntax -> compare -> compare_analysis, projected through the Python Document model, and relied on string/document regeneration as the projection mechanism
    • current BA3: Rust: morphotag main only, keep gold raw, build a ComparisonBundle with local-window alignment plus structural word matches, and materialize either the released main output or an internal AST-first gold projection
  • 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_core Python 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
  • 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

RemovedReplacement
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

  1. Start with the command/runtime crosswalk plus the current HK engine and extension-layer chapters.
  2. 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.
  3. Add/extend golden cases before behavior changes.
  4. 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-master compare 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.csv from a structured table model instead of reconstructing structure from strings or a Python Document shell.

Source map

Use these as the primary files when reviewing the rewrite:

  • BA2 reference (the local ~/batchalign2-master archive is checked out at the Jan 9 baseline 84ad500b..., where compare.py does not yet exist; use the post-redesign commit 1f224df346c2ec590d45afa31136a3b878db622b to 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 calls compare() and project_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 old compare_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.cha convention
  • 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:

  1. 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.

  2. 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 gold ChatFile. It does not infer projection by reparsing %xsrep or by patching reconstructed strings. %xsrep / %xsmor are emitted from typed compare-tier models, and .compare.csv is emitted from a structured table model via the standard Rust csv crate.

  3. Tier projection is conservative by design. Exact structural matches may copy %mor, %gra, and %wor wholesale. Full gold-word coverage without exact structural identity may still project %mor. Partial %gra / %wor projection is intentionally withheld until there is a chunk-safe mapping, because “close enough” projection is exactly how BA2-style structural drift happens.

  4. 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 / %xsmor and .compare.csv. The internal main-annotated materializer remains available for benchmark-style flows, but it is no longer the compare command contract.

  5. One explicit bug-exception policy is in force. batchalign2-master can emit structurally lossy partial %mor / %gra projection 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

ConcernBA2BA3
Gold pairingCLI / dispatch filename logiccompare planner + dispatch pairing
Best-window search_find_best_segment()find_best_segment()
Local alignment coreCompareEngine.process()compare()
Metrics outputCompareAnalysisEngineCompareMetricsCsvTable / format_metrics_csv()
Gold-side projectionPython Document / serializer pathproject_gold_structurally()
Output selectioncommand path decides output formmaterializer 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-master did 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 / %xsmor was missing the gold utterance terminator as PUNCT

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:

  1. extend ComparisonBundle and project_gold_structurally(), not the serialized %xsrep / %xsmor text
  2. use AST walkers and dependent-tier helpers before inventing new string glue
  3. extend the typed compare-tier / CSV models before widening serializer output strings
  4. do not project partial %gra / %wor without explicit chunk-safe mapping
  5. 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 the batchalign2-master local-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.csv all driven from the same bundle and lowered through typed serializer models instead of raw string assembly?
  • are partial %gra / %wor projections 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.

  • Document owns transcript structure, metadata, media references, and utterances.
  • Utterance owns ordered Form content plus dependent information such as timing, morphology, dependency, comparison tokens, and comments.
  • Most engines mutate or analyze Document directly.
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.

  • BatchalignEngine is the unit of work.
  • Engines advertise tasks.
  • Engines implement one of:
    • generate
    • process
    • analyze
  • BatchalignPipeline sequences 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.

  • transcribe with Rev.AI: BA2 used Rev speaker labels directly and still ran BA-side utterance segmentation through process_generation(..., utterance_engine=...).
  • transcribe_s / --diarize: CLI dispatch mapped to asr,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 handling
  • dispatch_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:

  • batchalign2 baseline 84ad500b09e52a82aca982c41a8ccd46b01f4f2c
  • later released batchalign2 master-branch point e8f8bfada6170aa0558a638e5b73bf2c3675fe6d where 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/%gra drift 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,
  • %gra generation uses explicit chunk/head validation rather than positional guesswork.
  • utseg now treats constituency parsing and assignment computation as separate steps instead of flattening subtree leaves and DP-aligning them back to forms.
  • coref now carries typed sentence/chain structure instead of detokenized text plus DP remap back to utterance positions.

For migration purposes, separate:

StageAlgorithm/data-structure shift
Jan 9 BA2 -> Feb 9 BA2released BA2 already improved DP behavior, caching boundaries, and a number of robustness/performance details inside the Python architecture
Feb 9 BA2 -> current BA3current 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:

  1. 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.

  2. 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

FeatureBA2 Jan 9BA3
UTR matchingExact onlyFuzzy (Jaro-Winkler 0.85) default inside TwoPassConfig
Overlap awarenessNoneTwoPassOverlapUtr with index-aware onset matching, currently reachable only via explicit --utr-strategy two-pass
Dense overlap handlingNone (all words treated equally)Density detection inside two-pass (max_exclusion_density = 0.30, falls back to global when exceeded)
Language-aware UTRNone (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 FANone@Options: CA suppresses %wor generation in fa/mod.rs::run
Custom media directoryNot supported--media-dir flag
FA orchestrationPython string surgeryRust 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:

  • %gra root 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 %gra relations;
  • @c and @s special forms are mapped explicitly rather than relying on placeholder leakage;
  • xbxxx placeholders are restored back to the original form in retokenized output;
  • reflexive pronouns explicitly emit reflx;
  • retokenization can split contractions structurally, while retokenize=false preserves original tokenization.

Important comparison nuance:

  • reflexive reflx, special-form handling, and xbxxx restoration were not invented only in current BA3; older BA2 already had versions of those behaviors in Python ud.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 %xtra back 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 %xcoref by validated sentence/line mapping.

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 BatchalignHK baseline.
  • 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 yue ASR chunks are then split with the shared cantonese_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 every dp_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! at crates/batchalign-transform/src/retokenize.rs:75 ("retokenize text diverged; using length-aware monotonic fallback without DP") fires whenever build_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:

ScenarioBA2BA3 (initial)BA3 (current)
Unknown code → Rev.AIpycountry crash (uncaught AttributeError)Silent wrong code via &other[..2] truncationHard error at job submission with alternatives
Unknown code → Whisperpycountry crashSilent English fallback (return "english")Hard ValueError with message
Unsupported Rev.AI languageHTTP 400 deep in pipelineHTTP 400 deep in pipelineRejected 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”:

  1. Rev.AI truncation fallback: &other[..2] silently produced wrong codes (e.g., hakha, polpo). Replaced with a ~78-entry explicit mapping table (revai/preflight.rs::try_revai_language_hint)

    • "auto" fallback with tracing::warn.
  2. Whisper English fallback: return "english" when pycountry found 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 with raise 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.

ScenarioBA2BA3 (current)
Missing @Languages: headerSilent ["eng"] default applied to non-English filesHard 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 inferenceHard error at parse with the offending value quoted in the diagnostic.
Bilingual file (@Languages: spa, eng)Primary language usedPrimary language used (unchanged). Secondary languages routed per-utterance via [- xxx] precodes; @s words routed to L2 dispatch by default.
Job-level --lang flagSentinel 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 provenanceRemoved. 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 uses GlobalUtr. The language-aware / overlap-inspecting branch was disabled 2026-03-30 after operator-reported alignment regressions on real files, uncorrected end-time overlap (enforce_monotonicity only 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-level select_strategy() in chat_ops/fa/utr.rs inspects the file for +< linkers or CA markers and returns TwoPassOverlapUtr if 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:

  1. Rust (morphosyntax/batch.rs): extracts per-utterance language_code from [- lang] precodes (falls back to @Languages header, then to primary --lang), groups all cache-miss items by language
  2. Rust (morphosyntax/batch.rs): dispatches language groups via futures::future::join_all, gated by a tokio::sync::Semaphore of max_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.
  3. Rust (morphosyntax/worker.rs): within each language group, splits large batches into chunks across up to max_workers_per_key (default 4) workers of the same language, also via join_all.
  4. 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.

BehaviorBA2BA3
[- lang] precode parsedYesYes
Per-utterance Stanza routingNo (always primary lang)Yes (Rust groups by language)
Cross-language parallelismNoYes (concurrent language groups, semaphore-bounded)
Intra-language parallelismNoYes (chunked across multiple workers)
@s:lang per-word routingNoYes (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:

BehaviorBA2BA3
Unsupported secondary languageSilently 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 codeNever (MultilingualPipeline absorbs it)Never (Rust filters before dispatch)
POS accuracy for unsupported langsWrong (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.

BehaviorBA2BA3 (before registry)BA3 (with registry)
Capability sourceHardcoded in codeHardcoded in 7 tablesStanza resources.json (authoritative)
Dutch utsegCrash (“constituency not known for nl”)Crash (same bug)Graceful (constituency omitted, sentence-boundary fallback)
MWT detectionBA2 exclusion-list inversionCopy of BA2 exclusion list (MWT_LANGS)Per-language from resources.json
Unsupported languageWrong model (MultilingualPipeline fallback)Reject/skip at dispatchReject at submission with clear error
iso3→alpha2 mappingHardcoded dict (56 codes)Hardcoded dict (3 copies: Python + 2 Rust)Derived from pycountry + resources.json
Constituency availabilityAlways 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.

CapabilityBA2 Jan 9BA3
--lang auto CLI flagNot explicitly supported; Whisper auto-detect worked implicitly by omitting the language kwargFully supported with Rev.AI Language ID pre-pass
Primary language detectionNone, always used user-specified languageRev.AI Language ID API (audio-based, ~5-30s, high accuracy); whatlang trigram fallback for Whisper
[- lang] code-switching precodesNever generated during transcribeGenerated per-utterance via whatlang trigram detection
Multi-language @Languages headerSingle language onlyMultiple languages, frequency-ordered, primary first
Rev.AI Language Identification APINot usedUsed 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):

  1. 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.

  2. Transcription with concrete language: the detected language is used as the language hint for the transcription job, which enables speakers_count and skip_postprocessing to be set correctly.

  3. 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.

  4. Multi-language @Languages header: the primary language is always listed first; secondary languages are added if they appear in MIN_UTTERANCES_FOR_SECONDARY (= 3) or more utterances, in descending-frequency order. The constant lives in crates/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 @Languages comes out spa, eng and the primary stays spa.
  • Whisper + whatlang frequently inverts the order to eng, spa with 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 requires MIN_CHARS_FOR_DETECTION (= 40 alphabetic chars) and UTTERANCE_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:

StageBA2 Jan 9BA3
Compound mergingPython, wordlist-driven merge (compounds list, O(n) in membership)Rust, 3,584 pairs in LazyLock<HashSet<(&str, &str)>> (O(1) lookup)
Multi-word splittingPython, timestamp interpolationRust, same algorithm
Number expansionPython, 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 normalizationPython (OpenCC dependency)Rust (ferrous-opencc crate, no Python OpenCC needed)
Long-turn splittingPython, 300-word thresholdRust, same threshold
RetokenizationPython, punctuation-basedRust, same algorithm
Disfluency replacementPython, string matchingRust, same wordlists
N-gram retrace detectionPython, ad-hoc groupingRust, 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

OperationBA2BA3Rationale
Pre-Stanza paren strippingline_cut.replace("(","") / replace(")","") strips parens from input before StanzaNo pre-Stanza stripping; Word::cleaned_text() handles CHAT notation at extraction timeBA2 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 outputSame operations, ported line-by-line in mor_word.rs:107-129Not a real BA2-vs-BA3 divergence, BA3 preserves the BA2 lemma-cleanup logic by design. The cleaned MOR is the migration contract.
Cantonese word segmentationNonePyCantonese segment() via --retokenizeBA2 had no word segmentation for CJK per-character ASR output.
Cantonese POS accuracy on core vocabularyStanza zh (Mandarin model), internal benchmarks scored ~50% on core Cantonese; ~63% Mandarin baseline on the UD held-out test setPyCantonese 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 (AsrRawTextAsrNormalizedTextChatWordText) 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:

  • polpo (should be pl)
  • hakha (not a valid ISO 639-1 code)
  • cesce (should be cs)

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>):

UtteranceBA2 / BA3 pre-fixBA3 post-fix
... her &-you_know . (trailing)you_know: no timingyou_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_382989you_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:

  1. 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.

  2. 60+ lines of .replace() calls. Python’s annotation_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.

  3. 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.

  4. 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

SuiteTestsWhat’s Verified
Rust unit tests65Every module: parsing, alignment, injection, retokenization
Python integration tests27End-to-end pipeline with test doubles
Python-Rust equivalence tests~20Same inputs produce same word extractions
DP alignment equivalence tests~29Rust alignment matches Python alignment exactly
CHAT round-trip tests~9Parse → 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 ClassPython (Text)Rust (AST)
Retrace confusionRegex to detect <...> [/]UtteranceContent::Retrace: dedicated variant, structurally distinct
Special form handlingString search for @c, @sWord.form_type field, parsed once, always available
Word boundary errorsCharacter-level offset trackingWords are tree nodes; boundaries are implicit
Tier count mismatchCount words in string, count mor items in string, hope they matchTraverse same tree for extraction and injection, count is always consistent
Annotation stripping.replace() for each character classAST 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 SizePythonRust
n=1,000~500ms<1ms
n=5,000minutes<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

ComponentPythonRust
Word extractionannotation_clean() + lexer filtering: ~100 lines of .replace() and regexextract.rs: 263 lines of typed traversal
%mor/%gra constructionString concatenation in morphoanalyze(): scattered across ~200 linesmor_parser.rs + inject.rs: 1,056 lines, self-contained
Retokenization~120 lines of character-level backplate + 15 regex fixupsretokenize.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:

  1. Production testing against large corpus: Completed. Rust pipeline validated against existing annotations.
  2. Forced alignment migration: Completed. FA uses the same AST + callback pattern via inference/fa.py.
  3. Utterance segmentation migration: Completed. Utseg uses the same pattern via inference/utseg.py.
  4. 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

StateLocationWhat it stores
Analysis cacheplatform-dependent OS cache dir (see below)SQLite database of NLP results keyed by content hash
Model cacheplatform-dependent (see below)Downloaded ML model weights (~2 GB)
Config file~/.batchalign.iniASR engine selection, Rev.AI API key
Run logsplatform-dependent OS cache dirPer-run structured logs
Daemon PIDplatform-dependentBackground 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 morphotag downloads Stanza models (~500 MB)
  • First run of align downloads Whisper/Wave2Vec models (~1-2 GB)
  • No network connection needed after first download
  • The download itself surfaces through progress_v2 events 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:

  1. The first call may be slow: models download and daemon starts.
  2. The daemon persists: after your driver process exits, the daemon continues running. Stop it explicitly with batchalign3 serve stop if you don’t want it.
  3. Audio-task results are cached: re-aligning identical media is near-instant; text-NLP results recompute every run.
  4. 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.

FlagTypeDefaultBaselineBA3 Status
-v / --verbosecount0Jan 9Wired (global verbosity)
--workersintos.cpu_count()Feb 9 onlyWired (worker count)
--memlogflagoffFeb 9 onlyRemoved
--mem-guard / --no-mem-guardflagoffFeb 9 onlyRemoved
--adaptive-workers / --no-adaptive-workersboolTrueFeb 9 onlyRemoved
--pool / --no-poolboolTrueFeb 9 onlyRemoved
--lazy-audio / --no-lazy-audioboolTrueFeb 9 onlyRemoved
--adaptive-safety-factorfloat1.35Feb 9 onlyRemoved
--adaptive-warmupint2Feb 9 onlyRemoved
--force-cpuboolFalseFeb 9 onlyWired (no --no-force-cpu companion in BA3)
--shared-models / --no-shared-modelsboolFalseFeb 9 onlyRemoved

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.

FlagTypeDefaultHelpBA3 Status
--whisper / --revexclusive pair--revUTR engine selectionHidden compat alias → --utr-engine
--wav2vec / --whisper_faexclusive pair--wav2vecFA engine selectionHidden compat alias → --fa-engine
--pausesflagoffKeep each word’s own end time instead of healing small gaps into it. Applies to every FA engine.Wired
--wor / --noworboolTrueWrite %wor tierWired (default_value_t = true)
--merge-abbrev / --no-merge-abbrevboolFalseMerge abbreviationsWired

Pipeline task: "fa" (forced alignment).

transcribe

Create transcripts from audio files via ASR.

FlagTypeDefaultHelpBA3 Status
--whisper_oai / --revexclusive pair--revASR engine (OAI variant)Hidden compat alias → --asr-engine; the engine is not implemented, so the job is refused
--whisper / --revexclusive pair--revASR engine (HF variant)Hidden compat alias → --asr-engine
--whisperx / --revexclusive pair--revASR engine (WhisperX variant)Hidden compat alias → --asr-engine; the engine is not implemented, so the job is refused
--diarize / --nodiarizeboolFalseSpeaker diarizationHidden compat alias → --diarization
--wor / --noworboolFalseWrite %wor tierWired
--merge-abbrev / --no-merge-abbrevboolFalseMerge abbreviations (Feb 9 only)Wired
--langstr"eng"Language codeWired
-n / --num_speakersint2Expected speaker countNot 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).

FlagTypeDefaultHelpBA3 Status
--retokenize / --keeptokensboolFalseRetokenize main line for UDWired
--skipmultilang / --multilangboolFalseSkip multilingual filesWired
--lexiconpathNoneManual lexicon overrideWired
--override-media-cache / --use-cacheboolFalseBypass analysis cache (Feb 9 only)Wired
--merge-abbrev / --no-merge-abbrevboolFalseMerge abbreviations (Feb 9 only)Wired
--no-l2-morphotagflagoffOpt out of BA3’s default-on per-word @s secondary dispatch and keep legacy L2|xxx placeholdersBA3-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.

FlagTypeDefaultHelpBA3 Status
--merge-abbrev / --no-merge-abbrevboolFalseMerge abbreviations (Feb 9 only)Wired
--translate-engine google|seamless|nllb|tencent|aliyunenumgooglePick translation engineBA3-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.

FlagTypeDefaultHelpBA3 Status
--merge-abbrev / --no-merge-abbrevboolFalseMerge abbreviations (Feb 9 only)Wired

Pipeline task: "coref".

utseg

Utterance segmentation.

FlagTypeDefaultHelpBA3 Status
--langstr"eng"Language codeWired
-n / --num_speakersint2Expected speaker countNot 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-abbrevboolFalseMerge abbreviations (Feb 9 only)Wired

Pipeline task: "utseg".

benchmark

ASR word error rate benchmarking against gold transcripts.

FlagTypeDefaultHelpBA3 Status
--whisper / --revexclusive pair--revASR engine (HF variant)Hidden compat alias → --asr-engine
--whisper_oai / --revexclusive pair--revASR engine (OAI variant)Hidden compat alias → --asr-engine; the engine is not implemented, so the job is refused
--langstr"eng"Language codeWired
-n / --num_speakersint2Expected speaker countNot 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 / --noworboolFalseWrite %wor tierWired
--merge-abbrev / --no-merge-abbrevboolFalseMerge abbreviations (Feb 9 only)Wired

Pipeline task: "asr" (transcribe) + "morphosyntax" (compare).

compare (Feb 9 only)

Transcript comparison against gold-standard references.

FlagTypeDefaultHelpBA3 Status
--langstr"eng"Language codeWired
--merge-abbrev / --no-merge-abbrevboolFalseMerge 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.

FlagTypeDefaultHelpBA3 Status
--feature-setchoice"eGeMAPSv02"eGeMAPSv02 / eGeMAPSv01b / GeMAPSv01b / ComParE_2016Wired
--langstr"eng"Language codeWired

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.

FlagTypeDefaultHelpBA3 Status
--langstr"eng"Language codeWired

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

EngineTaskModuleCredentials
tencentASRbatchalign_hk.tencent_asrTencent Cloud API key
aliyunASRbatchalign_hk.aliyun_asrAliyun NLS API key
funaudioASRbatchalign_hk.funaudio_asrNone (local model)
wav2vec_cantoFAbatchalign_hk.cantonese_faNone (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

CommandPipeline Task StringNotes
align"fa"Forced alignment
transcribe"asr"Without diarization
transcribe (diarized)"asr" + speakerWith --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 -v console logging (~30 scattered L.info() / baL.info() calls, ~15 raw print() statements) via Python’s logging module 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:

FailureWhat is logged
Utseg pre-validation failsFull CHAT text + parse error details
Whisper inverted timestampsWarning with start/end values
MOR item count mismatchWord count + MOR count + utterance text
Stanza sentence count mismatchExpected 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 (%mor and %gra tiers) 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:

  1. 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 point e8f8bfa....
  2. User Guide – Installation, quick start, CLI reference, Python API, server setup, and troubleshooting.
  3. Architecture – How the pipeline, engine, dispatch, caching, and validation systems work internally.
  4. Technical Reference – Detailed documentation on CHAT format, morphosyntax, forced alignment, multilingual support, and more.
  5. Developer Guide – Building from source, testing conventions, adding new engines, and working with the Rust core.
  6. 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

RequirementDetails
Python3.13 or 3.14 (a uv-managed 3.13 is used by default)
Disk spaceSeveral GB for ML models (downloaded on first use)
RAM8 GB minimum, 16 GB recommended
FFmpegOnly needed for some media formats
PlatformsmacOS 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:

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-fork and its segmentation/embedding dependencies) are public and ungated, but that engine also fetches one UNPINNED PLDA calibration artifact whose default is currently the GATED pyannote/speaker-diarization-community-1 repository; 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 runWhat downloadsApproximate sizeApproximate first-run wait
morphotag (any language)Stanza resource catalog (resources.json)~1 MB1-2 seconds
morphotag (first time for a language)Stanza language pack for that language250-500 MB30 s to 2 min
morphotag --retokenize on a Cantonese file (@Languages: yue)Nothing extra, PyCantonese is bundledNot applicableInstant
morphotag --retokenize on a Mandarin file (@Languages: cmn/zho)Stanza Chinese tokenizer~200 MB30-60 s
transcribe (Whisper engine)Whisper ASR model from HuggingFace0.5-3 GB depending on model size1-10 min
align (Whisper engine)Whisper FA model from HuggingFace~3 GB3-10 min
align (Wave2Vec engine, default)Wave2Vec MMS_FA bundle from torchaudio~1.2 GB1-5 min
align --lang yue (Cantonese FA)Wave2Vec Cantonese model~1 GB1-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 MB1-3 min once authorized
transcribe --diarization enabled (default speaker backend) or diarize --speaker-engine pyannote-aiNo local speaker model; uses paid pyannoteAI Precision-2N/AProvider latency only
translate (Seamless engine)SeamlessM4T from HuggingFace~2.4 GB2-8 min
transcribe (utterance segmentation, certain languages)BERT utterance model from HuggingFace~400 MB1-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:

LibrarymacOSLinuxWindows
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\
PyCantoneseBundled 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:

LibraryEnvironment variable
StanzaSTANZA_RESOURCES_DIR
HuggingFaceHF_HOME (controls all subpaths) or HF_HUB_CACHE (just the model hub)
torchaudioTORCH_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:

OSPath
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.


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: batchalign3 CLI, local server, and dashboard UI

Evaluating from source

Getting started

  1. Install Batchalign: follow the Installation guide. The desktop app needs batchalign3 on your PATH.

  2. Launch the shell from source: run npm run dev from apps/dashboard-desktop/.

  3. 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 that batchalign3 setup writes 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:

CardCommandWhat it does
Transcribe AudiotranscribeTurn audio or video recordings into written transcripts in CHAT format
Add GrammarmorphotagAdd part-of-speech tags and grammatical structure, needed for CLAN commands like MLU and DSS
Align to AudioalignLink each word in a transcript to its exact moment in the audio, so you can click to play
TranslatetranslateAdd an English translation line under each utterance in a non-English transcript
Segment UtterancesutsegAutomatically break a long block of text into separate speaker turns
Score AccuracybenchmarkMeasure 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

  1. Pick a command: click one of the six cards.

  2. Choose input files: click the dashed area to open a native folder picker. The app scans the folder for relevant files (.cha for most commands, audio files for Transcribe). You’ll see the file count and folder path.

  3. 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.

  4. Select language: shown for commands that need it (Transcribe, Align, Segment Utterances, Score Accuracy). Defaults to English.

  5. 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:

IndicatorMeaning
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:

  • 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:

CounterColorMeaning
crashesRedWorker processes that crashed unexpectedly
forced killsRedFiles force-terminated (OOM, stuck)
gate rejectsAmberJobs rejected by the memory gate
attemptsGrayTotal file processing attempts started
retriesAmberAttempts that were retried after transient failures
deferredGrayWork 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:

PhaseWhat’s happeningTypical duration
ReadLoading CHAT, resolving audio, checking cacheSeconds
TranscribeASR inference, timing recoveryMinutes (proportional to audio length)
AlignForced alignment on utterance groupsMinutes
AnalyzeMorphosyntax, segmentation, translation, coreferenceSeconds to minutes
FinalizePost-processing, building CHAT, writing outputSeconds

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:

  1. Reading: loading the CHAT file from disk
  2. Resolving audio: finding and preparing the media file
  3. 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.
  4. Aligning: forced alignment on utterance groups. Shows sub-progress (e.g., “3/7” groups).
  5. 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:

  1. Resolving audioTranscribingPost-processingBuilding CHAT → optional Segmenting / MorphosyntaxFinalizingWriting

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:

  1. Reading: files are loaded one at a time; each transitions from the initial stage to “Reading” during I/O.
  2. 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.
  3. 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:

SegmentPipeline PhaseStages Included
1ReadReading, Resolving audio, Checking cache, Parsing
2TranscribeTranscribing, Recovering utterance timing, Recovering timing (fallback)
3AlignAligning, Applying results
4AnalyzeMorphosyntax, Segmentation, Translation, Coreference, Comparing, Benchmarking
5FinalizePost-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

ClientWhat you see
Web dashboardTwo-column layout: job list with pipeline phase bars, system panels (workers, memory, vitals). See Web Dashboard for details.
Desktop appProcessing progress view with SSE-driven file list and stage labels
CLIindicatif progress bar with file count, elapsed time, and per-file terminal logs for both server-backed and direct local runs
TUIPer-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 from started_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/--output for a destination directory.
  • Omitting -o/--output means 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


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

OptionMeaning
-v, -vv, -vvvIncrease verbosity
--workers NMaximum 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-cpuDisable MPS/CUDA and force CPU-only models
--server URLRemote server URL. Env fallback: BATCHALIGN_SERVER
--override-media-cacheBypass the media analysis cache (audio tasks only; text NLP tasks are not cached at all)
--require-media-cacheRequire reusable evidence at cache-backed media stages; a miss fails instead of authorizing inference. Conflicts with both cache-override forms.
--override-media-cache-tasks TASKSBypass only named audio-evidence caches (comma-separated: forced_alignment, utr_asr, rev_asr_evidence, speaker_diarization_raw_evidence)
--debug-dir PATHDirectory 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 SECONDSOperator 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-tuiToggle full-screen TUI for server-backed jobs (DirectHost local runs stay on terminal progress bars)
--open-dashboard / --no-open-dashboardToggle browser auto-open for submitted server job pages (macOS only, interactive TTY only)
--engine-overrides JSONPer-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.
--sequentialProcess files one at a time with a single worker. No memory gate, no server. Ideal for small jobs on laptops
--no-serverSkip 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.

OptionCommandsMeaning
--asr-engine NAMEtranscribe, benchmarkASR engine.
--fa-engine NAMEalignForced-alignment engine.
--utr-engine NAMEalignUtterance-timing-recovery engine. Only consulted with --utr.
--existing-wor-boundaries {preserve,rebuild-from-evidence}alignv0.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}alignSame-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 NAMEtranslateTranslation 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 1 and --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-dashboard was not passed
  • The BATCHALIGN_NO_BROWSER environment 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:

OptionMeaning
PATHS...Input files or directories
-o, --output DIROutput directory
--file-list FILERead input paths from a text file (see below)
--in-placeModify 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)

CommandWhat it does
alignAdd word-level and utterance-level timestamps via forced alignment
morphotagAdd %mor POS/lemma and %gra dependency tiers
utsegRe-segment utterance boundaries using Stanza constituency parsing
translateAdd %xtra English translation tiers
corefAdd sparse %xcoref coreference annotation tiers (English only)
compareCompare against gold .cha references; write %xsrep/%xsmor + .compare.csv

Audio-input commands (input audio → new files)

CommandWhat it does
transcribeCreate .cha transcripts from audio via ASR
benchmarkTranscribe and evaluate WER against gold .cha references
opensmileExtract acoustic features → .opensmile.csv (positional I/O)
avqiCalculate 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:

OptionMeaning
--engine {rev,whisper}Persist default ASR engine
--rev-key KEYRev.AI key for non-interactive setup
--non-interactiveDisable prompts

logs

batchalign3 logs
batchalign3 logs --last
batchalign3 logs --export
batchalign3 logs --clear

Key options:

OptionMeaning
--lastShow the most recent run log
--rawRaw JSONL output with --last
--exportZip recent logs
--clearDelete log files
--followTail the newest log file
-n, --count NNumber of recent runs to list

serve

batchalign3 serve start --foreground
batchalign3 serve status
batchalign3 serve stop

serve start key options:

OptionMeaning
--port PORTListen port
--host HOSTBind address
--config PATHAlternate server.yaml path
--python PATHWorker Python executable
--foregroundDo not daemonize
--test-echoStart 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:

SubcommandPurpose
models prepExtract training text from CHAT files (Rust-native, no CLAN needed)
models trainForward 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.

OptionMeaning
--runs NNumber of repeat runs (default: 1)
--dataset LABELDataset label included in structured output (useful for cross-run comparison)
--use-cacheUse 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.

OptionMeaning
--lang LANGLanguage to test (default: eng)
--format {human,json}Output format (default: human)
--python PATHCustom Python path (overrides BATCHALIGN_PYTHON)
--explain KNOBTrace 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-errorsTreat 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:

SubcommandPurpose
eval l2-morphotagL2 morphotag evaluation: pair @s words with %mor / %gra items via typed AST walk (supersedes scripts/l2-eval/analyze.py)
eval utr-alignmentOffline global UTR word-to-token replay with fingerprinted typed evidence and no inference or CHAT mutation
eval utseg-replayReapply 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:

CodeMeaning
2Usage/input error
3Configuration error
4Network/connectivity error
5Server/job lifecycle error
6Local 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:

  1. request-shape validation Invalid path-mode shapes, malformed flags, and incompatible option payloads fail immediately.
  2. file-state inspection After parsing the CHAT file, Batchalign inspects whether the file is already timed well enough to skip UTR.
  3. 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:

  1. Cheap %wor reuse is health-checked first. Existing %wor timing is reused only when the word distribution already looks plausible. Rerun falls back to fresh FA instead of reuse when:

    • any %wor word is near-zero (< 40 ms)
    • a 3+-word utterance has one %wor word consuming more than 40% of the utterance span
    • the last %wor word already overruns the utterance boundary or other reuse-shape invariants fail
  2. 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 --pauses to 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.

  3. 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_know are 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 &-um when 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
  4. 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.

  5. Prior-boundary rebuilding is an explicit v0.4.0 research projection. The default --existing-wor-boundaries preserve retains the compatibility behavior above. rebuild-from-evidence instead treats earlier %wor and 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-cache and --debug-dir for 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-%wor fast 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.

  6. The default is preserve-cross-speaker: cross-speaker overlap is ordinary conversation and is left alone. --end-overlap-policy governs same-speaker (and, under clamp-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-adjacent ADDITIONALLY 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.
  7. 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)

OptionMeaning
PATHS...Input .cha files or directories
-o, --output DIROutput directory (omit to overwrite inputs in place)
--file-list FILERead 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-placeExplicit in-place flag

Alignment options

OptionDefaultMeaning
--media-dir PATHalongside .chaDirectory to search for audio files matching the @Media header stem
--utr-engine {rev,whisper,tencent}revUTR 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-utrenabledEnable or skip the UTR pre-pass entirely
--utr-strategy {auto,global,two-pass}autoOverlap 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 THRESHOLD0.85Two-pass only: Jaro-Winkler similarity threshold. Global/auto remain case-insensitive exact; 1.0 = exact only
--utr-ca-markers {enabled,disabled}enabledUse CA overlap markers (⌈⌉⌊⌋) to set alignment windows
--utr-density-threshold N0.30Max overlap fraction before skipping pass-1 exclusion (0.0-1.0)
--utr-tight-buffer MS500Pass-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--worInclude or suppress the %wor word-timing tier
--pausesoffPreserve 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}preservev0.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-speakerControls 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-abbrevoffMerge 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

  • %wor tier added or replaced with word-level timestamps (word ·start_end·)
  • Utterance-level bullet times (·start_end·) updated (see below for how)
  • Existing %mor and %gra tiers 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):

  1. 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.
  2. FA runs within those windows and aligns each word individually.
  3. 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, under clamp-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.



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 `&lt;...&gt; [/]`]
    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

OptionMeaning
PATHS...Audio files (.mp3, .mp4, .wav) or directories
-o, --output DIROutput directory for new .cha files
--file-list FILERead input paths from a text file
--in-placeWrite .cha files alongside the audio inputs

ASR and language options

OptionDefaultMeaning
--lang CODEeng3-letter ISO language code, or auto for language auto-detection
--asr-engine NAMErevASR 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 N2Speaker 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-speakersoffLet 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}autoDedicated speaker diarization stage (auto = disabled)
--speaker-engine {pyannote-ai,pyannote,nemo}pyannote-ai when enabledPaid pyannoteAI Precision-2 cloud diarization, or an explicit local engine
--wor / --nowor--noworInclude or suppress the %wor word-timing tier
--merge-abbrevoffMerge abbreviations in the output
--utseg-fallback-stanzaoffOpt 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.

NameNotes
revRev.AI cloud ASR. The default.
whisperLocal Whisper.
whisper_hubHuggingFace Whisper fine-tune by model id. See whisper-hub-asr.md.
whisperxNot implemented. Accepted as a name and refused at submission; nothing here runs WhisperX.
whisper_oaiNot implemented. Accepted as a name (whisper-oai is the historical spelling) and refused at submission; nothing here calls the OpenAI Whisper API.
whisper_rsRust-native whisper.cpp, run in process.
tencentTencent Cloud ASR.
aliyunAliyun ASR.
funaudioFunASR / SenseVoice. Local, no credentials, no network.
qwenQwen3-ASR. Local.
paraformerFunASR 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.wavfoo.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 form asr_model= uses), and DO NOT USE for 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 older Batchalign <version>, ASR Engine <engine>. form
  • @Languages, @Participants, @ID headers
  • Utterance lines with timing bullets
  • %wor tier (if --wor is 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.



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

OptionMeaning
PATHS...Input .cha files or directories
-o, --output DIROutput directory (omit to overwrite in place)
--file-list FILERead input paths from a text file
--in-placeExplicit 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.

OptionDefaultMeaning
--retokenize / --keeptokens--keeptokensRetokenize main lines to UD tokenization (may split/merge words), or preserve existing tokenization
--skipmultilang / --multilang--multilangSkip utterances in non-primary languages, or process all
--lexicon FILE:Comma-separated manual lexicon override file (read on client, injected as typed options)
--merge-abbrevoffMerge abbreviations in the output
--no-l2-morphotagoffOpt 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-hintsoffOpt 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|analyzehonorHonor @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

  • %mor tier added or replaced with POS tags and lemmas per word
  • %gra tier added or replaced with dependency relations
  • A provenance @Comment naming 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 --retokenize is set
  • Special @Options: dummy notation 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:engpron|it~aux|be) and phrasal verbs (wake@s up@sverb|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 under UNSUPPORTEDLANG, the worker partition routes that group to the fallback bucket (no Stanza dispatch), and every word in the utterance receives L2|xxx in %mor with no %gra relation emitted for those positions.
  • @s:UNSUPPORTEDLANG per-word markers, the secondary L2 dispatch path for that span is short-circuited the same way; the host primary analysis is preserved and the @s token’s slot stays as L2|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:

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], and chatter debug fix-s rewrites the qualifying whole-utterance pattern in place.
  • Explicit @s:LANG words still dispatch to LANG even if LANG is missing from @Languages, but validation emits warn-only E254 so the header drift is visible. chatter debug fix-s appends those missing explicit languages to @Languages.
  • chatter debug fix-s is a true no-op on already-correct files: it only rewrites a file when it can prove a [- lang] conversion or @Languages repair 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.



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

OptionMeaning
PATHS...Input .cha files or directories
-o, --output DIROutput directory (omit to overwrite in place)
--file-list FILERead input paths from a text file
--in-placeExplicit 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

OptionDefaultMeaning
--lang CODEeng3-letter ISO language code
--num-speakers N2Number of speakers. No short flag; -n was removed 2026-08-19.
--merge-abbrevoffMerge abbreviations in the output

What changes in the .cha file

  • Utterance boundaries (*SPK: lines) are recomputed, utterances may be split or merged
  • Existing %mor and %gra tiers on recomputed utterances will be invalidated; re-run morphotag after utseg if 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:

--langModel loadedSource
engtalkbank/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 languagerefused by default; opt in via --utseg-fallback-stanzaStanza

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:

ValueMeaning
<model id>@<revision>The TalkBank boundary model, with the exact revision the worker loaded (for example talkbank/CHATUtterance-en@764ec3f...)
stanza-constituencyThe 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.



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 to translate.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 through aliyun (cloud) or nllb (self-hosted) instead. Requires CAM credentials with tmt:TextTranslate permission in ~/.batchalign.ini [asr] section (engine.tencent.id / key / region), or via the BATCHALIGN_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’s alimt General 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 the BATCHALIGN_ALIYUN_AK_{ID,SECRET} environment variables. Region is pinned to cn-hangzhou (Aliyun MT exposes one global endpoint at mt.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 prefer tencent. 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; prefer nllb or tencent for 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

OptionMeaning
PATHS...Input .cha files or directories
-o, --output DIROutput directory (omit to overwrite in place)
--file-list FILERead input paths from a text file
--in-placeExplicit in-place flag

translate options

OptionDefaultMeaning
--translate-engine google|tencent|aliyun|nllb|seamlessgooglePick 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-abbrevoffMerge 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

SituationWhat 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 itemsFile 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) failsFile 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 / wrongFile 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 requestRaises 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 / wrongFile 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 languageRaises 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 venvWorker 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


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

OptionMeaning
PATHS...Input .cha files or directories
-o, --output DIROutput directory (omit to overwrite in place)
--file-list FILERead input paths from a text file
--in-placeExplicit in-place flag

coref options

OptionDefaultMeaning
--merge-abbrev / --no-merge-abbrevoffMerge 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.



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:

  1. A projected reference .cha: the gold transcript with %xsrep / %xsmor annotation tiers showing substitutions, insertions, and deletions
  2. A .compare.csv sidecar 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

OptionMeaning
PATHS...Input .cha files or directories (.gold.cha companions are auto-skipped)
-o, --output DIROutput directory
--file-list FILERead input paths from a text file
--in-placeExplicit in-place flag

compare options

OptionDefaultMeaning
--lang CODEeng3-letter ISO language code
--num-speakers N2Number of speakers
--merge-abbrevoffMerge 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, +word insertions in gold, -word deletions 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 companionMatches reportInsertions reportDeletions report
Carries %morthe gold tagthe main tagthe gold tag
Carries no %morthe main tagthe 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.



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:

StateMeaning
timedthe %wor tier was corroborated and times this word; start_ms and end_ms sit beside the state
unalignedthe tier was corroborated and simply carries no bullet for this word
no_wor_tierthe utterance has no %wor tier, so no word in it is timed
wor_tier_driftedthe 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_uncorroboratedthe 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:

  1. A hypothesis .cha transcript
  2. A .compare.csv with 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

OptionMeaning
PATHS...Input audio files (.mp3, .mp4, .wav) or directories
-o, --output DIROutput directory

benchmark options

OptionDefaultMeaning
--lang CODEeng3-letter ISO language code
--num-speakers N2Number of speakers. No short flag; -n was removed 2026-08-19.
--asr-engine NAMErevASR 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--noworInclude or suppress the %wor tier in the hypothesis output
--merge-abbrevoffMerge 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 ASR
  • FILE.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.



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

ArgumentMeaning
INPUT_DIRDirectory containing audio files
OUTPUT_DIRDirectory for output .opensmile.csv files

opensmile options

OptionDefaultMeaning
--feature-set SETeGeMAPSv02Feature set: eGeMAPSv02, eGeMAPSv01b, GeMAPSv01b, or ComParE_2016
--lang CODEeng3-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.



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

ArgumentMeaning
INPUT_DIRDirectory containing paired .cs.* and .sv.* audio files
OUTPUT_DIRDirectory for output .avqi.txt files

avqi options

OptionDefaultMeaning
--lang CODEeng3-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 sample
  • SPEAKER.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.



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

OptionDefaultMeaning
PATHS...Input media files and/or directories (.mp3, .mp4, .wav)
-o, --output DIROutput directory for .turns.json artifacts
--num-speakers Nauto-detectExpected 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}pyannoteLocal TalkBank Pyannote, paid pyannoteAI Precision-2, or local NeMo
--lang CODEeng3-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], key hf_token:

    [auth]
    hf_token = <your Hugging Face token, after accepting the model's terms>
    
  • Hugging Face’s own resolution: the HF_TOKEN environment variable, or the token saved by running hf 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], key engine.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.



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

OptionDefaultMeaning
PATHS...Input .cha transcripts and/or directories
-o, --output DIROutput directory for the evidence artifacts
--enroll SPANrequired<start_ms>-<end_ms>:<label>. Repeat once per known voice
--threshold FrequiredSimilarity at or above which a voice counts as a match
--tiers CODESall tiersSpeaker tiers to score, comma-separated or repeated
--permutations N1000Shuffled labellings drawn for each track contrast’s p-value; a thousand resolve p to 0.001
--permutation-seed S0Seed of that shuffle, so a run reproduces byte for byte. Both are recorded in the file
--lang CODEeng3-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

verdictMeaning
matchesExactly one enrolled voice scored at or above the threshold, with nothing tied. Carries that label and score.
no_matchNo single voice was established. best names every label that reached the highest score, and that score.
unscoredNo 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

reasonMeaning
too_short_for_embeddingShorter than the model can measure. Carries frames and minimum_frames.
no_bulletThe utterance has no timing, so there is no audio to embed.
no_comparable_embeddingIt had timing, but no enrolled embedding could be compared with its embedding.
audio_missingIts bullet names audio the recording does not contain. Carries the bullet and the recording’s length.
overlaps_enrollmentIt 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.


  • 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
FlagMeaning
--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.

ArtifactWritten asIs
ASR response<stem>_asr_response.jsonThe retained provider response.
Post-ASR CHAT<stem>_post_asr.chaThe document as built, before either segmentation pass touched it.
Pre-utseg CHAT<stem>_pre_utseg.chaThe input to the post-CHAT pass.
Post-utseg CHAT<stem>_post_utseg.chaThe output of the post-CHAT pass.
Pre-CHAT evidence<stem>-<12 hex>_pre_chat_utseg_evidence.jsonBoundaries over timed ASR chunks.
Post-CHAT evidence<stem>-<12 hex>_post_chat_utseg_evidence.jsonBoundaries 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
FlagPassMeaning
--input-chat <CHAT>post-chatThe document the run segmented, normally the _pre_utseg.cha dump.
--asr-response <JSON>pre-asrThe run’s retained *_asr_response.json.
--evidence <JSON>bothThe run’s retained utseg evidence sidecar for that pass.
--output-chat <CHAT>bothThe document that pass wrote, to reproduce.
--media-name <NAME>pre-asrThe media name the run recorded in @Media. Optional, like transcribe’s own; omit it for a run that recorded none.
--worpre-asrReproduce 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-dir research 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 utseg gates 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.

OutcomeExit codeMeaning
reproduced0Every compared line matches.
differing1The replay ran and the documents disagree. The report names the comparable line counts and where they first differ.
refusal2An 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:

  1. Walks the CHAT AST with talkbank-model::walk_words(TierDomain::Mor) so the pairing with %mor / %gra items is domain-correct (retraces, fragments, untranscribed placeholders do not consume positions).
  2. Classifies the splice outcome as Spliced, L2Xxx (dispatch failed to L2|xxx), or MissingMor (no MOR item at the expected position, should be near-zero with the AST walker).
  3. Applies rule-based suspicious-output detectors (heuristic flags): PropnForFunctionWord and FeaturePosMismatch. Flags are candidates for manual review, not confirmed errors.
  4. Writes four artifacts to --output:
    • per-word.csv: one row per @s word
    • per-pair.csv: one row per language pair, with dispatch rate, splice rate, heuristic-clean rate
    • flagged.csv: subset of per-word.csv where at least one flag fired
    • summary.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

FlagMeaning
--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:

MetricPython analyzerRust analyzer
@s words counted17,35216,845
Aggregate splice rate98.4%99.9%
Aggregate heuristic-clean rate95.0%98.0%
Pairs with zero missing_mor6 / 1918 / 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 %com tier 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

FlagMeaning
--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 pointReplacement
BatchalignPipelinebatchalign3 <command>
WhisperEngine, RevAIEngine, etc.batchalign3 transcribe --asr-engine <name>
CHATFile, Document, ParsedChatthe chatter CLI in talkbank-tools (chatter to-json, chatter validate, etc.)
run_pipeline(), LocalProviderInvoker, PipelineOperationbatchalign3 <command>
compute_wer()batchalign3 compare
batchalign.compatnone, 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

EngineTaskDescription
qwenASRQwen3-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.
tencentASRTencent Cloud speech recognition with speaker diarization.
aliyunASRAlibaba Cloud NLS real-time speech recognition (Cantonese only).
funaudioASRFunASR/SenseVoice local model (no cloud credentials needed).
cantoneseFACantonese 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-engineCantoneseNotes
rev (default)noRev.AI has no yue model.
whisperyesLocal, no credentials.
tencentyesCloud, 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; pass Qwen/Qwen3-ASR-0.6B-hf for 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:

  1. Simplified → Traditional via the ferrous-opencc Rust engine (embedded OpenCC S2hk conversion tables)
  2. 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 yue reached 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 StartMs leaves 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=yue required, 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 contains paraformer.
  • 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 its timestamp array, and Paraformer’s pre-punctuation raw_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-asr PyPI 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-asr package 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


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:

AnalysisCached?
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:

  1. 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.
  2. 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 changedWhat re-runsWhat stays cached
Edited the transcript wordsFA (per-group cache key includes text)UTR ASR (only depends on audio)
Re-recorded or replaced the audioFA, UTR ASR, Rev evidence, speaker evidence(n/a, audio is the cache key)
Changed the language codeUTR ASR and Rev evidence(other corpora’s entries)
Changed expected speaker countRev evidence and speaker evidenceFA and UTR ASR
Changed speaker backendSpeaker evidenceFA, UTR ASR, and Rev evidence
Changed only the speaker normalization algorithmDerived speaker segmentsRaw speaker inference evidence
Upgraded batchalign or an identified model revisionAffected audio evidenceEntries 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

CachemacOS defaultLinux 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, and avqi prefer the local daemon when auto_daemon is enabled.
  • Without an explicit remote target, auto_daemon: true makes 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_mode for local-audio commands such as align, transcribe, benchmark, opensmile, and avqi.
  • Explicit --server always stays on content mode, even when the URL is localhost.

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 is local.
  • 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 port
  • host: bind address (defaults to 0.0.0.0)
  • max_concurrent_jobs: 0 means auto-tune
  • auto_daemon: reuse or start a loopback daemon for ordinary CLI processing
  • media_roots: local execution-host media lookup roots
  • media_mappings: local execution-host root mappings from corpus paths to mounted media paths
  • memory_tier: override auto-detected tier: small, medium, large, fleet
  • memory_gate_mb: host headroom reserve in MB (default: 2048)
  • gpu_startup_mb / stanza_startup_mb / io_startup_mb: per-profile startup reservation overrides
  • worker_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:

  1. Export or mount the media directories on the server at a canonical path.
  2. Configure media_mappings so corpus-relative roots resolve to that mounted storage.
  3. 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

AspectDirect modeServer mode
SetupNoneserver.yaml + batchalign3 serve
Model loading~4-7s on first runWarm workers can be reused
Crash recoveryNone (restart manually)SQLite-backed recovery requeues resumable work on next server start
Multi-userNoYes (concurrent jobs)
Remote audioMust be localVia shared storage / media_mappings
MonitoringTerminal outputWeb 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 pyannote and nemo select 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 align or transcribe on the same audio reuses these and is much faster. Text-NLP commands (morphotag, utseg, translate, coref) are not cached: see Caching.
ScenarioRelative 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
morphotag1-2 GB (Stanza models)
align2-4 GB (Whisper/Wave2Vec)
transcribe2-4 GB (Whisper + diarization)
translate1-2 GB (translation model)
utseg1-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:

CommandWarm DaemonCold Start
morphotag2-5 seconds30-60 seconds
align5-15 seconds45-90 seconds
transcribe10-60 seconds (depends on audio length)60-120 seconds
translate2-5 seconds30-60 seconds
utseg3-8 seconds30-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 ba3 name. 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 | ...]
KeyMeaning
engineEach 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)
langLanguage code
retokenizePresent if CJK retokenization was applied
incrementalPresent if --before incremental mode was used
ud_repairsHow 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 | ...]
KeyMeaning
faForced alignment engine the worker reported
langLanguage code
utrTiming recovery engine (rev, whisper, tencent), present only if a recovery pass ran
worPresent if %wor tier was written
incrementalPresent if --before incremental mode was used

transcribe

[fc-ba3 transcribe | asr=rev ; lang=eng | ...]
KeyMeaning
asrASR engine (rev, whisper, tencent, aliyun, funaudio)
asr_modelThe 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
langLanguage code
diarizePresent if speaker diarization was enabled
worPresent 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:

  1. pipeline stages construct typed DecisionRecord values and trace them;
  2. orchestration retains the exact records in structured evidence;
  3. CHAT serialization applies the no-review-tier policy and strips legacy %xalign / %xrev tiers.

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 = 1 automatically when no functional GPU is detected. Leave the field absent in server.yaml (or set it to 0: the legacy “auto” sentinel that still deserializes to “no override”). The recommendation also sets force_cpu = true on 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_v2 calls into a single Python process where they fight for cores, slowing each other down by the contention factor. Verify your host’s resolved values with batchalign3 doctor --check (see Doctor).

The Rust-side dispatch_semaphore mirrors gpu_thread_pool_size permit-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-machine confirms that the biggest win is not a larger CPU thread pool. Warm loopback-daemon reuse dominated everything else: align --no-utr --fa-engine wav2vec dropped from 17.65s direct / 9.64s sequential to 1.06s warm-daemon, and transcribe --asr-engine whisper dropped from 93.92s direct to 13.58s warm-daemon. On Apple CPU-only hosts, preserving warm workers matters much more than tuning gpu_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.

  1. 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
  2. Ask the host-memory coordinator for a job execution plan.
  3. The coordinator subtracts active local reservations, preserves memory_gate_mb as host headroom, and grants the largest safe worker count.
  4. 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:

ProfileCommandsWhat it shares
GPUalign, transcribe, transcribe_s, benchmarkWhisper, Wave2Vec, and speaker models in one process
Stanzamorphotag, utseg, coref, compareStanza NLP models (POS, constituency, coreference)
IOtranslate, opensmile, avqiLightweight 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):

CommandMemory per worker (MB)What drives it
morphotag2,000Stanza POS/lemma/depparse models (per language)
align4,000Whisper or Wave2Vec forced alignment model
transcribe1,500Whisper ASR model
utseg2,000Stanza constituency parser
translate4,000Translation model (Seamless M4T or Google)
coref2,000Stanza coreference model
opensmile500Lightweight feature extractor
avqi1,500Voice quality analysis
compare2,000Stanza 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 --warmup flag and a warmup_commands config 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 --warmup is now an unrecognised-argument error, and warmup_commands in a server.yaml is 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:

  • /health may 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
  • align is 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 /health under worker_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:

  1. Too many concurrent workers. Reduce max_workers_per_job or gpu_thread_pool_size.
  2. Other processes using RAM. Check system memory usage.
  3. 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.
  4. Another local batchalign3 server or test run is already holding leases. Check /health for host_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:

  • /health host_memory_pressure and host_memory_reserved_mb
  • memory_gate_mb
  • gpu_thread_pool_size for GPU commands
  • other local batchalign3 servers 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_size
  • force_cpu
  • max_total_workers
  • max_concurrent_jobs
  • max_workers_per_key
  • memory_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: the HostFacts snapshot (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 name
  • resolved_value: the value the runtime will use
  • source: "operator_override" or "recommendation"
  • recommendation: what the recommendation function returned (always present, so operators can compare)
  • rule: narrative description of the recommendation rule
  • facts_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 --check exits non-zero with the recommendation in the message.

Today’s warning variants:

VariantTriggered by
GpuThreadPoolSizeAboveOneOnCpugpu_thread_pool_size > 1 on a host with no functional GPU. The configured threads contend for one CPU-bound model process without parallelism gain.
MaxConcurrentJobsAboveRamBudgetmax_concurrent_jobs higher than the recommendation derived from ram_total_mb and CPU availability. Risks memory-pressure stalls and worker OOMs under load.
MaxTotalWorkersAboveRamBudgetmax_total_workers higher than clamp(ram_total_mb / 6 GB, 2, 32). Risks OOMs under sustained load.
ForceCpuFalseOnNonFunctionalGpuforce_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:

VariantTriggered by
MaxConcurrentJobsWouldDeterministicallyOommax_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:

CodeMeaning
0All checks passed.
2Usage error (unknown --explain knob name, etc.).
Non-zeroValidation 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:

  1. BATCHALIGN_PYTHON environment variable
  2. Active VIRTUAL_ENV
  3. A sibling Python next to the binary that can import batchalign.worker
  4. A project .venv discovered by walking up from the binary
  5. python3.12 on macOS/Linux, or python on 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:

  1. Too many concurrent workers, reduce max_workers_per_job or max_concurrent_jobs in ~/.batchalign3/server.yaml
  2. Other processes consuming RAM
  3. 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_events on 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

  1. Accept partial coverage. For many workflows, having most utterances timed is sufficient. The untimed utterances are still valid CHAT – they just lack timing.

  2. 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.

  3. Use align --before when 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:

  1. Check the server log for lines containing “excluding from server capabilities”, these show which commands failed the capability gate and why.

  2. 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:

    • align needs torch and torchaudio
    • transcribe needs openai-whisper
    • translate needs googletrans
    • morphotag, utseg, coref need stanza
    • opensmile needs opensmile
    • avqi needs parselmouth and torchaudio

    All of these are included in the base batchalign3 package, including the Cantonese providers.

  3. 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:

  1. you actually passed --server http://...
  2. the target server advertises the command in /health.capabilities
  3. 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:

  1. Raise the remote server’s body limit. On the server, edit ~/.batchalign3/server.yaml:

    max_body_bytes_mb: 1024
    

    Then restart: batchalign3 serve stop && batchalign3 serve start. The default is 512 MB; raise it only if your payloads genuinely exceed that.

  2. Submit smaller batches. Split the input set so each submission stays under the current limit. --file-list makes chunking trivial: write the filenames for each chunk to a separate list and submit in sequence.

  3. Use a local daemon instead of remote --server. If the CLI and the server share a filesystem, dropping --server lets the CLI use the local daemon on 127.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 validate reports 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 --export output
  • batchalign3 -vvv <your-command> 2>&1 | tee debug.log
  • Your OS, Python version, and batchalign3 version output

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

OpHandlerDescription
healthPythonWorker health status
capabilitiesPythonAvailable infer tasks + engine versions
execute_v2Rust dispatcher → Python modelTyped V2 execution with prepared artifacts
infer / batch_inferPythonLegacy inference path (still supported)
shutdownProtocolClean 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 .cha parsing, no direct tier editing. Implementations are Python today; long-term they migrate into Rust.
  • Pipeline layer: operates on ChatFile in 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

SurfaceWhy
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

SurfaceWhy removed
ParsedChat class + callback methodsRust server uses ChatFile directly
batchalign.pipeline_apiRust server owns pipeline orchestration
batchalign.compatDeprecated BA2 shim, no longer needed
batchalign.inference.benchmarkWER 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:

ExecutorTaskWhat Rust preparesWhat Python does
execute_asr_request_v2ASRPCM audio bytesRun Whisper / Cantonese provider
execute_forced_alignment_request_v2FAPCM audio + word JSONRun Whisper / Wave2Vec FA
execute_speaker_request_v2SpeakerPCM audio bytesRun pyannote / NeMo
execute_opensmile_request_v2OpenSMILEPCM audio bytesExtract acoustic features
execute_avqi_request_v2AVQIPaired audio bytesCalculate voice quality
normalize_*_result (worker_text_results.rs)Text tasksn/aParse 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):

FunctionPurpose
funaudio_segments_to_asrFunASR segments → monologues + timed words
tencent_result_detail_to_asrTencent output → monologues + timed words
aliyun_sentences_to_asrAliyun 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/:

ModulePurpose
_main.pyWorker CLI entry point and stdio startup
_model_loading/Task-level model-loading package (bootstrap, translation, forced_alignment, asr)
_stanza_loading.pyStanza configuration and ISO-code mapping
_execute_v2.pyTyped V2 execute router for prepared-audio and prepared-text tasks
_text_v2.pyThin batched text-task V2 host; Rust owns text-task batch-result shaping
_artifact_inputs_v2.pyThin 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.pyThin Python wrappers over Rust-owned executor control planes
_types_v2.pyPydantic models mirroring V2 wire format
_protocol.pyStdio JSON-lines serving loop
_protocol_ops.pyThin Python wrapper over Rust-owned stdio op dispatch
_handlers.pyHealth, capabilities, preflight handlers
_infer_hosts.pyBootstrap-owned batch-infer runtime hosts
_infer.pyThin request-time batch inference router
_types.pyPydantic models mirroring Rust wire format

batchalign/inference/:

ModuleInput → Output
morphosyntax.pywords+lang → raw Stanza UD annotations
utseg.pywords+lang → raw constituency parse tree
translate.pytext+lang → translated text
coref.pysentences → coreference chains
fa.pyaudio+words → raw word-level timings
asr.pyaudio path / prepared waveform → raw ASR payloads
speaker.pyprepared waveform → backend-specific raw speaker evidence (completed pyannoteAI job or local segments)
opensmile.pyprepared waveform → raw acoustic feature rows
avqi.pypaired 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:

  1. Infer tasks: which inference backends are available (_capabilities() import probes in batchalign/worker/_handlers.py).
  2. Engine versions: one entry per advertised infer task, keyed by task. Forced alignment’s entry is a validated engine name (ReportedEngineName, a wrapper over StampSafeText: non-blank, no surrounding whitespace, none of |, ;, ] or a line break), or null before an FA model has loaded. Every other task’s entry is null.

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:

InferTaskRequired importsengine_versions entry
morphosyntaxstanzanull
utsegstanzanull
corefstanzanull
translategoogletransnull
fatorch, torchaudiothe loaded FA model name; null until FA loads
asrwhisper or a configured Rev.AI keynull
opensmileopensmilenull
avqiparselmouth, torchaudionull
speakerpyannote.audionull

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 batchalign3 package, 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

ClassCommandsShape
Generationtranscribe, transcribe_sBuilds ChatFile from ASR output via build_chat()
Per-file processingalign, morphotagParse, mutate, serialize
Cross-file batchutseg, translate, corefPool utterances across files in one GPU batch
Reference projectioncompareMain transcript + gold companion → projected views from typed compare bundle
Compositebenchmark (= transcribe + compare)Chains existing workflows without reimplementing
Analysisdiarize, opensmile, avqiProduces 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:

  1. Parse: parse_lenient() produces a ChatFile AST.
  2. Pre-validate: check input quality against a command-specific ValidityLevel (e.g., MainTierValid for morphotag).
  3. Collect payloads: extract per-utterance data from the AST (word lists, text, language metadata).
  4. Cache check: hash payloads with BLAKE3, partition into hits and misses.
  5. Infer: send misses to Python workers via typed worker IPC (execute_v2 on the live infer surfaces). Workers return raw ML output.
  6. Inject: insert results (cache hits + infer results) into the AST.
  7. Cache put: persist new results for future reuse.
  8. Post-validate: alignment checks + semantic validation.
  9. 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:

  1. Pair each primary transcript with FILE.gold.cha.
  2. Morphotag the main transcript only, and carry morphotag’s own post-validation proof rather than its bytes.
  3. Parse the gold companion leniently into a ChatFile AST. The main side is already the document that proof carries, and is never serialized and read back.
  4. Build a ComparisonBundle with main/gold compare views, structural word matches, and metrics.
  5. 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:

  1. Pre-validation: rejects malformed input early based on the command’s required ValidityLevel.
  2. Alignment validation: checks tier word counts (%mor/%gra/%wor must match the main tier). ParseHealth-aware: utterances flagged as unparseable are excluded.
  3. 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:

  1. collect_payloads(): extract per-utterance payloads with positions.
  2. inject_from_cache(): inject cached %mor/%gra strings.
  3. 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_transcribeprocess_utseg_with_evidenceprocess_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.

APIPurpose
command_spec(ReleasedCommand) -> &'static CatalogEntryThe 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:

HelperReplaces
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.

TypePurpose
JobPlanCommandSpec + Vec<PlannedWorkUnit> + Vec<PlannedArtifactSet> + IoMode
IoModePaths (shared filesystem) or Content (staged under job directory)
PlannedWorkUnitOne input file with its resolved paths
PlannedArtifactSetOutput 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:

FilePurpose
kernel.rsExecutionKernel: runs stages, manages state transitions
morphotag/Morphotag execution: input prep, window policy, progress, writeback
coref.rsCoreference execution stage
translate.rsTranslation execution stage
utseg.rsUtterance segmentation execution stage
text_io.rsCHAT file read/write for text-based commands
worker_gateway.rsWorkerGateway 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: PlanWorkUnitsReadChatInputsReadReferenceInputs (resolve and parse *.gold.cha companions) → Morphosyntax (morphotag the main transcripts) → CompareAlign (gold-anchored comparison) → MaterializeOutputs (write output CHAT + CSV).

Migration status

CommandDispatch modelNotes
compareexecution/ kernelFirst migration, fully recipe-driven
All othersLegacy 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:

  • HostExecutionPolicy for 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 FamilyCommandsParallelismKey shape
Per-file transformalign, transcribe, transcribe_s, morphotagConcurrent files (semaphore-bounded by num_workers)One file in, one primary output out
Cross-file batch transformutseg, translate, corefCross-file batching: pool utterances, group by language, dispatch languages concurrently, chunk large language groups across multiple workersTwo-level parallelism: cross-language × intra-language chunking (up to max_workers_per_key per language)
Reference projectioncompareConcurrent files, but with two primary CHAT inputs per fileMain+gold comparison bundle plus AST-first materializers
Composite workflowbenchmarkConcurrent files (semaphore-bounded by num_workers)Transcribe first, then compare via typed command composition
Media analysis V2opensmile, avqiConcurrent 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.

ShapeFile-level parallelismWithin-file parallelism
Per-file transformSupervised tasks + Semaphore(N)Single worker call or Rust composition per file
Reference projectionSupervised tasks + Semaphore(N)Main+gold comparison bundle plus materializers per file
Composite workflowSupervised tasks + Semaphore(N)Transcribe then compare per file
Cross-file batch transformN/A (single batch)One GPU batch call covers all files
Media analysis V2Supervised 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

  1. CLI discovers .cha files in the input directory (sorted largest-first), submits them as a single job via POST /jobs.
  2. Server creates the job in Queued state and returns immediately.
  3. The runner checks the memory gate, if an idle worker already exists for (align, eng), the memory check is bypassed entirely.
  4. Pre-scaling spawns 2 worker processes to avoid sequential spawn overhead. Workers load the FA model (Whisper or Wave2Vec) at startup.
  5. Files are processed concurrently, bounded by num_workers via a tokio::sync::Semaphore. For each file, the server resolves the audio file by walking the parent directory or using media mappings for matching .wav/.mp3/.mp4 files. If the resolved file is MP4 (or another container format), ensure_wav converts 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 %wor timing. If main↔%wor alignment is clean and every mapped %wor word 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 %wor from 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 calls count_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, then inject_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).
  6. Cache lookup uses BLAKE3 hashes of (words + audio identity + time window
    • gap-healing policy + engine). Cache hits skip worker IPC entirely.
  7. Cache misses are sent to a checked-out worker via typed execute_v2 requests. The CheckedOutWorker RAII guard returns the worker to the pool on drop.
  8. The Rust server DP-aligns model timestamps to transcript words (Hirschberg algorithm), converts chunk-relative times to file-absolute milliseconds, generates %wor tiers, and runs monotonicity/overlap checks.
  9. New results are cached for future reuse.
  10. 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

  1. CLI submits CHAT files for morphosyntactic enrichment.
  2. The runner selects the per-file dispatch path (dispatch_morphotag_job).
  3. Files are processed concurrently, bounded by num_workers. This prevents the BA2 over-parallelism crash mode while maximizing throughput on multi-core hosts.
  4. For each file, the server parses the transcript. If the parsed header declares @Options: CA, the file is serialized back as-is, no %mor / %gra tiers are added or removed, and no provenance comment is injected (mirroring align’s @Options: NoAlign pass-through). Otherwise the server clears any stale morphology and collects payloads (word lists + language metadata).
  5. Cache lookup checks all utterances in the file at once. BLAKE3 keys include (words + language + terminator + special forms + engine version).
  6. 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).
  7. Results (both from cache and worker) are injected back into the file’s AST, inserting new %mor and %gra tiers.
  8. The file is validated (ensuring morphology matches the main tier) and serialized back to CHAT.
  9. Each file’s result is written to disk immediately as it finishes, allowing for incremental progress visibility on large corpora.
  10. 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

  1. The CLI submits only primary .cha inputs; the gold companion is resolved by the compare planner/dispatch layer.
  2. 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.
  3. compare() performs BA2-style per-gold-utterance window selection and local DP, then returns a ComparisonBundle containing main-anchored tokens, gold-anchored tokens, structural gold↔main word matches, and aggregate metrics.
  4. The released materializer projects onto the gold/reference AST and injects %xsrep / %xsmor there. The main-annotated materializer still exists for internal benchmark-style flows, but it is no longer the compare command surface.
  5. %xsrep / %xsmor are emitted from typed compare-tier models, not from raw string hacking, and .compare.csv is 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

  1. 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=true is sent so BA3’s own pre-CHAT utterance model handles segmentation. In --lang auto mode 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_count and skip_postprocessing were 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.
  2. 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 no use_speaker_labels parameter, this matches BA2’s unconditional speaker reading. The --diarization flag only gates the dedicated speaker stage (step 2c), not the use of ASR-provided labels. 2c. Dedicated diarization (optional): If --diarization enabled is set, the server dispatches execute_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-dir is 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 from SpeakerBackendV2, and an enabled write failure fails the file. This is an interim debug/research artifact, not the final durable evidence sidecar.
  3. All post-processing happens in Rust (batchalign), not Python. The normalization stages in prepare_asr_chunks() are:
    1. Compound merging, joins adjacent subword tokens
    2. 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
    3. Multi-word splitting, split space-separated tokens, interpolate timestamps
    4. Number expansion, digits to spelled-out words (language-aware)
    5. Long-turn splitting, chunk monologues at >300 words 5b. Long-pause fallback splitting, split strongly separated runs when provider punctuation is missing
  4. 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 V2 utseg worker 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 Rev auto -> resolved eng and Rev auto -> true auto request -> later resolved eng can eventually reach the English utterance model. Only the first branch is provider-request equivalent to explicit --lang eng.
  5. CHAT assembly (build_chat) creates a complete ChatFile AST with proper headers (participant codes derived from speaker indices: PAR, INV, CHI, MOT, etc.) and utterances with %wor timing tiers. Speaker-safe chunks already carry the labels from the dedicated projection when it ran.
  6. 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

  1. DB recovery: Any jobs left in Queued or Running state from a previous crash are first marked Interrupted, and expired entries are pruned.
  2. Runtime reconciliation: JobStore::load_from_db() rebuilds each job and then uses the Job recovery transition to choose a canonical state: resumable files are re-queued, while all-terminal jobs are promoted to Completed or Failed. The reconciled status and cleared lease metadata are written back to SQLite so memory and persistence agree.
  3. The server begins accepting requests immediately. Capabilities are not yet known, they are populated lazily.
  4. 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 each InferTask: for each task, the worker tries to import the required Python packages (e.g., stanza for Morphosyntax, torch+torchaudio for FA). If imports succeed, the task is reported as available. Its engine_versions entry is null, except forced alignment’s, which names the FA engine once an FA model has loaded.
  5. The worker stays in the pool for actual job work, it is not shut down after capability detection.
  6. 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 /health returns in worker_capability_admissions. WorkerCapabilitySnapshot::detected() then derives the released command surface with capability::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 (only align, whose cache rows are namespaced by the FA engine) additionally reads the FA engine from that report with FaCacheNamespace::from_loaded, refusing a job whose worker still names no FA engine after the load.
  7. The /health endpoint 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:

LayerWhereWhat it catches
1dispatch/utr.rs asr_response_to_utr_tokensWhisper returning start==end for a single-frame token
2utr.rs project_global_utr_plan reading the plan’s UtrTimingProposal::NonPositiveDP 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
3utr.rs project_global_utr_plan monotonicity post-passTwo adjacent non-overlap utterances assigned to tokens with the same start_ms (DTW collision at a shared boundary)
Safety netorchestrate.rs enforce_monotonicityResidual 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):

BulletSourceWho sets itupdate_utterance_bullet behavior
UtrUTR pre-pass via Bullet::utr_hint()Overwrite with FA word span
AuthoritativeParser (hand-linked), Bullet::new(), or FA-derivedUnion (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:

  1. Utterance timing is derived dynamically, not stored. Utterance.alignment in document.py:182 is a computed property: it scans forward/backward through word.time to 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 inside whisper_fa.py:183-224: each word’s end_ms is set to the start of the next word, and any word where start >= end is dropped (word.time = None).

  2. 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 an enforce_monotonicity pass 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 --diarize was 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:

FieldTypePurpose
progress_stageOption<FileProgressStage>Stable machine-readable stage code
progress_labelOption<String>Human-readable label derived from progress_stage
progress_currentOption<i64>Current counter (e.g. group 3)
progress_totalOption<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)

StageLabelcurrent/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

StageLabelcurrent/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)

StageLabelcurrent/total
Mark processingCommand-specific label,
Read each file“Reading”,
Pre-batch countCommand-specific label0/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)

StageLabel
Audio prep and conversion“Resolving audio”
Worker request running“Processing”
Writing output artifact“Writing”

benchmark (Rust-owned benchmark pipeline)

StageLabel
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 23 FileProgressStage variants to visual phases: Read → Transcribe → Align → Analyze → Finalize. The active segment pulses using the existing status-dot-pulse CSS 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() in ProcessingProgress.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), parses live_worker_keys strings 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 fields system_memory_total_mb, system_memory_available_mb, system_memory_used_mb. Shows a segmented gauge bar with the memory_gate_threshold_mb marked 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 ProgressSender so 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() in ProcessingProgress.tsx.

Adding Progress to a New Command

  1. Tier 1: Add set_file_progress() calls in the dispatch function at stage transitions.

  2. 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 ProgressUpdate at meaningful points inside the orchestrator

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

batchalign3 never 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

OperationStatusWhere
Parse an existing replacement from input CHAT✅ Yestalkbank-parser (delegated; this repo doesn’t reimplement)
Preserve a replacement through extract→modify→inject round trips✅ Yesbatchalign content walkers
Route the right side to %mor/%gra extraction✅ Yesextract.rs::collect_replaced_word
Route the left side to %wor / FA / %pho / %sin✅ Yesfa/extraction.rs, extract.rs (per-domain branches)
Re-serialize to valid CHAT including the replacement✅ Yesmodel-layer WriteChat
Emit a new [: ...] annotation programmaticallyNoNothing 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 mechanismasr_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 extractedUse
TierDomain::Morreplacement words (replaced.replacement.words)Stanza receives the corrected/intended form for UD analysis
TierDomain::Wororiginal word (replaced.word)%wor (timing) sees what was actually spoken
TierDomain::Phooriginal word%pho describes phonology of what was spoken
TierDomain::Sinoriginal word%sin records spelling-in-actual
Noneboth, recursing into all leavesfor 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.

SiteFile:lineWhat it does
Extractioncrates/batchalign/src/chat_ops/fa/extraction.rsSends the original word to the FA worker
Countcrates/batchalign/src/chat_ops/fa/mod.rsCounts 1 for the ReplacedWord (regardless of replacement word count)
Injectioncrates/batchalign/src/chat_ops/fa/injection.rsConsumes 1 cursor slot, sets replaced.word.inline_bullet
Preservationcrates/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:

SiteFile:lineWhat it does
Extraction../chatter/crates/talkbank-transform/src/extract.rs:129 (collect_replaced_word)Sends the replacement words to Stanza
Countmodel.utterance.mor_alignable_word_count() (delegated to talkbank-model)Counts replacement-word count, not 1
Injectioncrates/batchalign-transform/src/morphosyntax/injection.rs::inject_resultsAsserts 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::Mor yields ["want", "to", "go"] (not ["wanna", "go"]).
  • mor_alignable_word_count() returns 3 (not 2).
  • Injection succeeds when given a 3-item %mor line.

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.text directly (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 Word validator. [: C-3PO] fails E220 the same way C-3PO on 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 existing ReplacedWord nodes during reconstruction but does not create new ones.
  • Generate replacements during ASR retrace detection. Detected retraces produce WordKind::Retrace plus structural retrace nodes (Retrace, <...> [/]), which are a different mechanism. See crates/batchalign-transform/src/build_chat/utterances.rs::build_word_utterance for how retraces are emitted; WordKind::Replacement does 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):

FormWhen 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

ConcernFile: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 countcrates/batchalign/src/chat_ops/fa/mod.rs
FA injectioncrates/batchalign/src/chat_ops/fa/injection.rs
FA preservationcrates/batchalign/src/chat_ops/fa/mod.rs
%mor injection (count check)crates/batchalign-transform/src/morphosyntax/injection.rs::inject_results
Retokenize preserves ReplacedWordcrates/batchalign-transform/src/retokenize/rebuild.rs
Read-only consumption testcrates/batchalign/src/chat_ops/fa/tests/grouping_and_wor.rs::test_wor_policy_replacements_use_original_surface
CHAT-format canonical referencethe CHAT-format replacements reference in the chatter project

See Also

  • CHAT Data Model (in the chatter project): how UtteranceContent variants (including ReplacedWord) 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 chatter project): for what %mor represents 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):

  1. Walk content with walk_words(domain=Mor)
  2. Collect cleaned_text() for each alignable word
  3. Replace special forms (@c"xbxxx", @s → language marker), Stanza can’t handle CHAT-specific markers
  4. 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:

  1. Range token filtering (Retokenize only): exclude UdId::Range parent entries from the token vector, only component words appear.
  2. Grammatical-invariant rewrites (apply_grammatical_invariants at talkbank-transform/morphosyntax/invariants.rs:14, talkbank-transform/morphosyntax/invariants/ for the per-rule modules): operate on the typed UdSentence BEFORE map_ud_sentence runs. English primary only today, dispatched via lang2(&ctx.lang) in talkbank-transform/morphosyntax/sentence_mapping.rs. The only rule shipped so far is finite_verb_main_clause::rescue_english_copula_progressive (at invariants/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 → AUX be, root NOUN → VERB VerbForm=Part, governor deprel → nsubj). See Stanza Limitations, Defect 1 for the defect description and re-evaluation procedure.
  3. UD → CHAT mapping: Convert Universal Dependencies POS/features to TalkBank %mor format (category mappings, stem extraction, feature translation).
  4. 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.
  5. %gra construction: Build dependency graph with chunk-based indexing (GRA indices are %mor chunk positions, not surface word positions).
  6. L2 splice (default; opt out with --no-l2-morphotag): after primary injection, @s words with L2|xxx are routed to secondary Stanza models and spliced back with real morphology. L2 extracts its l2_deferred positions from the ORIGINAL ud_responses captured before apply_grammatical_invariants ran (crates/batchalign/src/pipeline/morphosyntax.rs:352-356, plus the L2 dispatch in crates/batchalign/src/morphosyntax/batch.rs), so the English rewrite cannot corrupt L2 position mapping.
  7. Validation: Check word count alignment, GRA cycle detection, chunk count consistency.
  8. 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/):

StageModuleWhat it does
1. Compound mergingcompounds.rsJoin split compounds: ice + creamice+cream (3,584 pairs, O(1) HashSet)
2. Timed word extractionmod.rsConvert seconds → milliseconds, extract ASR tokens, strip MOR_PUNCT, lowercase
2d. Cantonese normalizationcantonese.rsSimplified → 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 splittingmod.rsSplit space-separated tokens with timestamp interpolation
4. Number expansionnum2text.rs + ordinal_year_eng.rsSingle 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 splittingmod.rsBreak turns > 300 words into separate utterances
5b. Pause-based splittingmod.rsLong pauses in unpunctuated runs create utterance boundaries
6. Retokenizationmod.rsSplit 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&lt;Utterance&gt;"]
    UCap["apply_english_transcribe_rules_post_retokenize\n(utterance-initial cap, skips retrace/markers)"]
    Out["Vec&lt;Utterance&gt;\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’s normalized_split_separator treats . as a word separator, so Dr. would fragment into Dr + . before the allowlist could match. Stripping on the raw element keeps Dr a 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 WordKind tags are assigned; the rule walks past xxx/yyy/www, &-prefixed tokens, and WordKind::Retrace copies 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:

  1. Concatenates both word lists into character strings
  2. Runs character-level DP alignment
  3. Builds a deterministic mapping from Stanza token indices back to CHAT word indices
  4. Uses this mapping to assign Stanza annotations (POS, lemma, depparse) to the correct CHAT words

This handles splits (don'tdo + 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:

TaskCache key formula
Morphosyntax`BLAKE3(“{words}
Utseg`BLAKE3(“{words}
Translation`BLAKE3(“{text}
FA`BLAKE3(“{audio_identity}
UTR ASR`BLAKE3(“utr_asr
CorefNo 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&bull; value: AsrRawText\n&bull; ts/end_ts: AsrTimestampSecs\n&bull; 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&lt;AsrWord&gt;\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&bull; text: AsrNormalizedText\n&bull; start_ms/end_ms: Option i64\n&bull; 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&lt;ParseError&gt;)\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_separator treats . as a word separator and would fragment Dr. into Dr + . before the allowlist could match. Stripping early keeps Dr as 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:

CheckpointUnit list parallel to timestampWhy 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
SenseVoicewords, built by FunASR in lockstep with timestamp, punctuation units includedtext 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. Units has 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 split text itself 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 fieldAbsent used to meanAbsent now means
Tencent StartMsthe segment starts at the beginning of the recordingevery word in the segment is untimed (SegmentStartAbsent)
Tencent OffsetStartMs / OffsetEndMsthe word starts at its segment’s start; a missing end copied the startthe word is untimed, with the missing bound named
Tencent Wordthe empty string, which the blank filter then droppeda refusal naming the segment and word
Tencent SpeakerIdspeaker 0undiarized when separation was not requested; a REFUSAL when it was (see below)
Aliyun startTime / endTimea span beginning at 0 msthe 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 i64 cast;
  • 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 saidRequest askedResult
a labeleitherAttributed, with the label admitted non-empty
no speakernot_requestedUndiarized
no speakerintegratedrefused: 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.

#StageFunctionWhat changes
1Compound mergingprepare_words_pre_expansion()Adjacent compound pairs joined (“air”+“plane” → “airplane”)
2Timed word extraction + separator stripprepare_words_pre_expansion()Seconds → ms, pause markers filtered, MOR_PUNCT (, ) and RTL separators trimmed from word boundaries. Case is preserved: see “Casing” below.
2dCantonese 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.
3Multi-word splittingprepare_words_pre_expansion()Space-containing tokens split, timestamps interpolated, hyphens joined
3bPercent-suffix splitsplit_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.
4Number expansionexpand_number() per wordSingle 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.5Post-expansion re-splitsplit_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:

  1. Other languages. Per the Rev.AI API docs, skip_postprocessing is 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 returns None). The normalizer stages still fire for those responses.
  2. 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

TypeOn structContainsConstructor
AsrRawTextAsrElement.valueRaw provider output: digits, spaces, provider markersAsrRawText::new(s): infallible
AsrNormalizedTextAsrWord.textCompound-merged, number-expanded, disfluency-marked textAsrNormalizedText::new(s): infallible
ChatWordTextWordDesc.textA 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&amp; 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&lt;ParseError&gt;)\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 lowercased content_keys vector 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&quot;I&quot;, &quot;Sarah&quot;,\n&quot;Cincinnati&quot;)"]
    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. &quot;&amp;-um&quot;)"]

    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 (&quot;&amp;-&quot; 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:

TypeSourceUsed for
ChatRawTextWord::raw_text()AST preservation, display
ChatCleanedTextWord::cleaned_text()NLP models, alignment, cache keys
SpeakerCodeUtterance.speakerPer-speaker analysis keying

These types are documented in Type-Driven Design.

Code References

ComponentFile
ASR types and newtypescrates/batchalign-transform/src/asr_postprocess/asr_types.rs
Pipeline orchestratorcrates/batchalign-transform/src/asr_postprocess/mod.rs
Compound mergingcrates/batchalign-transform/src/asr_postprocess/compounds.rs
Disfluency and retracecrates/batchalign-transform/src/asr_postprocess/cleanup.rs
Number expansioncrates/batchalign-transform/src/asr_postprocess/num2text.rs
Cantonese normalizationcrates/batchalign-transform/src/asr_postprocess/cantonese.rs
FunASR unit admissioncrates/batchalign-pyo3/src/cantonese_asr_bridge/funasr_projection.rs
CHAT assemblycrates/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:

ShapeExampleWhy 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:

  • PortugueseOrdinalSyntax is 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), so 54ªabc, 54.ª-feira and the degree-sign lookalike 54° are not ordinals.
  • PortugueseOrdinal is a syntax whose rank is in 1..=1000, built only by TryFrom<PortugueseOrdinalSyntax>.
  • PortugueseOrdinal::to_words composes hundreds, tens and units ordinal stems and inflects every component for gender and number: 54ª becomes quinquagésima quarta, 54.ºs becomes quinquagésimos quartos, 1000ª becomes milésima.

It has two call sites:

  1. Tokenizer (prepare.rs::split_chunk_word). At each token start, protected_ordinal_prefix runs before the generic separator split, so the abbreviation period in 54.ª 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.
  2. 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 (, 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 ( 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 no ml backend
  • ell (Greek), num2words has no el backend; hand-curated entries preserved verbatim from pre-codegen file (known typos flagged for follow-up native-speaker review)
  • eus (Basque), num2words has no eu backend
  • hrv (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

FilePurpose
crates/batchalign/src/pipeline/transcribe.rsstage_asr_postprocess, prepare_asr_chunks (per-word Rust expansion + whitespace split + finalize)
crates/batchalign-transform/src/asr_postprocess/num2text.rsexpand_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.rsexpand_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.rsnum2chinese(n, script) for CJK
crates/batchalign-transform/src/asr_postprocess/ordinal_por.rsPortuguese indicator ordinals: typed recognition (PortugueseOrdinalSyntax, PortugueseOrdinal), gendered rendering, tokenizer protection
crates/batchalign-transform/src/asr_postprocess/prepare.rsTokenizer (split_chunk_word); protects indicator ordinals before separator splitting
crates/batchalign-transform/src/asr_postprocess/num2text_baseline.rsFrozen-baseline check and its opt-in regeneration test
crates/batchalign-transform/data/num2lang.jsonPer-language Rust tables (43 codegenned + 4 hand-curated)
crates/batchalign-transform/data/number_expansion_baseline.jsonFrozen (language, token) outputs of expand_number and the prepare pipeline
crates/batchalign-transform/data/eng_ordinal_year_fixtures.jsonCross-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.

LangWire tokenExpanderSource
eng"3"Rust NUM2LANGdata/num2lang.json:eng
eng"3rd"Rust expand_ordinal_engordinal_year_eng.rs
eng"1950s"Rust expand_decade_engordinal_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 cardinalsnum2text.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 firesordinal_por.rs
any"$5"Rust try_expand_currencynum2text.rs
any"80%"Rust currency-style + PERCENT_WORD_BY_LANGnum2text.rs
43 codegenned langs (eng/fra/deu/spa/por/ita/nld/…)"3"Rust NUM2LANGdata/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 porpassthroughNone, 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 (1234decompose(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_PREFIXES and CURRENCY_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:

  1. 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.
  2. 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, time 3:30) means touching every dispatch site. Layer 2 of the rework addresses this.
  3. 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_CURATED in scripts/codegen_num2lang.py as the languages come online.
  4. 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.
  5. 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>.rs modules

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)

  1. Layer 2 (typed parser). Now-or-never refactor of detection. Best done before Layer 3 because Layer 3’s normalizer methods consume NumberToken.
  2. 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 per feedback_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:

  1. The “Current architecture” section above to reflect the new reality. If you migrated something out of the proposal, move it from “Future architecture” up.
  2. The per-language coverage matrix.
  3. The module map if file paths or line numbers shifted.
  4. The frozen baseline: regenerate it and name every changed row in the commit message.
  5. The Last updated header at the top.
  6. Cross-references: book/src/reference/languages/<lang>.md for any per-language pages that mention numbers; the book/src/batchalign/developer/adding-language-support.md checklist (“Number expansion” section) if the procedure changes.

When adding a new language (transcribe support for a language not already on the matrix):

  1. Determine which expander applies (per the Adding Language Support checklist’s number-expansion section).
  2. Either:
    • add the ISO 639-3 → 2-char mapping to ISO3_TO_NUM2WORDS in scripts/codegen_num2lang.py and re-run the script, OR
    • add a HAND_CURATED entry in the same script (one-shot codegen never overwrites the overlay).
  3. Add the row to this page’s coverage matrix.
  4. Add a test in num2text.rs::tests.
  5. 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:

  1. Re-run uv run python scripts/codegen_num2lang.py --output crates/batchalign/data/num2lang.json.
  2. Re-run the English ordinal/year fixture generator (see comments in scripts/codegen_num2lang.py).
  3. Diff the generated files; any value change in a covered language is a behaviour change worth a callout in the commit.
  4. Run cargo test -p batchalign --lib; the ordinal_year_eng cross-validation tests catch divergence.

When changing the CHAT digit-allowlist (rare, requires CHAT-spec maintainer sign-off):

  1. Update talkbank-tools/.../digits.rs::DIGIT_ALLOWED_LANGS.
  2. Update the matrix’s last row (“Lang allows digits”) to reflect the new set.
  3. 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 NUM2LANG path after the fix.
  • crates/batchalign/CLAUDE.md: asr_postprocess/ module map; references num2text.rs for 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 the DIGIT_ALLOWED_LANGS allowlist.

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

LevelSyntaxExampleScope
File@Languages: eng, spaPrimary + secondary languagesAll utterances
Utterance[- spa] precodeOverride language for one utteranceOne utterance
Word@s:spa markerMark a single word’s languageOne 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:

  1. [- lang] precode on the utterance (e.g., [- fra]), highest priority.
  2. @Languages header: first declared language used as fallback.
  3. --lang CLI 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 enginePrimary 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:

  1. Concatenate all word texts into a single string.
  2. Run whatlang::detect(): returns (Lang, confidence) or None.
  3. If confidence ≥ 0.5 and text has ≥ 40 alpha characters, set utt.lang = Some(iso639_3_code).
  4. If utt.lang differs 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.

ThresholdValueRationale
MIN_CHARS_FOR_DETECTION40Below this, trigrams are too sparse. Raised from 20 to reduce false positives on short bilingual utterances.
UTTERANCE_CONFIDENCE_THRESHOLD0.5Moderate bar to avoid false code-switch markers.
MIN_UTTERANCES_FOR_SECONDARY3A 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:lang markers are parsed but not routed. Code-switched words become L2|xxx special 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 no ml backend, 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 for mal (Rev.AI broken). The fix path is upstream num2words coverage, not in this codebase.

See Also


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 with ParseHealth flags 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:

  1. Rust collects payloads from the AST (word lists, utterance texts, etc.).
  2. ML inference runs (via worker IPC on the server path, or via Python callback on the Python API path).
  3. 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

OperationModulePython Library
ASR transcriptioninference/asr.pytransformers
Forced alignmentinference/fa.pytransformers, torchaudio
Morphosyntactic analysisinference/morphosyntax.pystanza
Speaker diarizationinference/speaker.pynemo, pyannote
Utterance segmentationinference/utseg.pystanza
Translationinference/translate.pygoogletrans, seamless
Audio feature extractioninference/opensmile.pyopensmile
Cantonese ASRinference/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:

  • batchalign handles CLI parsing, file discovery, dispatch, daemon lifecycle, and local output writing.
  • batchalign provides 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:

  1. The CLI and server share one Rust workspace and one typed contract surface.
  2. Remote-only clients can use the CLI without local ML dependencies.
  3. Local processing still relies on Python workers, but model loading is pushed out of the Rust process and managed through the worker pool.
  4. 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.
BucketCurrent surfacesDirection
Stays Python (for now)batchalign/worker/, batchalign/inference/, batchalign/models/host for ML model calls until Rust gains the equivalent coverage
Thin worker-side gluebatchalign/providers/ (re-exports worker IPC types), schema mirrors at the worker boundarykeep minimal; Rust owns all document semantics
Already moved to Rustconfig/runtime policy, payload preparation, post-processing, CHAT mutation, validation, orchestration, WER scoringdone; no backsliding
Already removedbatchalign.compat, batchalign.pipeline_api, batchalign.inference.benchmark, ParsedChatgone, 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:

  • JobStore owns in-memory job state plus SQLite write-through
  • RuntimeSupervisor owns the queue-dispatch loop and tracked per-job tasks
  • WorkerPool owns Python worker process lifecycle and serializes per-key bootstrap so bursty demand does not launch multiple heavy workers for the same bucket at once
  • AppState groups 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:

  • AppControlPlane for job store, queue wakeups, runtime supervision, and WS broadcast
  • WorkerSubsystem for worker-pool access and command capability data
  • AppEnvironment for config, media resolution, and filesystem roots
  • AppBuildInfo for 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:

  • JobIdentity
  • JobDispatchConfig
  • JobSourceContext
  • JobFilesystemConfig
  • JobExecutionState
  • JobScheduleState
  • JobRuntimeControl
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 RunnerJobSnapshot values for static job configuration
  • JobStore owns named execution mutations, and JobRegistry owns the in-memory projection/transition API, but the actual job-level state transitions for re-queue, running, failure, and finalization now live on Job
  • registry methods now return typed summary/file projections for WebSocket publication, so query modules no longer borrow raw Job values just to publish live updates
  • queue dispatch now uses typed QueuePoll snapshots and LeaseRenewalOutcome instead of raw strings, timestamps, and booleans
  • file-level status transitions now reconcile through Job methods 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:

  • QueuePoll for claimed ready jobs plus the next wake deadline
  • LeaseRenewalOutcome for the heartbeat loop
  • Job methods 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

ComponentCurrent locationRole
CLIcrates/batchalignclap CLI, dispatch router, daemon lifecycle, output writing
Servercrates/batchalignaxum routes, job store, worker pool, OpenAPI, server-side orchestration
CHAT opscrates/batchalignCHAT extraction, injection, validation, FA/morphosyntax helpers
Python workerbatchalign/worker/worker entry point, model loading, capabilities, infer/execute dispatch
Python inferencebatchalign/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:

  1. explicit --server for command classes that can target a remote server directly
  2. local daemon if auto_daemon is enabled
  3. already-running loopback server on the configured local port
  4. direct local execution

Special cases:

  • transcribe, transcribe_s, benchmark, and avqi prefer local-daemon dispatch when auto_daemon is enabled; if that daemon path is unavailable, the router still falls back to the explicit --server
  • explicit --server always stays on content mode, even for localhost
  • local daemons and auto-detected loopback servers use shared-filesystem paths_mode for local-audio commands
  • multi-server --server URL1,URL2 is rejected in the current release
  • daemon.rs still 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 /health
  • POST /jobs
  • GET /jobs
  • GET /jobs/{job_id}
  • GET /jobs/{job_id}/results
  • GET /jobs/{job_id}/results/{filename}
  • POST /jobs/{job_id}/cancel
  • DELETE /jobs/{job_id}
  • POST /jobs/{job_id}/restart
  • GET /jobs/{job_id}/stream
  • GET /media/list
  • GET /ws

Dashboard and bug-report routes are also present, but the list above is the core processing surface.

Concurrency mapping

Legacy Python implementationRust rewrite equivalent
ProcessPoolExecutor for CPU-heavy commandsStanza/IO profile: persistent Python subprocesses, exclusive checkout
ThreadPoolExecutor for GPU/ASR pathsGPU profile: SharedGpuWorker with Python ThreadPoolExecutor inside one process
Global pool size logic in Python serverJob-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 classRouting behavior
morphotag, align, translate, utseg, coref, compareExplicit single --server, local daemon, auto-detected loopback server, or direct local fallback
transcribe, transcribe_s, benchmark, avqiPrefer 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:

  1. Parse: read .cha files, parse into ChatFile AST
  2. Extract: collect payloads (words, text) from the AST
  3. Cache check: look up each utterance in the server-side UtteranceCache
  4. Infer: send cache misses to Python workers via typed execute_v2 requests (cross-file batching per language for text tasks)
  5. Inject: insert model results back into the AST
  6. Serialize: validate and write output .cha files
CommandDispatch PathWorker Role
morphotag, utseg, translate, corefinfer (cross-file)Stateless model inference only
aligninfer (per-file, per-group)Stateless audio/text alignment inference
transcribe, transcribe_sinfer (per-file audio)Raw ASR inference feeding a Rust-owned pipeline
benchmarkinfer (per-file audio + compare)Raw ASR inference feeding Rust transcribe + compare
diarize, opensmile, avqiinfer (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 connect
  • file_update: per-file status changes
  • job_update: overall job status changes
  • complete: job finished (stream closes)

Worker protocol

Workers are spawned by the server pool and communicate over stdio JSON-lines. The key operations are:

  • health
  • capabilities: reports infer tasks and engine versions; Rust derives commands
  • process
  • batch_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_v2 requests to one GPU worker without waiting for responses
  • Python’s _serve_stdio_concurrent() dispatches to a ThreadPoolExecutor (4 threads)
  • Responses carry request_id fields, 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.

LevelMechanismWhat it serializes
Processmemory_guard’s SPAWN_SEMAPHORE, one permit, held until the worker signals readyEvery spawn in the process, so a second model load never checks free RAM before the first one’s models are resident
Keythe 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 get WorkerError::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.

  1. SQLite marks previously active jobs as Interrupted.
  2. JobStore::load_from_db() rebuilds each Job value from persisted rows.
  3. Job::reconcile_recovered_runtime_state() decides the canonical next state: requeue unfinished work or promote all-terminal jobs to Completed / Failed.
  4. 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:

  • Cancelled is reserved for user gestures. TUI cancel and HTTP POST /jobs/{id}/cancel reach this state. Cancelled is permanent, a Cancelled job is never auto-resumed. The user said stop; the server honors that.
  • Interrupted is the system-initiated counterpart. Graceful server shutdown and crash recovery (db.recover_interrupted SQL migration) both write JobStatus::Interrupted. Although JobStatus::is_terminal() returns true for it, the recovery sequence above is special-cased to transition resumable Interrupted rows back to Queued so 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 valueOrigin
TuiTUI cancel keystroke
ApiHTTP POST /jobs/{id}/cancel
Cli / Dashboard / Stagingother user-facing entry points
Signalsystem-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

FileRole
crates/batchalign/src/cli/dispatch/mod.rstop-level dispatch router
crates/batchalign/src/cli/dispatch/single.rsexplicit remote single-server dispatch
crates/batchalign/src/cli/dispatch/paths.rslocal-daemon paths-mode dispatch
crates/batchalign/src/daemon.rsdaemon lifecycle, state files, sidecar handling
crates/batchalign/src/routes/mod.rsaxum router composition and middleware
crates/batchalign/src/routes/jobs/mod.rsjob 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.rstranscribe orchestrator (ASR→postprocess→CHAT assembly)
crates/batchalign/src/utseg.rsutseg orchestrator
crates/batchalign/src/translate.rstranslation orchestrator
crates/batchalign/src/coref.rscoreference 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.pyPython worker entry point
batchalign/worker/_model_loading/Python worker model-loading package
batchalign/worker/_stanza_loading.pyStanza 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 (/process route) 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

PathRole
frontend/React dashboard sources (served by batchalign server)
apps/dashboard-desktop/Tauri shell (rust-tauri + React UI build)
crates/batchalign/src/openapi.rsOpenAPI 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 in batchalign-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 N CLI flag, or max_workers_per_job in server.yaml.
  • Default: GPU-heavy commands (transcribe, align, benchmark): 1 file (prevents OOM). CPU-only commands (morphotag, utseg, translate): auto-tuned by compute_job_workers() based on available RAM and CPU cores.
  • Implementation: runner/dispatch/ uses a JoinSet with a Semaphore(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_key in server.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.rs manages 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_size in server.yaml (default 4). Single knob sets both Python ThreadPoolExecutor(max_workers=K) and Rust-side dispatch_semaphore permit 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.rs and worker/pool/shared_gpu/tcp.rs each carry a dispatch_semaphore: Arc<Semaphore> with K permits, acquired before the per-request timeout starts.
    • Python: batchalign/worker/_protocol.py::_serve_stdio_concurrent hosts a ThreadPoolExecutor(max_workers=K).
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 &lt; 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 &ge; 128 GB\nheadroom: 8 GB\nstanza: 12 GB\ngpu: 16 GB\nworkers: 8"]

    detect --> tier
    tier -->|"&lt; 24 GB"| small
    tier -->|"24-48 GB"| medium
    tier -->|"48-128 GB"| large
    tier -->|"&ge; 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:

  1. Pool layer: WorkerPool::checkout returns Err(SpawnFailed("... cannot wait (would deadlock)")) rather than freeing a slot from an idle worker of a different key.
  2. Orchestrator layer: the morphosyntax batch catches the pool error, logs a warning, substitutes an empty UdResponse for every utterance in the affected group, and continues to injection. clear_morphosyntax already cleared the existing tiers; the empty placeholder is stripped by remove_empty_morphosyntax_placeholders. Net effect: file serialized with rc=0 but 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:

  1. Try eviction, WorkerPool::try_evict_idle_from_other_group snapshots every group’s idle count, picks the non-skip group with the highest idle count via the pure helper select_eviction_target, non-blockingly acquires its available permit, pops one idle handle, decrements that group’s total, and drops the handle on a detached task. One global-cap slot is now free; the main loop continues back to spawn.
  2. Park on worker_returned: Arc<Notify> with a bounded deadline (checkout_wait_timeout_s, default 300 s). Every CheckedOutWorker::drop calls notify_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.
  3. 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.total never underflows. The decrement in eviction runs only after a successful try_acquire + pop_front.
  • No worker is destroyed while it has pending work. Only idle workers are evicted (non-blocking try_acquire on available).
  • No new lock-ordering hazard. Same order as global_worker_count and try_spawn_into_group.
  • Graceful shutdown. The evicted handle is dropped on a detached task so checkout never waits on WorkerHandle::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

  1. Idle-eviction prevents the saturation-induced “cannot wait (would deadlock)” error in the common case where other groups hold idle workers.
  2. 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.
  3. 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

KeyDefaultPurpose
max_workers_per_keyper-profile, RAM-derived (recommend_max_workers_per_key); GPU ≈ ram_total_mb / 16 GB, Stanza ≈ ram_total_mb / 12 GB, IO 1Per-key cap; prevents one language from hogging
max_total_workerscomputed from RAM (clamped 2-32)Global cap
checkout_wait_timeout_s300Bounded 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

FileRole
runner/dispatch/infer_batched.rsBatch dispatcher: read → delegate → write
morphosyntax/batch.rsMorphotag L2 dispatch for @s words
morphosyntax/worker.rsPer-language-group worker dispatch with chunking
morphosyntax/mod.rsTop-level morphotag orchestrator; aggregates per-language Result<Vec<UdResponse>, ServerError> and propagates failures upward
fa/mod.rsFA per-file processing
runner/dispatch/fa_pipeline.rsFA orchestrator with JoinSet concurrency
runner/dispatch/transcribe_pipeline.rsTranscribe per-file with optional morphotag
utseg.rs, translate.rs, coref.rsOther batched text commands
worker/pool/mod.rsWorker lifecycle and group management
worker/pool/eviction.rsIdle eviction (try_evict_idle_from_other_group, select_eviction_target)
worker/pool/checkout.rs / dispatch.rsCheckout state machine (saturation timeout, worker_returned notify)
runner/util/auto_tune.rscompute_job_workers() planning
types/runtime.rsRe-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.tomlPer-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:

  • yue uses the PolyU Cantonese utterance model
  • cmn / zho use talkbank/CHATUtterance-zh_CN
  • all three are separate from the word-segmentation / --retokenize path

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: ResultDetail with pre-segmented Words array → absolute timestamps computed from segment start + word offset.
  • FunASR: FunASR’s own unit list (words or raw_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.

  • AlignedNormalization is the only route to normalized Cantonese text. normalize_cantonese itself 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’s S2hk conversion tables in the build. No C++ dependency, no optional import, no fallback path.
  • 31-entry domain replacement table: Aho-Corasick with LeftmostLongest matching 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_cantonese and cantonese_char_tokens used 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

LanguageRetokenize segmenterWhy
yue (Cantonese)PyCantonese segment()Cantonese-specific dictionary; Stanza zh is Mandarin-trained and misses Cantonese compounds (佢哋, 鍾意)
cmn/zho (Mandarin)Stanza zh jointly-trained tokenizerBest 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). --retokenize is needed for all Cantonese morphotag.
  • POS tagging accuracy is ~50% on Cantonese vocabulary without the PyCantonese override. Stanza’s zh model 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 fileCoverage
tests/languages/cantonese/test_common.pyNormalization, config, timestamps, language codes
tests/languages/cantonese/test_funaudio.pyText cleaning, tokenization, timed word sorting
tests/languages/cantonese/test_tencent_api.pyModel selection, monologues, timed words, normalization
tests/languages/cantonese/test_cantonese_fa.pyJyutping conversion, romanization, batch FA inference
tests/languages/cantonese/test_aliyun.pySentence parsing, word extraction, error handling
tests/languages/cantonese/test_helpers.pyCross-engine smoke tests
tests/languages/cantonese/test_integration.pyEnd-to-end with real models (auto-skips)
tests/pipelines/morphosyntax/test_cjk_word_segmentation_claims.pyWord 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 bare PAD appears as a %gra relation in the corpora.
  • Non-UD iob flowed into %gra as IOB across 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:

#TestSourceRejects
0the split accounts for every character of the wordstructural invariantpentolone -> pento+lo (drops ne), hallo, tagliatelle, cavalla
1every non-initial piece is an Italian encliticclosed class of the languagegallina -> galli+na, disegna -> di+se+gna
2the base is an attested verb, or eccoStanza’s own shipped lexiconbello -> ib+lo, pello -> ip+lo, spaghetti, cielo
3the whole form is not itself a dictionary wordStanza’s own shipped lexiconcavallo -> 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:

utterancebeforeafter
attenzioneverb|attenzare-Inf-Ind-Imp-S2~pron|nenoun|attenzione-Fem
macchineverb|maccare-Part-Past-P~pron|nenoun|macchina-Fem-Plur
gallinagalli + nanoun|gallina-Fem
cavallocava + lonoun|cavallo-Masc
belloib + loadj|bello
mucchemu + cce + henoun|mucca-Fem-Plur
eccoloecco + loadv|ecco~pron|lo (PRESERVED)
dammeloda + me + loPRESERVED
diglielodi + glie + loPRESERVED
giralogira + loPRESERVED

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:

patternexampletest
preposition + articlealla = a + lasurface is in the contracted paradigm AND the split is ADP + DET
ecco + encliticeccolo = ecco + loreconstruction + real clitics + the presentative host
clitic clusterglielo = glie + loreconstruction + every piece is an enclitic
verb + encliticgiralo = gira + loreconstruction + 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 so cavallo is 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

  1. 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.
  2. Extend the audit with an in-context damage signature for limitation 3 (e.g. det|il~det|il and verb+enclitic items on multi-word utterances), so in-context committed damage is enumerated the same way.
  3. 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.
  4. 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.
  • 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 the tokenize_postprocessor branch, NOT the tokenize_pretokenized one, which is why Stanza re-tokenizes CHAT words at all.
  • chatter error E761 (%gra relation 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:

MetricValue
Cache hit rate on a 15,748-file corpus rerun6-16%
SQLite lookup time per 25-file window2,500 ms
Inference time saved by hits~100 ms
Net effectCache ≈ 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:

  1. Most utterances are unique across files. Only short common phrases (“thank you”, “okay”) repeat. The 6-16% observed hit rate reflects this.
  2. 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.
  3. 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

  1. Audio inference is expensive. Whisper ASR takes 30-120 seconds per file. FA takes 10-60 seconds. Caching saves minutes, not milliseconds.
  2. 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.
  3. Hit rates are high for repeated alignment. Re-running align on 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_evidence stores 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_alignment stores the locally projected WordTiming vector 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.

  • CacheBackend trait: storage contract (get, put, delete; both single and batched).
  • TieredCacheBackend: production implementation; in-memory moka hot layer wrapping a persistent SqliteBackend cold layer.
  • SqliteBackend: persistent storage via SQLite WAL mode for concurrent read/write safety.
  • UtteranceCache: public entry point, wraps Box<dyn CacheBackend>. Its reads and writes are typed: each takes a CacheTask<N> constant from cache::tasks, which fixes the namespace type N that task’s rows use, and a value of that sealed CacheNamespace type. A task cannot be handed another task’s namespace, and the constants can only be made in the cache module. UtteranceCache deliberately does not implement the string-keyed CacheBackend trait itself, since that would be a route around the pairing.

The typed tasks:

Task constantNamespace typeNamespace bytes
FORCED_ALIGNMENT, FORCED_ALIGNMENT_RAW_EVIDENCEFaCacheNamespacethe FA engine name the worker reported, byte for byte
UTR_ASRUtrAsrCacheNamespaceutr-asr-v1:<UTR engine wire name>:<composition> then one |<role>=<id>@<revision> per model
REV_ASR_EVIDENCERevAsrModelRevisionthe Rev provider revision
SPEAKER_DIARIZATION_RAW_EVIDENCESpeakerEvidenceModelRevisionthe speaker model revision
SPEAKER_DIARIZATION_SEGMENTSSpeakerNormalizationRevisionthe local normalization revision

The layers:

LayerImplementationCapacityEviction
Hotmoka::future::Cache10,000 entries (~5-20 MB)24h time-to-idle
ColdSqliteBackend (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

PlatformPath
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):

  1. tokio::fs::metadata(audio_path) to get file metadata.
  2. Extract meta.len() (file size in bytes).
  3. Extract meta.modified() (mtime seconds since Unix epoch).
  4. 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:

VariantWire stringOrchestrator
ForcedAlignmentforced_alignmentfa/
UtrAsrutr_asrrunner/dispatch/fa_pipeline.rs (UTR pre-pass)
SpeakerDiarizationRawEvidencespeaker_diarization_raw_evidencepipeline/transcribe.rs
SpeakerDiarizationSegmentsspeaker_diarization_segmentspipeline/transcribe.rs
RevAsrEvidencerev_asr_evidencepipeline/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

TaskKey components
Forced alignmentaudio 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 evidenceschema + source-byte digest + preparation revision + backend + expected speakers + model revision
Derived speaker segmentsraw-evidence fingerprint + normalization revision
Raw Rev ASR evidenceschema + 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:

ActionFAUTR fullUTR segmentSpeaker evidence
Edit transcript wordsMissHitHitHit
Change language codeMissMissMissHit
Re-record audioMissMissMissMiss
Rename/copy identical audioMissMissMissHit
Change FA engineMissHitHitHit
Change ASR engineHitHit*Hit*Hit
Change speaker backend/countHitHitHitMiss
Upgrade identified model versionMissMissMissMiss
Use --override-media-cacheSkipSkipSkipRefresh

Raw Rev evidence invalidation is independent of those four columns:

ActionRaw Rev evidence
Edit transcript wordsHit
Change inference-media bytesMiss
Rename/copy byte-identical inference mediaHit
Change requested language or expected speakersMiss
Change Rev request-policy/model revisionMiss
Use --override-media-cacheRefresh

* 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:

  1. Collect payloads (FA groups, UTR segments) from the parsed CHAT AST.
  2. Compute cache keys (BLAKE3 hash of payload content).
  3. Batch lookup: hit entries are injected directly.
  4. Infer misses: send uncached payloads to Python workers.
  5. Inject results into the AST.
  6. Batch put: persist new results for future reuse.

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 AsrResponse with key BLAKE3("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 diarize output: the first speaker-evidence slice is wired to dedicated diarization inside transcribe; standalone output is not yet replayed from this cache.
  • Non-Rev ordinary transcribe ASR: 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

ConfigValueWhy
BackendSQLite via sqlxConcurrent reads (WAL), atomic writes, zero-config
Pool size16 connectionsMatches validation worker count
mmap256 MBFast random access for 95k+ entries
InvalidationVersion field + 30-day TTLSchema changes auto-invalidate; stale entries pruned
BridgeEmbedded single-threaded tokio runtimeSync workers call rt.block_on() for async SQLite

Schema

file_cache table (see crates/talkbank-transform/migrations/20260101000000_initial.sql):

ColumnRole
path_hashBLAKE3 hash of the resolved path (part of the lookup key)
file_pathResolved file path, indexed for path-based maintenance ops
content_hashHash of the file content; mismatch invalidates the entry
versionSchema/code version, mismatch invalidates the entry
cached_atInsertion timestamp
check_alignmentWhether alignment validation was requested
is_validCached validation outcome (0/1)
roundtrip_testedWhether roundtrip equivalence was checked
roundtrip_passedRoundtrip result when tested
parser_kindParser 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

PlatformPath
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 version field; old entries become unreachable.
  • Time-based: entries older than 30 days are pruned.
  • Manual: pass --force to 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-cache needed
  • 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)

StageInside/OutsideCode
Tier 1: check reusable %wor timingOutside (bypasses cache entirely)fa/mod.rs
Group utterances by time windowsOutside (pre-cache)fa/mod.rs
Word extraction per groupOutside (pre-cache)fa/mod.rs
Cache key: BLAKE3(audio_identity | start_ms | end_ms | text | healing_flag | engine)Boundarychat_ops/fa/mod.rs
Whisper/Wave2Vec inference → Vec<Option<WordTiming>>InsidePython fa.py
postprocess_utterance_timings()Outsidefa/postprocess.rs
- WordGapHealing::Heal: backward end-time propagation, bounded by plausibility capsOutsidefa/postprocess.rs
- WordGapHealing::PreserveMeasured (--pauses): leave each word’s end aloneOutsidefa/postprocess.rs
- Clamp to utterance bullet rangeOutsidefa/postprocess.rs
update_utterance_bullet() (overwrite UTR hints; union with authoritative)Outsidefa/orchestrate.rs
%wor tier generationOutsidefa/orchestrate.rs
E362/E704 enforcementOutsidevalidation layer

UTR (Utterance Timing Recovery)

StageInside/OutsideCode
Full-file key: BLAKE3(utr_asr_v2 | UTR engine | audio_identity | lang)Boundarychat_ops/fa/utr.rs
Segment key: BLAKE3(utr_asr_segment_v2 | UTR engine | audio_identity | start_ms | end_ms | lang)Boundarychat_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 writtenBoundarycache/mod.rs
ASR inference → Vec<AsrTimingToken>InsidePython asr.py
Global Hirschberg DP alignment (words ↔ ASR tokens)Outsiderunner/dispatch/utr.rs
Utterance bullet injectionOutsiderunner/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 changedOverride needed?Why
Post-processing logic (injection, bullet computation, %wor generation, retokenization after cache, terminator patching)NoRuns after cache retrieval, cached value is still correct
Cache key computationNoOld entries become orphans (different key = automatic miss). New keys miss and re-infer.
Word extraction logic (changes which words are sent to the model)YesCached 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 engineFA 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 valuesUsually noNormal mode may treat an unreadable derived value as work to recompute; required mode refuses the unresolved group.
Serialization format of raw Rev/speaker evidenceNo automatic refreshCorruption fails closed so local damage cannot authorize a paid call. Change the schema/revision deliberately.
Parse logic (changes how CHAT is parsed before extraction)DependsIf extraction produces different words → yes (different key). If same words → no.
Pre-cache text normalization (e.g., the script rendering in TranslationSource::render)YesKey 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:

  1. What’s cached? Vec<Option<WordTiming>>: the raw per-word timings from Whisper/Wave2Vec.
  2. Where’s the bug? In update_utterance_bullet(): post-processing that runs after cache retrieval.
  3. 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 by Bullet::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.):

  1. Identify the change category using the decision matrix above.
  2. If override is NOT needed: Deploy the new binary. Cached results are reprocessed through the fixed post-processing automatically.
  3. If override IS needed: Deploy the new binary, then re-run affected commands with --override-media-cache on the target corpora. For large corpora, consider running only on affected files rather than the full dataset.
  4. 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).
  5. If unsure: inspect the raw/derived boundary first. Use --require-media-cache to prove an experiment can replay without inference; use --override-media-cache only 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.

LevelNameChecks
L0ParseableNo parse errors (clean tree-sitter CST)
L1StructurallyComplete@Participants and @Languages present, all speaker codes declared, every utterance has a terminator
L2MainTierValidWell-formed words, valid timing bullets if present

Pre-validation gates

Each command requires input to meet a minimum level before processing:

CommandRequired level
morphotagMainTierValid
utsegStructurallyComplete
translateStructurallyComplete
corefStructurallyComplete
alignParseable (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():

  1. Alignment validation: checks that %mor/%gra/%wor tier word counts match the main tier. ParseHealth-aware: utterances flagged as unparseable during lenient parsing are excluded.
  2. 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 %gra in old corpora may be accepted with warnings so files remain processable.
  • Newly generated %gra from batchalign3 is validated more strictly before writeback.

Bug Reports and Cache Purges

When post-serialization validation fails:

  1. A structured bug report is written to ~/.batchalign3/bug-reports/.
  2. 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:

GateWhat it checks
G0Parser signature guardrail
G1Rust workspace compile check
G2Spec tools compile check
G3Spec runtime tools compile check
G4CHAT manual anchor links
G5Generated parser corpus equivalence suite
G6Golden fragment validity (words + tiers)
G7Bare-timestamp regression gate
G8Reference corpus semantic equivalence
G9%wor tier parsing and alignment
G10Golden tier roundtrip (%mor, %gra, %pho, %wor)
G11Reference corpus node coverage
G12Generated artifacts match committed sources
G13Fuzz workspace isolation
G14Imported 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 %gra round-trip cleanly); some bullet-format minor variants. Newly generated tiers from batchalign are validated more strictly before writeback.
  • %wor word counts are not validated against the main tier. %wor is a timing-annotation tier with no downstream positional indexing; legacy files may have xxx, fragments, or nonwords in %wor without producing alignment errors.
  • Cross-utterance quotation validation is gated off by default (enable_quotation_validation flag), 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 marked Status: not_implemented; these generate #[ignore] tests via make test-gen rather than failing CI. Run grep -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 ValueError in 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 ParseHealth flags so downstream validation skips them.
  • Parse warnings are captured in ParsedChat.warnings and available via parse_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:

FieldTypeNotes
codestre.g. "E705"
severitystr"error" / "warning"
lineOptional[int]1-based; None when unavailable
columnOptional[int]1-based; None when unavailable
messagestrFull diagnostic message
suggestionOptional[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:

CategoryMeaningExamples
inputBad CHAT contentCHATValidationException, parse errors
mediaMissing audio/video filesFileNotFoundError
systemInfrastructure failureMemoryError
processingUnexpected errors during processingEverything 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:

FileSites
worker_asr_exec.rsparse_execute_request, final serde_json mapping
worker_media_exec.rs4 sites
worker_text_results.rs16 sites
worker_fa_exec.rs2 sites
worker_artifacts.rs20 sites
cantonese_asr_bridge.rs4 sites
py_json_bridge.rs1 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), not PyValueError.
  • exc.errors[0].code is 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 legacy classify_error helper that matches on substrings of str(exc) is a fallback for non-PyO3 code paths only; it is not the contract for typed exceptions.
  • Re-using talkbank-transform’s MorphosyntaxStrategy / DecisionRecord types directly across the boundary. These have richer structure than the Python side wants today; they’re flattened to ValidationErrorEntry at the boundary.
  • Rust-side panics. The no-panic standard from CLAUDE.md applies; this contract assumes panics never cross the boundary.
  • crates/batchalign/src/error.rs: server-side ServerError typed-status mapping; the PayloadTooLarge variant 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 to MAX_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:

LayerFileFunctionNote
Driver (per-file)crates/batchalign/src/pipeline/text_infer.rsrun_text_pipelineSingle-file flow; per-item Err collapses to one typed ServerError::Validation
Driver (cross-file)crates/batchalign/src/pipeline/text_infer.rsrun_text_batch_pipelineCross-file flow; per-item Err attributed back to source file via per_file_info
Shared helpercrates/batchalign/src/text_batch.rsunwrap_per_item_resultsCollapses Vec<Result<R, ItemFailure<S>>>Result<Vec<R>, TextWorkflowFileError>
translate workercrates/batchalign/src/translate.rsparse_translate_item_resultsPer-item parsing; engine error, protocol violation and a translation with nothing to apply all → Err
utseg workercrates/batchalign/src/utseg.rsinfer_admitted_batchSame shape; the projection to bare responses (infer_batch) is gone, so the batch keeps each prediction’s evidence
coref workercrates/batchalign/src/coref.rsinfer_batchPer-document (one item per file)
morphotag workercrates/batchalign/src/morphosyntax/worker.rsinfer_batch_singlePer-item; Stanza-parse-failure folded into per-item Err
Python worker (utseg)batchalign/inference/utseg.py_parse_tree_indicesRaises AttributeError on malformed tree (previously returned [])
transcribe (ASR)crates/batchalign/src/pipeline/transcribe.rsstage_asr_postprocessThe 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.rsstage_asr_postprocessWords came back and post-processing kept no utterance from them: EmptyTranscription::Postprocess
transcribe (CHAT assembly)crates/batchalign/src/pipeline/transcribe.rsstage_build_chatUtterances 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:

  1. L2|xxx morphotag fallback for code-switches into Stanza- unsupported languages. infer_batch_per_item marks those items Ok(UdResponse { sentences: vec![] }), and downstream inject_results skips the empty UdResponse so the existing L2|xxx placeholder stays in %mor. This is BA2-parity behavior for languages Stanza cannot analyze, the empty response is the feature, not a missing result.

  2. 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 %xcoref is 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:

  1. A file with missing terminators proceeds through forced alignment (92s of GPU), only to be rejected by the post-serialization validation gate.
  2. A file with a corrupted main tier (e.g., to+... parsed as to+ + .) gets silently mangled during roundtrip serialization.
  3. 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.

  • @UTF8 header present
  • @Begin / @End headers present
  • No unrecognized line types (no unsupported_line nodes)
  • 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:

  • @Participants header present with at least one participant
  • @Languages header 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() in TierDomain::Mor)
  • @Languages header (determines Stanza model language)
  • Per-utterance language markers ([- spa]) for multilingual filtering
  • Special form markers (@s, @c, @b) on words for POS override
  • Existing %mor tiers (to skip already-tagged utterances, unless --override-media-cache)

Writes:

  • %mor tier: morphological analysis (POS, lemma, features) per utterance
  • %gra tier: 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, %gra tiers (will be overwritten)
  • %pho, %sin, %wor tiers (untouched, validity irrelevant)
  • %xtra, %xcoref tiers (untouched)
  • Timing bullets (untouched, validity irrelevant)
  • Temporal ordering (untouched)
  • @Date, @ID field formats (not read)

Output guarantees:

  • Every utterance with extractable words has a %mor and %gra tier
  • %mor word count equals main tier alignable word count (enforced by validate_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() in TierDomain::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, utseg should typically be run before morphotag and align, 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:

  • %xtra tier: translated text as a UserDefined dependent tier
  • Replaces existing %xtra if 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 %xtra tier
  • 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-%xtra dependent 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)
  • @Languages header (English-only gate)

Writes:

  • %xcoref tier: bracket-notation coreference annotations (sparse, only utterances with actual chains)
  • Replaces existing %xcoref if 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 %xcoref tiers 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 Word nodes in the main tier
  • Utterance-level timing bullet (first-word-start to last-word-end)
  • %wor tier: 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, %gra tiers (untouched)
  • %xtra, %xcoref tiers (untouched)
  • Existing %wor tier (will be regenerated)
  • @Date, @ID field formats (not read)

Output guarantees:

  • Every alignable word has a timing bullet (or None if alignment failed)
  • Utterance-level bullets span first-to-last word timing
  • %wor tier 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-%wor dependent 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, @End headers
    • @Languages, @Participants, @ID headers
    • @Media header referencing the audio file
    • Main tier utterances with speaker codes and timing bullets
    • %wor tiers 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

CommandMin LevelAdditional Preconditions
morphotagLevel 2@Languages present
utsegLevel 1,
translateLevel 1,
corefLevel 1English language
alignLevel 2Audio file exists
transcribeN/AAudio file exists
benchmarkN/AAudio file exists
opensmileN/AAudio file exists
avqiN/APaired audio exists

Rejection Behavior

When a file fails pre-validation:

  1. The file is marked as error in the job status with category "validation"
  2. The specific validation errors are reported (e.g., “E304: Missing terminator on line 15”, “E233: Empty compound trailing part on line 22”)
  3. Processing continues with the next file in the job (partial job completion)
  4. No compute is wasted on the invalid file
  5. 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:

  1. Alignment validation: tier word counts match (for commands that write dependent tiers)
  2. Temporal validation: monotonic timestamps, no self-overlap (for commands that write timing)
  3. 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.

CommandPreservation Set
morphotagHeaders, timing, annotations, %pho/%sin/%wor/%xtra/%xcoref, %com
utsegHeaders, non-utterance lines (dependent tiers on split utterances are NOT preserved)
translateHeaders, main tier, timing, all tiers except %xtra
corefHeaders, main tier, timing, all tiers except %xcoref
alignHeaders, 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 %xalign or %xrev. Legacy ReviewLevel values 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. Now FaStrategy::NarowBulletRescud fails 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. TimingStripped exists under both FaStrategy and MonotonicityStrategy because both modules legitimately emit it; the outer DecisionStrategy discriminator distinguishes them. Same for InjectionFailed (Morphosyntax and Coref) and NotApplicable / 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 , what Utterance::mor_alignable_word_count() returns.
  • MorItemCount , what mor_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_applicable
  • crates/batchalign-transform/src/utseg.rs: UtsegOutcome, validate_utseg_response
  • crates/batchalign-transform/src/coref.rs: CorefOutcome, apply_coref_results_with_outcomes
  • crates/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 (returns Result<(), MisalignmentDiagnostic>)
  • batchalign/inference/morphosyntax.py, realignment-skipped WARN at the Python boundary
  • chatter/crates/talkbank-model/src/alignment/helpers/count.rs , MorAlignableWordCount / MorItemCount newtypes and count_tier_positions walker

Tests that pin the architecture:

  • crates/batchalign/tests/mor_count_parity_reference_corpus.rs , cross-walker count parity across the 98-file reference corpus
  • batchalign/tests/inference/test_morphosyntax_realignment_contract.py , Python contract test pinning tok_ctx.original_words sequencing
  • crates/batchalign-transform/src/morphosyntax/outcome.rs #[cfg(test)]: per-variant classification tests
  • crates/batchalign/src/eval_cmd/l2_morphotag/tests.rs , UtteranceOutcome classifier truth table

Deep-dive pages:

How to investigate a morphotag misalignment decision

When logs report module=morphosyntax strategy=misalignment_bug, use this flow:

  1. Read the suspected_class field in the reason. Five values (RealignmentSkipped, MwtReassemblyBug, TerminatorFilterBug, LanguageDispatchIssue, Unknown) each point at a different stage.
  2. Compare chat_words and stanza_tokens_after_mapping also in the reason. The word/token sequences usually show where they diverged, e.g. a comma dropped, an MWT split wrongly reassembled.
  3. 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 RealignmentSkipped class is concrete.
  4. Use the typed line index and speaker in the trace to locate the utterance. --review-level is 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 %mor items 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'tdo + 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.rs asserts that Utterance::mor_alignable_word_count() and extract::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.py asserts that tok_ctx.original_words = word_lists happens before every nlp() call in normal mode, and is empty under --retokenize.
  • Stage 3: unit tests in nlp/mapping/mod.rs exercise 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:

ModeTriggerWhy skip realignment
CJK retokenizeMandarin (zho/cmn) with retokenize=True; use_retok_pipeline=True in morphosyntax.pyChinese 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 retokenizeAny language with --retokenize CLI flag; req.retokenize=TrueThe 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.

KindMeaningSurfaces 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 was None for 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_punct dropped too many or too few PUNCT tokens, e.g. dropping mid-utterance cm|cm separators 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 to counts_for_tier in talkbank-model, propagated through mor_alignable_word_count() to every caller. Pipeline-internal heuristics are banned.
  • It does not reduce observability on the happy path. Aligned outcomes 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 types
  • crates/batchalign-transform/src/inject.rs: invariant check & outcome emission
  • crates/batchalign-transform/src/morphosyntax/payload.rs: NotApplicable classification
  • batchalign/inference/morphosyntax.py: realignment stage
  • batchalign/tests/inference/test_morphosyntax_realignment_contract.py: Stage 2 tests
  • crates/batchalign/tests/chat_ops_mor_count_parity_reference_corpus.rs: Stage 1 tests
  • talkbank-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:

TypeInnerDefined inDomain
JobIdStringcrates/batchalign-types/src/domain.rsJob identity
CommandNameStringcrates/batchalign-types/src/domain.rsBatchalign command ("morphotag", "align")
ReleasedCommandenumcrates/batchalign-types/src/domain.rsClosed released command vocabulary
LanguageCode3Stringcrates/batchalign-types/src/domain.rsValidated ISO 639-3 code (3 ASCII alpha, lowercased)
LanguageSpecenumcrates/batchalign-types/src/domain.rsAuto or Resolved(LanguageCode3): language at job boundary
DisplayPathStringcrates/batchalign-types/src/domain.rsDisplay-oriented file path within a job ("sample.cha", "subdir/sample.cha")
NodeIdStringcrates/batchalign-types/src/domain.rsServer/fleet node identity
BuildOwnedNamespaceCow<'static, str>crates/batchalign/src/cache/mod.rsThe 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
StampSafeTextCow<'static, str>crates/batchalign-types/src/domain.rsText 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
ReportedEngineNameStampSafeTextcrates/batchalign-types/src/domain.rsAn 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
CorrelationIdStringcrates/batchalign-types/src/domain.rsCross-service tracing ID
NumSpeakersu32crates/batchalign-types/src/domain.rsSpeaker count for diarization
DurationSecondsf64crates/batchalign-types/src/domain.rsDuration in seconds
UnixTimestampf64crates/batchalign-types/src/domain.rsEpoch timestamp
DurationMsu64crates/batchalign-types/src/domain.rsDuration in milliseconds
MemoryMbu64crates/batchalign-types/src/domain.rsMemory amount in megabytes
WorkerPidu32crates/batchalign-types/src/worker.rsOS process ID
AsrTimestampSecsenumcrates/batchalign-transform/src/asr_postprocess/asr_types.rsAn 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
SpeakerIndexusizecrates/batchalign-transform/src/asr_postprocess/asr_types.rsZero-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:

TypeUsed byNot used by
LanguageSpecJobSubmission.lang, JobDispatchConfig.lang, RunnerDispatchConfig.lang, JobInfo.lang, JobListItem.langWorker IPC, cache keys, MorphosyntaxParams, TranscribeOptions
LanguageCode3Worker IPC, cache keys, MorphosyntaxParams, FA params, all domain-internal language references,

Resolution: LanguageSpec::Auto is resolved to a concrete LanguageCode3 at two points:

  1. Dispatch layer: resolve_or(&fallback) for commands that need a known language (FA, morphotag, compare).
  2. Transcribe pipeline: stage_build_chat uses AsrResponse.lang (the ASR engine’s detected language) when opts.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);
TypeSourceLives in
ChatRawTextWord::raw_text()../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction)
ChatCleanedTextWord::cleaned_text()../chatter/crates/talkbank-model/src/text_types.rs (CHAT direction)
SpeakerCodeUtterance.speaker../chatter/crates/talkbank-model/src/model/header/codes/speaker.rs (CHAT direction)
AsrRawTextASR provider outputcrates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction)
AsrNormalizedTextAfter 8-stage pipelinecrates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction)
ChatWordTextASR-to-CHAT boundarycrates/batchalign-transform/src/asr_postprocess/asr_types.rs (ASR direction)

Attributes:

  • #[repr(transparent)]: zero runtime cost, identical layout to inner String.
  • #[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 boilerplate
  • LanguageCode3 is not a string_id! because it needs real validation
  • LanguageSpec and WorkerLanguage are 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:

ContextPattern
JSON/IPC serializationpath.to_string_lossy().into_owned()
Tracing/loggingaudio_path = %path.display()
Format stringsformat!("{}", path.display())
Passing to &str functionspath.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

GoalAttributeExample
Newtype as plain value#[serde(transparent)]ChatCleanedText"hello"
Flatten inner struct/enum#[serde(flatten)] on fieldMorphotagOptions.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

DateChangePatternRationale
2026-02Text provenance newtypesProvenanceBugs 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-02JobStatus / FileStatusKind enumsState machineReplaced stringly-typed status fields; commit cbe0f873
2026-02Flattened command option groupsStruct compositionReduced duplication while keeping flat JSON across command request types
2026-03string_id! / numeric_id! macrosDomain identifierEliminated primitive obsession across server codebase; 13 newtypes
2026-03CachePolicy, WorTierPolicy enumsBoolean blindnessReplaced ambiguous override_media_cache: bool, write_wor: bool
2026-03MorphosyntaxParams, FaParams, AudioContext, PipelineServicesParameter groupingReduced orchestrator signatures from 14-16 params to 3-6
2026-03&Path/PathBuf for audio pathsPath typesReplaced &str/String for file paths; explicit conversion at IPC boundaries
2026-03Boundary conversion patternsAllCodified convert-once-at-boundary: HTTP→JobId, DB→deref, IPC→to_string_lossy, CLI→From<bool>
2026-03LanguageSpec enum + validated LanguageCode3Sentinel enum"auto" sentinel leaked into @Languages header (job 696870c7); split into Auto/Resolved enum with validated construction
2026-03ClientPath, ServerPath, RepoRelativePath, MediaMappingKeyPath provenanceUntyped paths allowed mixing client/server filesystems; ClientPath omits AsRef<Path> to prevent accidental I/O; details

Guidelines

  1. Default to newtypes for any String or number that has domain meaning beyond “some text” or “some count.” Use string_id! or numeric_id!: never hand-roll the boilerplate.
  2. Use Path/PathBuf for file system paths, never String/&str. Convert to strings only at IPC/JSON boundaries via to_string_lossy().
  3. Prefer enums over booleans when a third option is plausible or the boolean’s name doesn’t clearly convey both states.
  4. Convert at the boundary, once. Raw String from HTTP extractors → JobId::from() immediately. bool from CLI flags → CachePolicy::from() in the dispatch layer. Interior code never handles raw primitives for typed values.
  5. Use #[serde(untagged)] + #[serde(flatten)] to introduce ADTs without breaking wire format. Always verify with snapshot tests.
  6. Add predicate methods (is_terminal(), can_cancel()) on state enums, they centralize transition logic and make match exhaustiveness work for you.
  7. Keep newtypes simple: #[serde(transparent)] struct with a single field. No phantom types, no generics. The string_id! macro generates Deref<Target=str> for zero-friction migration.
  8. 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.
  9. Function signatures must be self-documenting through types. If you need to read the parameter name to understand what a &str argument 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 component
  • suffix_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/Photos
  • mappings: {"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

TypeUsed inField / Parameter
ClientPathJobSubmissionsource_dir, source_paths, output_paths, before_paths
ClientPathJobMetadatasource_dir
ClientPathJobFilesystemConfigsource_dir
MediaMappingKeyJobSubmissionmedia_mapping
MediaMappingKeyServerConfigmedia_mappings (key)
ServerPathServerConfigmedia_roots, media_mappings (value)
RepoRelativePathJobSubmissionmedia_subdir
RepoRelativePathFA pipelineInferred 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

FileRole
crates/batchalign-types/src/paths.rsAll four newtypes + infer_media_mapping()
crates/batchalign/src/types/request.rsJobSubmission uses ClientPath, MediaMappingKey, RepoRelativePath
crates/batchalign/src/types/config.rsServerConfig.media_mappings: BTreeMap<MediaMappingKey, ServerPath>
crates/batchalign/src/runner/dispatch/fa_pipeline.rsMedia resolution cascade using all four types
crates/batchalign/src/submission.rsClientPath to PathBuf bridge in materialize_submission_job()
crates/batchalign/src/store/job/types.rsJobFilesystemConfig 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 mar have tokenize/pos/lemma/depparse? does nld have mwt? does eng have constituency?)
  • For a given ISO-639-3 code, what alpha-2 catalog key does Stanza use? (e.g. marmr, yuezh-hans, nornb).

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.

DateWhat driftedSymptomRoot cause
2026-04-15MWT_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-06iso3_to_alpha2() (Python) had a hardcoded ISO-3 → ISO-1 mapping that did not include Marathi (marmr); 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 crashCONSTITUENCY_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 tableLocationWhat it hardcodedNow
MWT_LANGSPython inferenceLanguages with multi-word token expansionReplaced by StanzaRegistry::has_mwt().
SUPPORTED_STANZA_CODESstanza_languages.rsISO-639-3 codes Stanza could processHardcoded fallback only; live registry overrides.
iso3_to_alpha2 (hardcoded dict)Python workerISO-639-3 → ISO-639-1 code mapping for Stanza model loadingReduced 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_LANGSPython utsegLanguages with constituency parsingReplaced by StanzaRegistry::has_constituency().
STANZA_SUPPORTED_ISO3 (removed)request.rsWas a duplicate of SUPPORTED_STANZA_CODESRemoved; submission validation delegates to the chat-ops list.
Inline checks in _stanza_loading.pyPython workerAd hoc feature gatingReplaced by StanzaRegistry queries via the capability table.
Inline checks in batch.rsRust morphosyntaxLanguage filteringReplaced by registry queries.

The principle (re-derive when in doubt)

  1. Stanza’s installed catalog (resources.json) is the only source of truth for which languages have which processors.
  2. pycountry is the only source of truth for the standard ISO-639-3 ↔ ISO-639-1 mapping.
  3. _ISO3_OVERRIDES in _stanza_capabilities.py is the only place to record Stanza-specific deviations from the standard mapping (yue/cmn/zho → zh-hans, nor → nb, etc.).
  4. Every iso3↔alpha2 conversion in the worker imports _ISO3_OVERRIDES from that one site rather than redefining it. iso3_to_alpha2 does this; do not reintroduce a second override dict.
  5. Every “is lang X supported” check at submission, dispatch, or load time goes through the live StanzaRegistry (with the chat-ops SUPPORTED_STANZA_CODES list 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&lt;StanzaRegistry&gt;\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:

ProcessorStanza keyWhat 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:

  1. _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).
  2. 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:

MethodWhat it checksUsed by
supports_morphosyntax(iso3)tokenize + pos + lemma + depparseSubmission validation, batch dispatch
has_mwt(iso3)mwt processor availableMorphosyntax pipeline (includes MWT step only when available)
has_constituency(iso3)constituency processor availableUtseg dispatch (falls back to sentence-boundary without it)
alpha2(iso3)ISO-639-3 to Stanza alpha-2Model loading configuration
supported_languages()All ISO-639-3 codesError messages, help text
is_populated()Non-empty registryFallback 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:

  1. Pre-filter (validate_language_support() in request.rs): delegates to chat_ops::stanza_languages::is_stanza_supported, which consults the hardcoded SUPPORTED_STANZA_CODES set (~50 languages). This runs even before any worker has started, so it catches obviously unsupported languages immediately.

  2. Authoritative check (validate_language_with_registry() in request.rs): uses the live StanzaRegistry when available. This is called from materialize_submission_job() in routes/jobs/mod.rs where the registry is accessible via AppState.

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

CommandRequired processorsOptional processors
morphotagtokenize + pos + lemma + depparsemwt (if available)
utsegtokenize + posconstituency (degrades to sentence-boundary without it)
corefEnglish-only, uses Stanza coref
translateDoes not use Stanza
align/transcribeDoes 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 mwt processor from the Stanza pipeline configuration.
  • No constituency: Utseg falls back to sentence-boundary segmentation (_stanza_loading.py:UtsegConfigBuilder checks 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

FileRole
batchalign/worker/_stanza_capabilities.pyReads resources.json, builds StanzaCapabilityTable
batchalign/worker/_handlers.pySerializes table into CapabilitiesResponse.stanza_capabilities
batchalign/worker/_types.pyCapabilitiesResponse, StanzaLanguageProcessors Pydantic models
batchalign/worker/_stanza_loading.pyUtseg config builder queries table for constituency/MWT
crates/batchalign-types/src/worker.rsWorkerCapabilities, StanzaLanguageProcessors Rust types
crates/batchalign/src/stanza_registry.rsStanzaRegistry with typed query methods
crates/batchalign/src/worker/pool/mod.rsOnceLock<StanzaRegistry> storage, record_capabilities()
crates/batchalign/src/types/request.rsSingle Rust fallback via is_stanza_supported_language(); delegates to chat-ops
crates/batchalign/src/types/request.rsvalidate_language_with_registry(); submission-time gate delegates to chat-ops
crates/batchalign/src/pipeline/transcribe.rsPlan-time gate (registry first, chat-ops fallback)
batchalign/worker/_stanza_loading.pyUnsupportedLanguageError preflight before stanza.Pipeline
crates/batchalign/src/morphosyntax/batch.rsQueries registry for language filtering
scripts/generate_stanza_language_table.pyRegenerates 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

DefectStageMitigation fileTest pointerStanza version confirmed
1Post-depparse, pre-map-UDcrates/batchalign-transform/src/morphosyntax/invariants/finite_verb_main_clause.rstest_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
2Tokenize (postprocessor hook)batchalign/inference/_tokenizer_realign.py::_realign_sentencetest_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
3Dedicated 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
4Retired in Stanza 1.12.0 (no active patch-point), (mitigation files removed in commit cea8f082),retired upstream in 1.12.0
5Pipeline constructionbatchalign/worker/_stanza_loading.py::should_request_mwttest_stanza_loading.py::TestShouldRequestMwt; test_stanza_config_parity.py::TestMwtCapabilityDriven; test_stanza_he_el_mwt_splits.py; test_he_el_mwt_end_to_end.pyevery 1.x through 1.14.0
6Unpatched 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
7Unpatched 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
9Post-depparse, pre-map-UDcrates/batchalign-transform/src/morphosyntax/invariants/lexicon_category.rs, lexicon.rs, data/eng_lexicon_verdicts.jsonlexicon_category.rs and lexicon.rs #[cfg(test)]1.11.1, 1.14.0
10Post-depparse, pre-map-UD (first in the English chain)crates/batchalign-transform/src/morphosyntax/invariants/english_contractions.rsenglish_contractions.rs #[cfg(test)]1.14.0
11Post-depparse, pre-map-UD (evidence computed at injection)crates/batchalign-transform/src/morphosyntax/evidence.rs, invariants/discourse_marker.rsevidence.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 %mor content flowing through unimpeded (Defects 6 and 7).

When to add a new patch-point

Use this procedure for any newly discovered Stanza defect:

  1. 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 Document output originates post-infer. Patch at the origin, not earlier or later.
  2. 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.
  3. 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).
  4. Register the defect in stanza-limitations.md with the full format (version, reproducer, correct output, mitigation pointer, tests, re-evaluation criteria).
  5. 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 lemmatizing par and echoes the surface fragment as the lemma. The %mor chunk has the right count but the wrong content. Candidate signal: head.lemma == head.text on 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 to il. The %mor chunk 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() or is_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 gets Connection 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 exceeds max_body_bytes_mb).
  • Attempts and backoff: RETRY_ATTEMPTS = 3 total, exponential backoff starting at RETRY_BACKOFF = 2.0 s with 0.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.

ConcernWhere it lives
Provenance-carrying report from a backendBackendProgress (runner/util/batch_progress.rs)
Declared work + completions per fileBatchProgressLedger, projected as SourceProgress
Publishing cadence and shutdownBatchProgressReporter (execution/morphotag/progress.rs)
Per-file handle threaded to the dispatch siteBackendProgressPort

Two scope rules, both learned by getting them wrong.

  1. A group DECLARES its work before dispatch; the denominator is never inferred from what has reported. infer_batch_homogeneous calls declare_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.
  2. Completions are keyed (source, group, chunk) and summed. A language group is split into up to max_workers_per_key chunks, each its own request with its own counts, so aggregating them by language alone displayed 453/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 and RunnerEventSink::set_batch_progress were 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_progress was permanently null.
  • 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/274 overflow. It had not; the tagger addressed a different bug (all languages collapsing onto the shared stage label) 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 batchalign package 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_core native 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, and websocket.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

FileWhat 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.rsProgressReport, BackendProgress, BatchProgressLedger and its SourceProgress projection
execution/morphotag/progress.rsBatchProgressReporter (owns the ledger, publishing cadence, stall republish) and BackendProgressPort (per-file handle)
runner/dispatch/infer_batched.rsDrain task, heartbeat gap, progress publishing
morphosyntax/batch.rsLanguage group dispatch, semaphore, timeouts
worker/handle/lifecycle.rsStderr capture, ready signal
worker/error.rsProcessExited { code, stderr }
runner/util/error_classification.rsError → user-facing message translation
store/queries/file_state.rsStore 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:

  1. Run transcribe on audio → initial CHAT with ASR output
  2. User reviews: fixes words, splits/merges utterances, corrects speakers
  3. Run morphotag and/or align on 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:

  1. Extract Mor-domain words per utterance from both files using extract_words().
  2. Compute fingerprints: space-joined cleaned word text per utterance.
  3. Run Hirschberg DP alignment on the fingerprint sequences (reusing the existing dp_align::align() infrastructure).
  4. Post-process the alignment to detect substitution pairs (adjacent ExtraPayload + ExtraReference from the DP aligner).
  5. 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:

FilePurpose
diff/types.rsUtteranceDelta enum, DiffSummary
diff/classify.rsdiff_chat(): DP alignment + classification
diff/preserve.rscopy_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:

  1. full-file %wor refresh when the whole file is already reusable
  2. per-utterance %wor preservation for unchanged regions in an edited file
  3. 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, &params).await
} else {
    // Batch path
    process_morphosyntax_batch(&files, services, &params).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:

MethodReturns 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

ContextBefore sourceAfter source
--in-place CLICurrent file on diskSame file (pre-edit is the “before”)
--before flagExplicit pathInput file
REST APIbefore_text fieldFile content
First runNone → full processingInput 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

CommandIncremental?GranularityBehavior
morphotagYesPer-utteranceSkip unchanged; reprocess changed words
alignYesPer-groupAny changed utterance in a group → re-align group
utsegNot yetPer-utteranceWould skip unchanged utterances
translateNot yetPer-utteranceWould skip unchanged utterances
transcribeN/AWhole-fileCreates from scratch (audio → text)
corefN/AWhole-documentContext-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:

PathWorker callsCache checks
Batch50 utterance payloads50
Incremental3 utterance payloads3

For FA with 8 groups where 1 contains a changed utterance:

PathFA groups aligned
Batch8
Incremental1

Files

FileCratePurpose
diff/types.rstalkbank-transformUtteranceDelta, DiffSummary
diff/classify.rstalkbank-transformdiff_chat() algorithm
diff/preserve.rstalkbank-transformcopy_dependent_tiers()
morphosyntax/batchalignprocess_morphosyntax_incremental()
fa/batchalignprocess_fa_incremental()
runner/dispatch/infer_batched.rs, fa_pipeline.rsbatchalignDispatch 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:morphosyntax or infer: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)

ModuleModelSourceSizeLoaded WhenHF Hub
inference/morphosyntax.pyStanza pipeline (tokenize, pos, lemma, depparse, mwt)stanza / HF Hub300-500 MB per languageFirst 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.

ModuleModelSourceSizeLoaded WhenHF Hub
inference/fa.py (Whisper FA)openai/whisper-large-v2HF Hub~3 GBWorker startup (immediate)Yes
inference/asr.py (UTR)openai/whisper-large-v3 for every language, pinned to the commit named in model_manifest.rsHF Hub~3 GBFirst audio file (lazy)Yes
Rust (disfluency)None (rule-based data files)localnegligibleN/ANo
Rust (retrace)None (Rust n-gram)localnegligibleN/ANo

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 namespace utr-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 / EngineModelSourceSizeLoaded WhenHF Hub
Rust crates/batchalign/src/revai/asr.rs: Rev (default)Rev.AI HTTP client onlylocal + remote APInegligible local memoryper-file server dispatchNo
inference/asr.py: Whisperopenai/whisper-large-v3 + optional BertUtteranceModelHF Hub~3 GB + ~400 MBWorker startup (immediate)Yes
Rust (disfluency)NonelocalnegligibleN/ANo
Rust (retrace)NonelocalnegligibleN/ANo

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 / BackendModelSourceSizeLoaded WhenHF Hub
inference/translate.py: Google (default)None (Google Translate API)remoteN/AN/ANo
inference/translate.py: Seamlessfacebook/hf-seamless-m4t-mediumHF Hub~1.2 GBWorker startup (immediate)Yes

Result caching: SQLite utterance cache. Key = BLAKE3(text + src_lang + tgt_lang).


utseg (task string: utterance)

ModuleModelSourceSizeLoaded WhenHF Hub
inference/utseg.pyBertUtteranceModel 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 languageFirst batch (lazy factory)Yes

Result caching: SQLite utterance cache. Key = BLAKE3(text + lang).


coref (task string: coref)

ModuleModelSourceSizeLoaded WhenHF Hub
inference/coref.pyStanza tokenizer + ontonotes-singletons_roberta-large-lorastanza / HF Hub~500 MBFirst file (lazy)Yes

English only. Result caching: None.


benchmark (server-owned composition over asr)

Same engines as transcribe plus a Rust-side WER step:

ModuleModelSourceSizeLoaded WhenHF Hub
crates/batchalign-transform/src/benchmark.rsNone (Rust Hirschberg DP alignment via the allowlisted dp_align::align call site)localnegligibleN/ANo

opensmile (task string: opensmile)

ModuleModelSourceSizeLoaded WhenHF Hub
inference/opensmile.pyNone (C++ feature extraction)localnegligibleWorker startupNo

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:

PriorityDeviceNotes
1CUDAIf torch.cuda.is_available() and not --force-cpu
2MPSmacOS Metal (Apple Silicon). Used on Apple Silicon server/client machines when available
3CPUFallback

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

AreaCall siteDP algorithmNotes
Whisper ASR timestamp extractionbatchalign/inference/audio.pyDTW (_dynamic_time_warping)Maps decoder tokens to audio frames from cross-attention matrices
Whisper FA token timingbatchalign/inference/fa.pyDTWToken jump times extracted in Python, mapped in Rust
Wave2Vec forced alignmentbatchalign/inference/fa.pyCTC forced alignment (Viterbi-style DP)torchaudio.functional.forced_align on emission matrix vs transcript
FA word-level remappingcrates/batchalign/src/chat_ops/fa/alignment.rs::apply_indexed_timingsNoneIndexed callback protocol maps timings 1:1 by index
FA token-level remappingcrates/batchalign/src/chat_ops/fa/alignment.rs::align_token_timingsNoneDeterministic token→word stitching only; unmatched words remain untimed
UTR timing recoverycrates/batchalign/src/chat_ops/fa/utr.rs::inject_utr_timingHirschberg edit-distance DPGlobal alignment of all document words against all ASR tokens
Morphosyntax retokenize mappingcrates/talkbank-transform/src/retokenize.rs::build_word_token_mappingNoneDeterministic span-join + length-aware monotonic fallback
WER evaluationcrates/talkbank-transform/src/benchmark.rs (uses dp_align::align)Hirschberg edit-distance DPCanonical use of DP for transcript comparison
Compare commandcrates/talkbank-transform/src/compare/engine.rs (uses dp_align::align)Hirschberg edit-distance DPAligns 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 Alignable trait. Both String (word-level) and char (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:

PairJWMatch at 0.85?
“gonna” / “gona”0.95Yes
“went” / “wen”0.94Yes
“yesterday” / “yestarday”0.92Yes
“mhm” / “mmhm”0.83No
“the” / “da”0.00No
“he” / “she”0.61No
“cat” / “dog”0.00No

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.

FlagDefaultControls
--utr-strategyautoauto (currently global) / global / two-pass
--utr-ca-markersenabledUse ⌈⌉⌊⌋ for onset windowing
--utr-density-threshold0.30Max overlap fraction before skipping exclusion
--utr-tight-buffer500Pass-2 tight window ±ms
--utr-fuzzy0.85Jaro-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

  1. Crossing alignments / rapid overlaps. Global monotonic aligners cannot represent crossing matches; one side is dropped or mis-assigned.
  2. Repeated-token ambiguity. Repeated words create many equal-cost paths; deterministic tie-breaks may pick semantically wrong matches.
  3. ASR drift and hallucinations. Large payload/reference divergence causes sparse matches and low timing coverage.
  4. Tokenization or normalization mismatch. Char-level DP may align surprising spans when punctuation/case/tokenization differ.
  5. 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 sitePurpose
crates/talkbank-transform/src/benchmark.rsWER evaluation
crates/talkbank-transform/src/compare/engine.rsTranscript comparison (window alignment + rotation, 2 calls)
crates/batchalign/src/chat_ops/fa/utr.rsUTR global timing recovery
crates/batchalign/src/chat_ops/fa/utr/two_pass.rsUTR 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 PreparedWorkers backend 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:

LayerWhereWhat
required-features gatecrates/batchalign/Cargo.tomlML binary excluded from a plain cargo test; opt in with --features ml-golden --test ml_golden
--test-threads=1the ML entry point (make batchalign-test-ml-golden)ML binary serialized when opted in
Claude Code guard hookoperator’s ~/.claude/settings.json deny list (workspace-level, not tracked in this repo)Blocks test commands when worker processes detected
Global worker capWorkerPool (max_total_workers)Hard ceiling on total workers across all keys
WorkerPool::Droppool/mod.rsKills idle workers when pool dropped without shutdown()
PID file reaperpool/reaper.rsScans ~/.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):

  1. A runner setup hook: some harness starts a daemon
  2. Test-managed daemon: file-lock coordination in common/mod.rs
  3. 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:

  1. The Rust CLI submits the job to a single server or local daemon.
  2. The Rust server groups utterances into FA windows per file.
  3. The server checks the utterance cache per group.
  4. Cache misses are sent to Python workers through the worker pool.
  5. 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:

  1. UTR enabled (default). UTR populates utterance bullets from the ASR pre-pass. Proportional estimation usually doesn’t fire.
  2. UTR disabled (--no-utr). Proportional estimation kicks in during group_utterances() when total_audio_ms is available. ~96% coverage on test corpora.
  3. UTR partial success. Individual utterances that UTR could not match (the unmatched count in UtrResult) fall through to proportional estimation.
  4. 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:

  1. First pass: count total alignable words across all utterances (timed and untimed). If total_audio_ms is None, skip untimed utterances as before.
  2. Second pass: for untimed utterances when total_audio_ms is Some, 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

LayerCoverage
Rust unit (fa/grouping.rs)Untimed grouped with proportional estimates when total_audio_ms is provided
Rust unitUntimed still skipped when total_audio_ms is None (backward compat)
Rust unitMixed timed/untimed grouped correctly
Rust unitBuffer clamped to [0, total_audio_ms]
Python integrationadd_forced_alignment with untimed CHAT + total_audio_ms produces timing
Python integrationadd_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 content
  • BracketedItem::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 %wor tier 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:

  1. Walk each utterance’s content (including inside groups).
  2. Extract OtherSpokenEvent nodes, recording speaker + text.
  3. Remove them from the host utterance.
  4. For each extracted event, create a new Utterance with +< linker and words split from the underscore-joined text.
  5. Insert after the host utterance.

Edge cases handled

  • Multiple &* in one utterance (each becomes its own +< utterance)
  • Multi-word &* with underscores (oh_okay_yeahoh okay yeah)
  • &* inside groups (<... &*INV:mhm ...> [//])
  • Reverse direction (&*PAR:yeah on INV’s line)
  • Host utterances with and without timing bullets
  • Host dependent tiers preserved (they were already &*-invisible)

Corpus Statistics

&* (OtherSpokenEvent)

CorpusFilesTotal markersSingle-word %
ca-data25612,01696%
aphasia-data64410,16188%
rhd-data1905,16083%
psychosis-data2362,79998%
tbi-data1352,10590%
dementia-data3901,68089%
slabank-data191774,
childes-data146411,
Total~35,00091%

Top words: mhm (~12,500), yeah (~5,500), okay (~3,300), mm (~1,400).

+< (LazyOverlapPrecedes)

CorpusFiles+< utterances
childes-data10,596194,720
phon-data61450,892
biling-data24837,727
aphasia-data1,24115,720
tbi-data2517,469
ca-data2426,606
dementia-data1,5364,745
Total~327,000

Of these, ~131,000 (40%) already have timing bullets.

File Locations

FilePurpose
crates/batchalign/src/chat_ops/fa/utr.rsUtrStrategy trait, GlobalUtr, select_strategy, run_global_utr
crates/batchalign/src/chat_ops/fa/utr/two_pass.rsTwoPassOverlapUtr, recover_overlap_timing
crates/batchalign/src/chat_ops/fa/tests/Integration tests (snapshots + per-feature modules)
crates/batchalign/src/runner/dispatch/utr.rsresolve_strategy, UtrPassContext.strategy
crates/batchalign/src/types/options.rsUtrOverlapStrategy 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}/traces REST 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_with for 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 typeSource orchestratorWhat it captures
DpAlignmentTracedp_align.rsFull cost matrix, traceback path, alignment result
AsrPipelineTracetranscribe.rs7-stage ASR post-processing intermediates
FaTimelineTracefa.rsGroup boundaries, pre/post timings, violations
RetokenizationTracemorphosyntax.rsWord↔token mapping per utterance

Frontend

Visualizations

VisualizationRoute (static)Route (live)Status
DP Alignment Explorer/dashboard/visualizations/dp-alignment/dashboard/jobs/:id/traces/dp-alignmentComplete
Retokenization Mapper/dashboard/visualizations/retokenize/dashboard/jobs/:id/traces/retokenizeStatic 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 fileRust sourceWhat it ports
dpAlignment.tscrates/batchalign-transform/src/dp_align/align_small with step-by-step emission
retokenize.tscrates/batchalign-transform/src/retokenize.rsWord↔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/:

ComponentPurpose
CostGridSVG grid for DP cost matrix with fill/traceback animation
StepControlsPlay/pause/step/skip controls for stepping through algorithm
ModeToggleStatic ↔ Live mode indicator
SpanRulerHorizontal span bar for retokenization mapping

Trace Collection Points

OrchestratorWhat to captureWhere in code
fa.rsGroup boundaries, pre/post timings, violationsAfter parse_fa_response() and apply_fa_results()
morphosyntax.rsRetokenization mappings per utteranceReturn value of inject_results()
transcribe.rsASR pipeline intermediate statesWrap process_raw_asr() stages, not yet wired
dp_align.rsCost matrix + tracebackOptional 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; CostGrid visualization with fill / traceback animation; live mode via useTraceQuery hook against the server’s /jobs/{id}/traces endpoint.
  • 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; DiffView component for stage-by-stage transforms; trace collection in transcribe.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: a ReleasedCommand and fields keyed by the closed StampField enum, each holding a StampFieldValue
  • inject_provenance(&mut ChatFile, &ProvenanceComment): AST-level injection that adds/replaces @Comment headers
  • inject_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() and result_named_provenance() (one builder for translate and coref, chosen by ResultNamedCommand) 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), and incremental_morphotag_provenance() reads the --before document’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 a FileProvenance state
  • is_provenance_only_difference(old, new, command): decides whether a re-run of a command job would change nothing meaningful, so the writer (write_chat_output_artifact_with_provenance_gate in recipe_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 StampField enum (asr, asr_model, diarize, engine, fa, incremental, lang, retokenize, utr, wor). Its Ord compares 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 over StampSafeText (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 Unicode White_Space characters, StampSafeText::WHITESPACE), and |, ;, ], newline and carriage return (StampSafeText::STAMP_STRUCTURE); the error is InvalidStampSafeText. ReportedEngineName wraps the same type, so a reported engine name becomes a field value with no second check.
  • StampSafeText has three routes in, and each keeps the invariant: TryFrom (also used by deserialization) checks runtime text; const fn from_static checks a literal at compile time (callers write it inside const { }); and join(first, rest, StampJoiner) composes values that are already safe with a safe joiner (Concat, Plus for +, Colon for :, At for @). Every conversion into StampFieldValue is 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_model is admitted by AsrBackend::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 what asr_model= renders: the models a run actually loaded are, and they are stamp-safe by construction because every id and revision was admitted as StampSafeText when the plan pinned it.
  • The JSON Schemas of StampSafeText and ReportedEngineName carry a pattern generated from the same two character lists (StampSafeText::json_schema_pattern). The Python producer, reported_engine_name in batchalign/worker/_types.py, uses an explicit White_Space set rather than Python’s own idea of whitespace. Rust’s stamp_safe_text_tests and batchalign/tests/test_stamp_safe_text_conformance.py both read tests/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 UnparseableStamp naming the comment and its StampDefect (no closing ], missing command or timestamp, a field that is not key=value, a repeated key). extract_provenance returns it instead of silently leaving the stamp out. The job results endpoints (GET /jobs/{id}/results and GET /jobs/{id}/results/{filename}) report each file’s provenance as a typed FileProvenance: parsed with its entries, unparseable with the reason, or not_read for 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.

StepFunctionAnswers
OneRecognizedComment::classify(body)Whether the comment is ours and, for a stamp, WHICH command wrote it, from the opening and the command alone
TwoNamedStamp::parse()The fields and the timestamp of a stamp whose command step one has already read

Step one has four answers, not two:

AnswerMeaning
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
ForeignWritten 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:

OperationNames
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.

StageStamps it contributes
BuildChattranscribe
UtteranceSegmentationutseg
Morphosyntaxmorphotag
ForcedAlignmentalign
RunTranscribeRecipeevery stamp of the transcribe recipe
RunCompareRecipeevery stamp of the compare recipe
every other stagenone

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-ba3 or ba3) 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:

  1. Any existing @Comment holding a stamp for the same command, under either name, is removed from ChatFile.lines
  2. A new Line::Header { Header::Comment { BulletContent } } is inserted after the last constant participant header (@ID, @Birth of, @Birthplace of, or @L1 of)
  3. 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:

CommandFileInjection site
morphotagpipeline/morphosyntax/states.rsAnalysis::<Applied>::postcheck(): before the post-validation gate
morphotag (incremental, --before)morphosyntax/mod.rsprocess_morphosyntax_incremental(): before its gate, only on the path where utterances were reanalyzed
utsegpipeline/text_infer.rsrun_text_batch_pipeline() per file, and run_text_pipeline() for the per-file pipeline transcribe uses: after apply(), before the gate
translatepipeline/text_infer.rsSame as utseg (shared generic pipeline)
corefcoref.rsrun_coref_batch_impl(): per file, after the annotations are applied and before that file’s gate
alignrunner/dispatch/fa_pipeline.rsAlignAudioTask::finalize_success(): PostValidated::with_provenance_injected() on the gated proof
transcribepipeline/transcribe.rsserialization 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:

CommandFieldSource
morphotagengine=, 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=trueBuilt 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.
utsegengine=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.
translateengine=The engine each translated item named (AdmittedTranslation), distinct names joined with + in text order. No stamp when nothing was translated.
corefengine=The engine the resolved result named (ResolvedCoref). No stamp when nothing was resolved.
alignfa=, 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).
transcribeasr=, 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:

  1. inject_provenance() scans all Line::Header entries
  2. Any Header::Comment holding a stamp for that command, under either stamp name, is removed
  3. 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):

ShapeText
Currentfc-ba3 <build>, ASR engine <engine>. Unchecked output of ASR model, DO NOT USE.
LegacyBatchalign <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:

  1. The worker enters a deterministic failure that masquerades as a wait (catalog missing, model not loaded, network unreachable).
  2. The orchestrator treats the resulting worker exit as transient and retries.
  3. Each retry dumps a multi-line stack trace to a log file with no user-visible signal.
  4. 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:

  1. What is happening (“Downloading openai/whisper-large-v3 for ASR”).
  2. How big (approximate; ~3 GB is fine).
  3. That it’s a one-time cost (“future runs will use the local cache” or “future runs will be instant”).
  4. 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 ResourcesFileNotFoundError and returned None, 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 tqdm prints to terminal stderr, which reaches the CLI but not the TUI, web dashboard, or desktop app. The progress_v2 channel 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_missing does 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

  1. Identify the slow site. Anywhere the worker (or the runner) blocks for > 1 s.
  2. Choose a stage identifier. Short, snake-case, unambiguous. Examples: downloading_stanza_catalog, loading_whisper_large, calling_rev_ai_asr, decoding_audio.
  3. Choose user wording. Use the _progress.py templates. Convey the four things above. Be specific.
  4. Pair start with completion when the operation is recoverable, e.g., a download has both a “downloading…” event and a “ready” event.
  5. Verify each UI surface renders the event. CLI: run the command and see the line. TUI: run with --tui and see the dashboard label. Web: poll the dashboard JSON. Tauri: check the desktop app’s status panel.
  6. Add a regression test. A unit test that mocks the slow path and asserts at least one progress_v2 event was emitted with the right stage.

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:

  1. 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.
  2. 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.log spam 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 into WorkerError::Bootstrap; classifier returns FailureCategory::WorkerBootstrap; is_retryable_worker_failure returns false.
  • 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:

  1. One decode path, not two. Adding a sibling op ({"op": "bootstrap_error", ...}) would double the number of match arms at every consumer of WorkerResponse, plus add a serializer surface. A discriminator field is cheaper.
  2. 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::ProcessExitedFailureCategory::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:

FunctionTransportMode
_serve_stdiostdiosequential
_serve_stdio_concurrentstdioconcurrent (thread pool)
_handle_tcp_connection_sequentialTCPsequential
_handle_tcp_connection_concurrentTCPconcurrent (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:

ConcernFile
WorkerError enumcrates/batchalign/src/worker/error.rs
Wire WorkerResponse decodercrates/batchalign/src/worker/handle/protocol.rs
TCP variant decodercrates/batchalign/src/worker/tcp_handle.rs
FailureCategory enumcrates/batchalign/src/types/scheduling.rs
Classifier (classify_worker_error, is_retryable_worker_failure)crates/batchalign/src/runner/util/error_classification.rs
Retry loopcrates/batchalign/src/infer_retry.rs
User-facing message routercrates/batchalign/src/runner/util/error_classification.rs::user_facing_error

WorkerError taxonomy

Each variant has a documented retryability class.

VariantFires whenRetryable?
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 timeRetryable, transient stall
ReadyParseFailed(String)Worker emitted invalid ready signalTerminal: version mismatch
HealthCheckFailed(String)Periodic health probe failedRetryable, pool replaces worker
ProcessExited { code, stderr }Worker died unexpectedly mid-jobRetryable, but if deterministic, replacement will die too
Protocol(String)IPC framing or response shape was wrongTerminal-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 todayTerminal

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:

  1. 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 tracing for developer debugging.
  2. 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:

  1. Define the typed error in Python. Inherit from a sensible base (RuntimeError for general bootstrap failures, ValueError/UnsupportedLanguageError for input-driven ones). Document that it is bootstrap-class in the docstring, the type itself is the contract.

  2. Register it in the classifier. Add a lazy import + append to the bootstrap_types list in batchalign/worker/_protocol.py:_classify_dispatch_exception.

  3. 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.

  4. Add a test. Two patterns, both already in the codebase:

    • batchalign/tests/test_serve_stdio_bootstrap_error.py: assert the new exception type classifies as bootstrap.
    • A handler-level test that mocks the underlying failure and asserts the worker emits the right wire envelope.
  5. 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 existing WorkerError::Bootstrap variant 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):

  1. Add the variant to WorkerError in worker/error.rs with a doc comment explaining when it fires and its retryability class.
  2. Add a matching FailureCategory variant in types/scheduling.rs (and update the Display/FromStr impls).
  3. Update classify_worker_error to map the new WorkerError variant to the new category.
  4. Update user_facing_error to render an actionable message for the new category.
  5. Update is_retryable_worker_failure if the new category is retryable.
  6. Update the test in runner/util/mod.rs::worker_error_classification_is_stable to lock in the classification.
  7. 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


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_lenient at ../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

LocationModeWhy
Rust server (per-file dispatch)LenientInput CHAT may have broken dep tiers
Rust server (post-injection re-parse)StrictEngine output should be valid; catch bugs early
Rust server (comment insertion)StrictPipeline output should be valid

Per-command tier handling

CommandReads Dep TiersWrites Dep TiersNotes
morphotagNone (clears %mor/%gra first)%mor, %graclear_morphosyntax() strips existing tiers before processing
align%wor (for UTR)%worRegenerates timing from scratch
translateNone%xtraAdds translation tier
utsegNone(restructures utterances)Splits/merges utterance boundaries
transcribeN/A (generates from audio)AllCreates fresh CHAT
opensmileN/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 / %gra from 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.

  • CHAT manual: https://talkbank.org/0info/manuals/CHAT.html (search for “Options” in the headers reference).
  • crates/batchalign/src/pipeline/morphosyntax.rs, where the submitted CaMorphotagPolicy and parsed header produce one typed per-file disposition consumed by all downstream stages.
  • crates/batchalign/src/fa/mod.rs: the align-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 the align command, 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.

FilePurpose
parse.rsparse_lenient(): top-level CHAT parsing entry point
extract.rsExtractedWord struct + word extraction from AST for morphosyntax input
inject.rsinject_morphosyntax(): primary AST injection of %mor / %gra tiers
morphosyntax/injection.rsinject_results(): orchestration helper called by the batch pipeline
morphosyntax/payload.rsclear_morphosyntax(), collect_payloads(), dispatch_secondary_l2() host adapter
morphosyntax/sentence_mapping.rsmap_ud_sentence(), map_ud_sentence_expanded(), shared build_gra_and_validate()
morphosyntax/gra_validate.rsvalidate_generated_gra(): single-root, cycle-free, valid-heads checks
morphosyntax/mapping_helpers.rsassemble_mors() (clitic merge), is_clitic(), map_relation()
morphosyntax/stanza_raw.rsParse raw Stanza JSON output, supply defaults for Range token annotation fields
morphosyntax/pos_hints.rsapply_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.rsEnglish-specific rules (irregular verbs, irrealis annotations)
morphosyntax/lang_fr.rsFrench-specific rules (pronoun case, APM)
morphosyntax/lang_ja.rsJapanese-specific rules (verb form overrides)
morphosyntax/lang_it.rsItalian-specific rules
retokenize/, retokenize.rsAST 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:

FilePurpose
morphosyntax/mod.rsrun_morphosyntax_impl(): top-level orchestrator called from the morphotag command
morphosyntax/batch.rsdispatch_secondary_l2(): async wrapper that calls into the transform-layer L2 seam for secondary @s dispatch
morphosyntax/worker.rsStanza-pool dispatch, partition_groups_by_stanza_support()
chat_ops/nlp/mapping/mod.rsRe-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.rsFA-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)

FilePurpose
inference/morphosyntax.pyCalls Stanza nlp(), returns raw to_dict() output
worker/_infer_hosts.pyWorker-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:

ConsumerWhat it accessesDecomposes POS/lemma/features?
Cache (engine.py)Final %mor/%gra strings (BLAKE3 key)No, stores/retrieves whole strings
Coreference (coref)Token boundaries in %mor tierNo, counts tokens only
WER evaluation (benchmark)Token count from %mor for word-level accuracyNo, 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 alignmentNo %mor accessN/A
ASR / diarizationNo %mor accessN/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) vs cat-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”

StructureLegacy MOR grammarStanza UDModel representation
POS subcategoriespro:sub|Ipron|IPOS string, subcategories preserved if present (parser accepts pro:sub)
Compoundsadj|+adj|big+n|birdNot producedParsed by grammar if encountered; no typed compound field
Prefixestrans#n|portNot producedParsed by grammar if encountered; stored in stem
Morpheme segmentationgo&PAST vs go-PASTNot producedBoth parsed; suffix carries separator character
UD feature keysN/ANumber=PlurMorFeature has optional key field, preserved if present
xpos (language-specific POS)N/AAvailable in StanzaDiscarded, 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=Plur are semantically meaningful and machine-readable. CLAN suffixes like -PL required 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&lt;Mor&gt;, Vec&lt;GrammaticalRelation&gt;)"]

    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:

  1. 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
    
  2. 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
    
  3. Adds terminator PUNCT relation pointing to ROOT.

TalkBank Conventions

The mapper applies four CHAT-specific transformations, all lossless:

UD ConventionTalkBank ConventionExample
head=0 for roothead=0 (same, UD standard)2|0|ROOT
Subtypes with colonSubtypes with dashacl:relclACL-RELCL
Lowercase relationsUppercase relationsnsubjNSUBJ
Multi-value features with commaCommas preservedPronType=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:

UPOSCHAT POSSuffix features
NOUNnoun|Gender, Number, Case, Ger
VERB/AUXverb|/aux|VerbForm, Tense, Person, -irr
PRONpron|PronType, Case, Reflex, Number, Person
DETdet|Gender, Definite, PronType
ADJadj|Degree, Case
ADPadp|(none)
PROPNpropn|(none)
INTJintj|(none)
CCONJcconj|(none)
SCONJsconj|(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:

DomainRetracesReplacementsUntranscribed (xxx/yyy/www)
MorSkippedUse replacement wordsSkipped (case-insensitive)
WorIncludedUse original wordsIncluded

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 as extract.rs and assigns Mor items to alignable Word nodes.
  • crates/batchalign-transform/src/morphosyntax/injection.rs::inject_results: the helper called by the batched orchestrator after map_ud_sentence returns.

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 tier
  • parse_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:

  1. 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.
  2. Character-level DP alignment between original and Stanza token texts
  3. Build mapping: original_word_idx → stanza_token_indices
  4. Walk AST, rebuilding content vectors (1:1, 1:N splits, preserving non-word content)
  5. New Word values created by calling the fragment parser in talkbank-parser (not Word::new, which would bypass parser validation for Stanza-supplied text that may carry CHAT-significant characters)
  6. 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):

KindTriggerApplied
pad_relationa padding label, <PAD> or <UNK>dep
relation_casea UD relation in the wrong case, NSUBJlowercased, subtype kept
relation_aliasa known non-UD spelling, iobthe UD relation it means, iobj
unknown_relationno UD relation and no known aliasdep

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. RepairedSentence can only be built from raw Stanza words, and _analysis takes 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 returned None and mutated its argument, which left no proof in any signature that it had run; the production path did not call it for months while PAD and IOB flowed into published corpora.
  • A repair cannot be a no-op or invalid. RelationRepair refuses 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:

  1. Validate each payload item (MorphosyntaxBatchItem); one that does not validate becomes that item’s error and no other item is affected
  2. An utterance with no words becomes no_words without reaching Stanza
  3. Group the rest by each item’s own language, so a code-switched utterance reaches the model for its language
  4. 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
  5. 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
  6. 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
  7. 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

  1. Primary pass produces %mor/%gra for the entire utterance. @s words get L2|xxx placeholders via the special form handler in inject.rs.
  2. Extract deferred positions identifies which words have L2|xxx and their target languages (from @s:spa, @s:eng, or bare @s resolved via @Languages).
  3. 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).
  4. 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.
  5. 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.
  6. Splice replaces L2|xxx with 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-@s patterns are rejected during pre-validation (E255). The accepted CHAT form is utterance-level [- lang].
  • Explicit @s:LANG still routes to LANG even if LANG is absent from @Languages, but validation emits warn-only E254 to surface the header drift.
  • chatter debug fix-s is the intended normalization tool for both cases: it rewrites the qualifying whole-utterance @s pattern, 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_batch partitions language groups via partition_groups_by_stanza_support; unsupported groups bypass Stanza dispatch and the words receive L2|xxx in %mor.
  • @s:UNSUPPORTEDLANG per-word markers, the secondary dispatch path for that span is short-circuited the same way; the host primary analysis is preserved and the @s token’s slot stays as L2|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

FilePurpose
morphosyntax/l2/plan.rsContiguous span planning and host-attachment planning
morphosyntax/l2/extract.rsExtract primary structural info from UD responses
morphosyntax/l2/spans.rsGroup @s positions into contiguous dispatch spans
morphosyntax/l2/merge.rsPOS resolution priority, planned structural merge
morphosyntax/l2/splice.rsReplace L2|xxx in ChatFile with merged MOR
morphosyntax/l2/deprel.rsUdDeprel newtype, deprel→POS constraint mapping
morphosyntax/batch.rsdispatch_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:engpron|it~aux|be (clitic MOR, not L2|xxx)
  • don't@s:engaux|do~part|not (clitic MOR)
  • working@s:engnoun|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:

  1. head=0 for the ROOT relation
  2. head=self for the ROOT relation

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, and ACL-RELCL.
  • Relation subtypes use dashes rather than UD colons, such as ACL-RELCL and NMOD-POSS.
  • %gra and %mor remain item-aligned: each %mor item has a corresponding %gra item.
  • The utterance terminator gets its own PUNCT relation 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): no ROOT relation
  • E723 (GraMultipleRoots): multiple ROOT relations
  • E724 (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:

  1. sequential indices
  2. exactly one non-terminator root (head=0 or head=self)
  3. no dependency cycles
  4. 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:

  1. mapping IDs explicitly rather than relying on brittle array-position repair
  2. rejecting invalid or unmappable structures before writeback
  3. 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:

  1. 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 local media_mappings, or via an explicit --media-dir. See Media Conversion.
  2. Media conversion: If the audio is in a container format that soundfile cannot 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 runs align through DirectHost. No HTTP server or daemon is spawned for that path.
  • With --server, align submits a shared-filesystem paths_mode job. 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, returns UtrResult
  • run_fa_from_ast(ChatFile, ...): accepts AST directly, returns FaResult
  • process_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:

  1. UTR (Utterance Timing Recovery): Assigns utterance-level timing boundaries.
  2. 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:

  1. verifies that every alignable main-tier word has a clean main↔%wor positional mapping,
  2. verifies that every mapped %wor word has a timing bullet,
  3. copies those timings back onto main-tier words,
  4. removes parsed InternalBullet tokens, and
  5. 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:

  1. Checks the ASR cache for a prior result (key includes audio identity + lang). On hit, skips inference entirely, repeat runs are instant.

  2. 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 via extract_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.
  3. Converts ASR response tokens to AsrTimingToken (text + start_ms + end_ms).

  4. 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-pass strategy (default 0.85; 1.0 for exact-only matching). It does not change the default global strategy.

  5. 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+&lt; 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 +&lt; utterances\nin global DP\n(too many to exclude)"]
    density -->|"No (≤30%)"| pass1["Pass 1: Global DP\nexcluding +&lt; 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):

StrategyWhen selectedBehavior
GlobalUtrNo +< or markers in fileSingle monotonic DP over all words (original algorithm)
TwoPassOverlapUtrAny 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:

FlagDefaultEffect
--utr-strategy auto|global|two-passautoOverride automatic strategy selection
--utr-ca-markers enabled|disabledenabledWhether Pass 2 uses CA markers for window narrowing
--utr-density-threshold <0.0-1.0>0.30Overlap fraction above which two-pass falls back to global
--utr-tight-buffer <ms>500Buffer around previous utterance for Pass 2 recovery window
--utr-fuzzy <threshold>0.85Two-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):

  1. 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.
  2. Then, additionally under --end-overlap-policy clamp-all-adjacent only (the default is preserve-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:

ConstraintLimitRationale
Time window (max_group_ms)Per engine: the max_group field of the selected engine’s row in FA_ENGINESCaps 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 bytesWhisper’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 &lt; ~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():

  1. fa_group_retry() is checked first, Wave2Vec CTC patterns still trigger the Whisper retry (which may produce timings). is_fa_runtime_failure is only reached when the fallback logic has already decided no retry is possible.
  2. is_whisper_model_unavailable() is checked before is_fa_runtime_failure in 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

ConditionScopeOutcomeLog
FA cache hitGroupReuse cached timings silently,
Audio extraction produces no framesGroupLeave words unaligned; continueWARN: group decoded no audio samples
Wave2Vec CTC target overflow (3 patterns)GroupRetry on the row’s fallback engine; record fallback traceWARN: retrying group on its fallback engine
Whisper retry succeedsGroupGroup timings resolved,
Fallback retry: ModelUnavailable (worker has no such model)GroupLeave words unaligned; continueWARN: fallback FA engine unavailable … leaving group words unaligned
Worker RuntimeFailure (any model exception: token overflow, shape error, OOM, etc.)GroupLeave words unaligned; continue, is_fa_runtime_failure() demotes to group-levelWARN: FA group failed with model RuntimeFailure
Whisper fallback also hits RuntimeFailureGroupLeave words unaligned; continueWARN: Whisper FA fallback also failed with model RuntimeFailure
Other worker error (retryable)FileRetry with backoff; fallback UTR if untimedWARN: FA error (raw)
Retry budget exhaustedFileTerminal 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:

DecisionCauseNeeds review?Action needed?
end_clamped_coverage_onlyOnly the bullet’s inherited coverage overshot the next utterance’s start; no measured word conflictedNoInformational; automatic
end_clamped_boundary_from_wordsBoth bullets’ inherited boundary replaced by their measured word hulls; the words never conflictedNoInformational; automatic
end_clamped_interleaved_wordsThe words themselves interleave, or the next utterance has none: a genuine conflictYesAdjudicate; the bullet and every affected word were clamped together
start_strippedUtterance start precedes previous accepted start, full timing removedYesReview 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 to end_clamped_interleaved_words instead, 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 same clamped_to_bullet route 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 returns None for, 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:

  1. A previous FA run already aligned this utterance correctly, and
  2. 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:

  1. bullet.source == BulletSource::Authoritative: not a runtime UTR hint, AND
  2. utterance.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 transcribealign 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 %wor already provides complete reusable word timing
  • per-utterance partial %wor reuse 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])
  1. 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.
  2. UTR full-file (default, mostly-untimed files): ASR runs on the full audio. The result is cached for instant repeat runs.
  3. 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.
  4. 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.
  5. Skip: When total_audio_ms is 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

EngineModelResponse formatLanguagesDefault?
wav2vecMMS_FA CTC forced alignmentWordLevel (word text + start/end ms)anyYes
whisperWhisper large-v2 cross-attention DTWTokenLevel (token text + onset seconds)anyNo
cantoneseMMS_FA CTC forced alignment over jyutpingWordLevelany (romanizes only for yue)No
qwen3_faQwen/Qwen3-ForcedAligner-0.6B-hfWordLevelyue, zho, cmn, engNo

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. FaEngineSpec requires every field and has no Default, so nothing can be left blank.
  • A new VARIANT is a compile error in the pairing list. The spec() lookup and FA_ENGINES are both generated by the fa_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 const block 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 WordTiming vector 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?

ChangeUTR cacheFA cache
Edit transcript textStays cached (audio unchanged)Groups with changed words re-run
Re-record audioRe-runs (audio identity changed)Re-runs (audio identity changed)
Change --fa-engineMay miss when the worker-version partition changesMisses (engine is part of FA key)
Change --langRe-runs (lang is part of UTR key)Re-runs (lang is part of FA key)
Change --utr-engineMisses: ASR provider is part of the keyReuse depends on resulting windows and words
Second run, nothing changedCan reuse matching retained entriesCan 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:

  1. 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.
  2. Optional trace capture: --debug-dir PATH enables debug_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 sourceMessage patternMeaning and response
fa/transport.rsFA group decoded no audio samplesExtraction returned zero frames. Inspect the window, duration and source timing; the group remains unaligned.
fa/transport.rsWorker process exited during FA groupThis request lost its worker. The exit alone does not prove OOM or bad input; review process evidence and the unaligned group.
fa/transport.rsFA engine hit a recoverable target constraint; retrying group on its fallback engineA 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.rsfallback FA engine unavailable … leaving group words unalignedThe retry worker lacks the loaded model named by retry_engine; review the group’s missing timing.
fa/transport.rsFA group failed with model RuntimeFailure / fallback FA engine also failed with model RuntimeFailureThe 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.rsFA cache batch lookup failedA cache read failed. Inspect the attached error before changing storage; inference may continue through cache misses.
runner/dispatch/fa_pipeline.rsFA failed with untimed utterances; attempting fallback UTRThe pipeline attempts fallback UTR before retrying FA.
runner/dispatch/fa_pipeline.rsFallback UTR recovered timingFallback 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.

AspectBA2 (Jan 2026)BA3 (current)
Default FA engineWhisper (cross-attention DTW)Wave2Vec (word-level start+end)
Wave2Vec availableYes, via --engine wave2vec_faYes, via --fa-engine wav2vec
Both models loaded simultaneouslyNo, one at a time by designNo, one at a time, same reason
Wave2Vec CTC overflow → fallbackNone: file failedRetry that group with Whisper
CTC overflow handlingUser had to rerun with --engine whisper_faAutomatic per-group retry
Silent file drop on CTC overflowNo, file errored visiblyNo, affected groups leave words unaligned; file completes
Fallback telemetryNoneFaFallbackEventTrace in job traces
FA result cachingPer-file Python shelvePer-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:

enginereportsword duration
Wave2Vecword start AND endmeasured by the engine
Whisper FAtoken ONSETS onlyderived 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 align without 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:

  1. 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.

  2. 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_ms is 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-None timings. 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

FieldTypeMeaning
provenanceobjectHow this file was made. See below.
utterancesarrayOne entry per utterance of the scored tiers, in transcript order.
tracksarrayOne entry per speaker code among those utterances, scored as ONE voice. See below.
track_contrastsarrayOne 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.

FieldTypeMeaning
schema_versionintegerSee above.
interpretationstringThe standing caveat that scores are agreement, not accuracy. Present because the file is what gets forwarded; the documentation is not.
transcriptstringThe transcript this run read, as named.
mediastringThe recording resolved beside it.
prepared_sample_rate_hzintegerSample rate of the single decode every span indexed into.
embedding_backendstringpyannote.
embedding_model_revisionstringpyannote-embedding:<40-hex Hub commit>, read from the packaged model manifest.
embedding_dimensionintegerWidth of every vector compared, as the worker reported it.
embedding_minimum_framesintegerThe model’s own minimum span length, as the worker reported it.
match_thresholdnumberThe 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.
tiersarray of stringTiers scored. ["*"] means every tier.
enrollmentsarrayEach { label, start_ms, end_ms }, in recording order.
permutationobject{ seed, count } behind every track_contrasts p-value, so each is reproducible byte for byte.
produced_bystringBuild 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[]

FieldTypeMeaning
utterance_indexintegerZero-based, counting utterances of the scored tiers.
lineintegerOne-based line of the main tier in the transcript.
speakerstringThe speaker code the transcript currently carries.
start_ms, end_msintegerThe bullet, absent entirely when the utterance has none.
scoresarray{ label, score } for every enrolled voice. Empty for an unscored utterance.
verdictobjectTagged 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 }
  • matches carries exactly one label. A match with two labels has no representation, which is how a tie is prevented from resolving to a speaker.
  • no_match’s best.labels normally holds one label. It holds more when the evidence ties, including when the tied score clears the threshold.
  • unscored’s reason is flattened alongside verdict, with its own fields. The five reasons are too_short_for_embedding (with frames, minimum_frames), no_bullet, no_comparable_embedding, audio_missing (with start_ms, end_ms, recording_ms) and overlaps_enrollment (with label). no_comparable_embedding means 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. centroid is the unit-normalized mean of the track’s unit line vectors, embedding_dimension wide, written so a later cross-session question needs no re-run. scores is the centroid’s cosine to every enrolled voice, the same shape as a line’s. lines_refused counts 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 no scores field 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_margin is 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, and at_or_above counts the shuffles whose margin reached the observed one. p_value is (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 about 0.001 rather 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:

  • line and utterance_index locate 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:

  1. Transcription (ASR) – Converting audio to text via the transcribe command
  2. Forced Alignment (FA) – Using Whisper’s encoder cross-attention for word-level timestamp alignment via the align command
  3. Utterance Timing Recovery (UTR) – Re-transcribing audio to improve forced alignment quality, automatically added by the align command

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 from crates/batchalign/src/revai/)
  • Supports speaker diarization natively
  • Requires an API key (batchalign3 setup or ~/.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 spelling whisper-oai) names the OpenAI Whisper API; whisperx names 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) or whisper_rs (Rust-native, in-process) instead. The default remains --asr-engine rev when 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() in inference/asr.py (returns WhisperASRHandle)
  • Uses language-specific model resolution (see below)
  • Supports bfloat16 (CUDA) with float16 fallback
  • Chunk length 25s with 3s stride for long files
  • Device selection: CUDA > CPU (MPS is intentionally excluded; see developer/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-backend Cargo feature at compile time; it is NOT built by default because whisper.cpp is a C/C++ build. Selecting whisper_rs in a build without the feature returns a clear “native Whisper path is not available in this build” error.
  • Model. Point BATCHALIGN_WHISPER_RS_MODEL at a ggml .bin model (for example from ggerganov/whisper.cpp). One model per process: the loaded WhisperContext is cached process-wide, so changing models needs a restart (a second model path returns ModelPathChanged rather than reloading).
  • Acceleration. macOS builds always enable Metal; CoreML (whisper-rs-coreml, needs a sibling <model>-encoder.mlmodelc bundle) 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 auto returns a validation error. Use Rev.AI (or a resolved language) for auto-detect.
  • Output parity. The chunk output is lowered to the shared AsrResponse domain 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; auto currently 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() (returns WhisperFAHandle)
  • Always uses openai/whisper-large-v2 – no language-specific resolution
  • Loads the full WhisperForConditionalGeneration model with attn_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

  1. Whisper processes an audio chunk with the transcript as forced decoder input
  2. Cross-attention weights are extracted from designated alignment heads
  3. Attention matrix is normalized (mean/std) and median-filtered
  4. Dynamic time warping aligns decoder tokens to audio frames (20ms resolution)
  5. Token-level timestamps are mapped back to words
  6. 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() in batchalign/inference/asr.py:119 and reuses the WhisperASRHandle type.
  • The same stock checkpoint is used for every language: openai/whisper-large-v3, pinned to an exact hub commit in crates/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::revai client 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/:

  1. 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 at crates/batchalign-transform/src/asr_postprocess/compounds.rs:84)
  2. Number-to-words – converts digits to words using language-specific lookup tables (crates/batchalign-transform/data/num2lang.json; 46 languages today) plus Chinese/Japanese via crates/batchalign-transform/src/asr_postprocess/num2chinese.rs
  3. Retokenization into utterances:
    • With utterance engine (English, Chinese, Cantonese): uses a BERT model to predict utterance boundaries
    • Without: splits on punctuation (., ?, !, etc.)
  4. 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):

ComponentModelApprox. 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)

ContextModel IDSize
ASR (--asr-engine whisper, all languages)openai/whisper-large-v3large-v3
ASR (--asr-engine whisper_hub, opt-in fine-tunes)per _RESOLVER["whisper_hub"] or --engine-overrides model_idvaries
FAopenai/whisper-large-v2large-v2
UTR (--utr-engine whisper, all languages)openai/whisper-large-v3, pinned to an exact commitlarge-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-backend is a DEFAULT Cargo feature: every build carries the whisper_rs engine (the non-default gate had silently dropped the engine from rebuilt binaries).
  • Model auto-resolution: BATCHALIGN_WHISPER_RS_MODEL still overrides, but without it the default ggml-large-v3.bin is fetched once from ggerganov/whisper.cpp via hf-hub and cached.
  • Language auto-detect: Auto no 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 only thennal/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_heads cross-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 in batchalign-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’s model(labels=...) applies shift_tokens_right before the decoder, so attention row k is produced from input token k-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 crate batchalign-fa-core (shared without the server stack); FaAssets::load/align is 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_model engine-override extra) and setup --prefetch-whisper-rs complete the ASR-side surface. Remaining for production: the FaInferItem-shaped dispatch behind the FA engine seam (with FaAssets cached 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:

EngineWhat it loadsWhen to use
rev (default)Rev.AI cloud APILanguages where Rev.AI quality is good (English, Spanish, most European).
whisperStock openai/whisper-large-v3 via HF transformersLanguages where stock Whisper handles the acoustic + language combo well.
whisper_rswhisper.cpp in-process (Rust-native)Local transcription with no Python worker.
whisperx, whisper_oaiNothing: not implementedNever. Both are accepted names with no implementation and are refused at submission.
whisper_hubHF community fine-tune by model_idLanguages where both Rev.AI and stock Whisper fail.
tencent, aliyun, funaudioCantonese providersChinese 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_idNotes
mal (Malayalam)thennal/whisper-medium-mlSee “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:

ModelMalayalam-script charsRepetitionUsable
thennal/whisper-medium-ml100.0 %0.01Yes
kavyamanohar/whisper-small-malayalam100.0 %0.04Yes, noisier
openai/whisper-large-v327.5 %0.27No (hallucinates “Thank you for watching.”)
openai/whisper-medium13.4 %0.73No (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 recommends whisper_hub for 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-ml took 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


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:

  1. 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.
  2. Option A costs ~15 lines of code and one doc update. It directly resolves the reported incident.
  3. 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() in crates/batchalign/src/types/request.rs, in the existing Rev.AI block immediately after the try_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).

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


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 &quot;&amp;-&quot;?\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 setCHAT output
&-um &-um I wentnone&-um &-um I went .
I I wentfirst II [/] I went .
&-um I &-um I wentfirst 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&lt;w1 w2 ...&gt; [/] w1 w2 ..."]

    Run --> Len
    Len -->|"N == 1"| Single
    Len -->|"N &gt; 1"| Same
    Same -->|"yes: unigram repeat"| Unigram
    Same -->|"no: multi-word phrase"| Group

Three worked examples:

Input (main-tier words)Run shapeCHAT output
the the dogN=1the [/] the dog
a a a sideN=2, samea [/] a [/] a side
I want I want a cookieN=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

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).

EngineModel
--asr-engine whisper (default)openai/whisper-large-v3 for all languages
--asr-engine whisper_hubper-language HuggingFace fine-tune via _RESOLVER or explicit --engine-overrides model_id
--utr-engine whisperopenai/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 groupMethodExample
Mandarin (zho, cmn)num2chinese (simplified)10000 → 一万
Cantonese (yue), Japanese (jpn)num2chinese (traditional)10000 → 一萬
Table languagesNUM2LANG JSON lookup5 → “five” (eng), “cinco” (spa), “cinq” (fra)
All othersPass-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:

  1. Simplified → Traditional Chinese via ferrous-opencc (embedded OpenCC s2hk conversion tables)
  2. 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:

RTLASCII
؟?
۔.
،,
؛;

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:

LanguageModelSource
Englishtalkbank/CHATUtterance-enTalkBank fine-tuned
Mandarintalkbank/CHATUtterance-zh_CNTalkBank fine-tuned
CantonesePolyU-AngelChanLab/Cantonese-Utterance-SegmentationPolyU

All other languages fall back to punctuation-based splitting (., ?, !, and CHAT-specific terminators like +..., +/.).

See Utterance Segmentation.

Stage 6: Morphosyntax (Stanza + Workarounds)

Stanza is the backbone for POS tagging, lemmatization, and dependency parsing. Language-specific workarounds correct systematic errors:

LanguageWorkaroundsReference
English201-entry irregular-form table, contraction MWT hints, GUM packageNon-English Workarounds §E1-E3
French20-entry pronoun-case lookup, 158 APM noun forms, MWT overrides§F1-F3
JapaneseOrder-dependent verb-form override chain, combined package, comma normalizationJapanese Morphosyntax, §J1-J3
HebrewHebBinyan/HebExistential feature extractionHebrew Morphosyntax
Italian“l’” MWT suppression, “lei” merge§I1-I2
Portuguese“d’água” MWT forcing§P1
DutchPossessive “’s” MWT suppression§D1

Cross-language infrastructure:

FeatureWhatReference
MWT dispatchCapability-driven via should_request_mwt() against the cached Stanza catalog§X1 + Stanza Limitations Defect 5
ISO 639-3 → 639-1 mappingiso3_to_alpha2() + _ISO3_OVERRIDES (Stanza-specific overrides) for codes like yue/cmn/zho → zh-hans, nor → nbLanguage Code Resolution
Number expansionTable-driven via num2lang.json + num2chinese.rs for CJKNumber Expansion

Stage 7: Forced Alignment

EngineLanguagesMethod
wav2vec_faAll (default)MMS FA CTC alignment; reports word start and end
whisper_faAllWhisper large-v2 cross-attention DTW; reports token onsets only
wav2vec_cantoCantonese onlyHanzi→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 聯係.


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:

  1. Succeed: map the code correctly and proceed, or
  2. 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:

  1. Stanza-specific overrides: _ISO3_OVERRIDES in _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-3Stanza keyWhy not the standard alpha-2
    yuezh-hansCantonese routes through Stanza’s Chinese model; zh-hans is the catalog key.
    cmnzh-hansMandarin. Same target.
    zhozh-hansChinese (generic).
    nornbNorwegian. pycountry says no; Stanza ships nb (Bokmål) only.
    msamsMalay. pinned for stability.
  2. pycountry: for every other code with a standard ISO-639-3 ↔ ISO-639-1 counterpart (marmr, swasw, engen, etc.). This must be the fallback rather than a duplicate hardcoded dict, see “Drift hazard.”

  3. 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 marmr). 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 from BATCHALIGN_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:

EngineModel / Backend
--utr-engine whisperopenai/whisper-large-v3 (stock Whisper), pinned to an exact hub commit
--utr-engine revRev.AI cloud API
--utr-engine tencentTencent 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)

LanguageCodeModel
Englishengtalkbank/CHATUtterance-en
Mandarincmn/zhotalkbank/CHATUtterance-zh_CN
CantoneseyuePolyU-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., polpo instead of pl, hakha which doesn’t exist). The current behavior is:

  1. A comprehensive explicit mapping table (~75 entries covering all Rev.AI-supported languages) in revai/preflight.rs
  2. A try_revai_language_hint() function that returns None for unsupported languages (enabling callers to report clear diagnostics)
  3. 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:

EngineWhen error surfacesError messageUser impact
Rev.AIAt job submissionClear error with alternativesNone, user redirected to Whisper/Tencent
WhisperAt inference timeValueError: Unrecognized ISO 639-3 codeClear error, no silent fallback
StanzaAt job submissionClear error listing all 55 supported codesNone, immediate feedback
TencentAt job submissionClear error with alternativesNone, user redirected to Whisper/Rev.AI
AliyunAt job submissionClear error: Cantonese onlyNone, 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() in revai/preflight.rs (~75 entries)
  • Validation approach: try_revai_language_hint(lang) returns None for 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 GenerationConfig has is_multilingual: true but no language list; supported languages are implicit in the tokenizer’s language tokens
  • Our mapping: iso3_to_language_name() in batchalign/inference/asr.py uses pycountry lookup with special cases for yue/cmn
  • Fixed (2026-03-19): Unknown codes now raise ValueError instead of silently falling back to English.
  • Whisper large-v3 nominally supports 99 languages, but quality varies dramatically (English/European languages are much better than others)

Stanza (morphosyntax)

  • Stanza has a stanza.resources module that lists available models; batchalign queries it at worker startup (stanza.resources.common.load_resources_json()) and caches a per-language capability table in batchalign/worker/_stanza_capabilities.py.
  • Our ISO-639-3 → alpha-2 mapping: iso3_to_alpha2() in batchalign/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 EngineModelType is chosen in RUST, at plan time, by model_manifest::tencent_engine_model_type. Every Chinese variety takes 16k_zh_large, decided by WritingSystem::of_language_code (the same Han-script table translation rendering uses); every other language takes 16k_ 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 auto is refused for the same reason.
  • This replaced a Python derivation whose own five-code Chinese list omitted Mandarin, so cmn asked for a 16k_cmn model 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 ValueError if lang != "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-3LanguageRev.AIWhisperStanzaTencentNotes
engEnglishenenglishen,Best quality across all engines
spaSpanishesspanishes,
fraFrenchfrfrenchfr,
deuGermandegermande,
itaItalianititalianit,
porPortugueseptportuguesept,
nldDutchnldutchnl,
jpnJapanesejajapaneseja,MWT disabled
korKoreankokoreanko,MWT disabled
rusRussianrurussianru,
araArabicararabicar,RTL punctuation
turTurkishtrturkishtr,
zhoChinesecmnchinesezh16k_zh_largeMaps to cmn/zh depending on engine
cmnMandarincmnchinesezh16k_zh_largeUTSeg model available
yueCantonese,cantonesezh16k_zh_largeFine-tuned Whisper; Aliyun/CantoneseFA; UTSeg model
hakHakka,hakka,16k_zh_largeRev.AI unsupported; Stanza unsupported
polPolishplpolishpl,
cesCzechcsczechcs,
ronRomanianroromanianro,
hunHungarianhuhungarianhu,
hebHebrewhehebrewhe,Fine-tuned Whisper; RTL punctuation
hinHindihihindihi,
cymWelshcywelshcy,
srpSerbiansrserbiansr,
afrAfrikaansafafrikaansaf,
finFinnishfifinnishfi,
danDanishdadanishda,
sweSwedishsvswedishsv,
norNorwegiannonorwegiannb,

(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:

  1. Check each engine the command will use against its supported language set
  2. Return a structured diagnostic listing which engines support the language and which don’t, with suggested alternatives
  3. Be called at job submission time (Rust server), before any processing
  4. 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:

  1. Stanza mapping: Add to _ISO3_OVERRIDES in batchalign/worker/_stanza_capabilities.py only when the standard alpha-2 mapping does not match Stanza’s catalog key.
  2. 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).
  3. 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_DEFAULTS with 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 to batchalign/models/resolve.py leaves the job refused with WhisperHubHasNoDefaultModel.
  4. Number expansion: Add an entry to crates/batchalign-transform/data/num2lang.json, or handle the language in crates/batchalign-transform/src/asr_postprocess/num2text.rs.
  5. 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.
  6. Morphosyntax workarounds: Add a crates/batchalign-transform/src/morphosyntax/lang_XX.rs file if Stanza produces systematic errors for this language (see existing lang_en.rs, lang_fr.rs, lang_it.rs, lang_ja.rs).
  7. MWT dispatch: No code change required; capability-driven should_request_mwt() picks up Stanza’s catalog automatically.
  8. 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:

  1. File level: @Languages header declares all languages used in the file
  2. Tier level: [- lang] directive switches utterance language
  3. Word level: @s and @s:lang markers 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 @s shortcut
  • Tertiary+ languages (3rd+ positions): Must use explicit @s:lang markers

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 @Languages
  • language_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

MarkerMeaningExample
@sShortcut: secondary language from @Languagesbiberon@s → Spanish
@s:spaExplicit single languagebiberon@s:spa → Spanish
@s:eng+fraMultiple languages (word legal in both)cafe@s:eng+fra
@s:eng&spaAmbiguous (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:

  1. @s shortcut → Secondary language from @Languages

    • Example: @Languages: eng, spa@s resolves to spa
    • Error if used in tertiary language tier (E244)
  2. @s:spa → Single(spa)

  3. @s:eng+fraMultiple([eng, fra])

  4. @s:eng&spaAmbiguous([eng, spa])

  5. 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:

  1. Resolves each word’s language using resolve_word_language()
  2. 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, not Shortcut enum 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:

  1. Know the @Languages header order
  2. Know the current tier language
  3. 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:

  1. The primary morphosyntax pass still emits L2|xxx as the safe intermediate placeholder for language-marked words.
  2. talkbank-transform then extracts deferred @s positions, plans contiguous spans plus host-side attachment, and asks batchalign only for the secondary Stanza worker dispatch.
  3. Successful secondary results are merged back into %mor/%gra; only unresolved, ambiguous, unsupported, or explicitly opted-out cases remain L2|xxx.

Why keep L2|xxx as an intermediate / fallback?

  • the primary model still cannot be trusted for foreign-word morphology
  • L2|xxx is 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&spa do 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 under UNSUPPORTEDLANG, the worker partitions that group out of the dispatch list (partition_groups_by_stanza_support), and every word receives L2|xxx.
  • @s:UNSUPPORTEDLANG per-word markers, the secondary L2 dispatch span is short-circuited the same way; the host primary analysis is preserved and the marker’s slot stays L2|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:LANG still resolves and dispatches even when LANG is absent from @Languages, but validation emits warn-only E254 so the header drift is visible
  • whole-utterance same-language all-@s runs now raise E255 and must be normalized to [- lang] rather than treated as acceptable shorthand
  • chatter debug fix-s is the repair path for both cases: it rewrites the qualifying whole-utterance pattern, clears bare @s shortcuts 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 @s words 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 @ID headers (first 3-letter language code)
  • Falls back to primary language passed to processing function

7. Validation and Errors

Language Resolution Errors

ErrorTriggerExample
E244@s shortcut in tertiary language tier[- fra] word@s when fra is 3rd+ language
E254Explicit @s:LANG language missing from @Languages@Languages: eng with hola@s:spa
E255Whole-utterance same-language all-@s pattern where [- lang] should be usedhola@s como@s estas@s .
E361Invalid language codeword@s:xyz (xyz not in ISO 639-3)
UnresolvedNo language context availableWord 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

LevelSyntaxScopeData Model Field
File@Languages: eng, spaAll utterancesHeader::Languages { codes }
Tier[- spa]Single utteranceMainTierContent.language_code
Word@s:spaSingle wordWord.lang

Word Language Markers

MarkerMeaningSyntax EnumResolved EnumExample
(none)Tier defaultNoneSingle(tier_lang)hello
@sSecondary langShortcutSingle(spa)biberon@s
@s:spaExplicitExplicit(spa)Single(spa)biberon@s:spa
@s:eng+fraMultipleMultiple([eng,fra])Multiple([eng,fra])cafe@s:eng+fra
@s:eng&spaAmbiguousAmbiguous([eng,spa])Ambiguous([eng,spa])no@s:eng&spa

Morphosyntax Processing Status

FeatureScopeStatusImplementation
Per-utterance routing[- lang]✅ ImplementedRust batches by tier language, Python routes to Stanza
Per-word routing@s:lang✅ Implemented with fallbackTransform-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: WordLanguageMarker enum (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_code field

Rust (talkbank-transform - payload & injection)

  • Batch payload definition: crates/batchalign-transform/src/morphosyntax/payload.rs:29-55: MorphosyntaxBatchItem struct (words, terminator, special_forms, lang)
  • L2|xxx placeholder for unresolved words: crates/batchalign-transform/src/morphosyntax/injection.rs: replaces pos with PosCategory::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_impl is the orchestration entry; per-utterance language grouping for the secondary L2 path lives at crates/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 Stanza nlp() per language, returns raw UD output
  • Per-utterance language routing: Python worker receives lang field, 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:

  1. warning/reporting surfaces for unresolved @s:eng+spa / @s:eng&spa
  2. policy-sensitive normalization around all-@s utterances and headers
  3. broader secondary-language quality work for weak or unsupported models

References


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 @s and @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
  • %mor output is L2|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:engpron|it~aux|be), phrasal verbs are recognized (wake up@sverb|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-morphotag opts back into legacy L2|xxx
  • unresolved / ambiguous markers such as @s:eng+spa or @s:eng&spa do not dispatch to one secondary model
  • unsupported target languages still preserve the code-switch signal via L2|xxx rather 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 under UNSUPPORTEDLANG, the worker partitions that group out of Stanza dispatch (partition_groups_by_stanza_support), and every word receives L2|xxx.
  • @s:UNSUPPORTEDLANG per-word markers, the secondary L2 dispatch span is short-circuited the same way; the host primary analysis is preserved and the marker’s slot stays L2|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-@s patterns are rejected by validation (E255). The accepted transcript form is utterance-level [- lang], not word@s word@s ... for an entire utterance.
  • Explicit @s:LANG still dispatches to LANG even when LANG is 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-s to normalize whole-utterance @s runs and append missing explicit languages to @Languages without 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 @s shortcuts on fillers and nonwords as part of the rewrite (otherwise the new [- LANG] precode would flip their resolved language).

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:

ContractionExpanded%mor output
don’tdo + n’taux|do-Fin-Ind-Pres~part|not
I’mI + ’mpron|I-Prs-Nom-S1~aux|be-Fin-Ind-Pres-S1
that’sthat + ’spron|that-Dem~aux|be-Fin-Ind-Pres-S3
can’tcan + n’taux|can-Fin-S~part|not
where’swhere + ’sadv|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:

WordWith MWT (correct)Without MWT (incorrect)
don’tAUX “do” + PART “not”ADV “don’t”
I’mPRON “I” + AUX “be”ADV “im”
that’sPRON “that” + AUX “be”AUX “that”
can’tAUX “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 + a tokenize_postprocessor callback 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=True for 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:

  1. Split contractions (intended): “don’t” -> “do”, “n’t”
  2. Split compounds (unintended): “ice-cream” -> “ice”, “-”, “cream”
  3. Normalize text (unintended): “cafe” for “café” (accent stripping)
  4. 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 by map_ud_word_to_mor() to produce Mor nodes. Never stored in the CHAT AST.
  • Mor (morphology node): Lives on the %mor dependent tier. Contains POS category, lemma, and features. One Mor per 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

  1. 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
  2. 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
  3. 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)
  4. 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:

PropertyDP Alignment (old)Character-Position Map (current)
ComplexityO(n*m) with Hirschberg optimizationO(n) linear scan
AmbiguityEqual-cost alignments broken arbitrarilyDeterministic, each character has exactly one position
NormalizationAccepts mismatches as edit operationsRejects immediately on any character difference
Failure modeMay return wrong alignment silentlyReturns 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"]

StagePython MasterOur Approach
Stanza output3 tokens3 tokens
PostprocessorDP aligns chars, merges back to 1 tokenChar-position map, merges back to 1 token
%mor result1 Mor node for “ice-cream”1 Mor node for “ice-cream”
Main tierRetokenize mode: risk of 3 wordsAlways original (1 word)

Contraction: “don’t” -> ["do", "n't"]

StagePython MasterOur Approach
Stanza output2 tokens with MWT Range [2,3]Same
PostprocessorKept as MWT tupleKept as MWT tuple
%mor resultaux|do~part|notaux|do~part|not
Main tierOriginal “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:

StagePython MasterOur Approach
Stanza outputClaus' MWT → [Claus, ']Same
Postprocessor("Claus'", True): English+apostrophe → allow MWTSame (_is_contraction returns True)
%mor resultpropn|Claus~punct|'propn|Claus~punct|'
CHAT validityValidValid

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”

StagePython MasterOur Approach
Stanza output“cafe” (accent stripped)Same
PostprocessorDP accepts mismatch as edit operationBail out: chars don’t match
%mor resultBased on “cafe” (wrong lemma)Based on “cafe” (Stanza’s analysis, not merged)
Main tierRetokenize: “cafe” leaks inAlways original “café”

Unicode Decomposition: “naïve” (NFC) vs “nai\u0308ve” (NFD)

StagePython MasterOur Approach
Stanza outputPossibly NFD-decomposed (6 chars vs 5)Same
PostprocessorDP: char count mismatch, Extra resultBail out: char sequences differ
Main tierUndefined 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 structure
  • injection.rs: injects mapped results into transcripts
  • synthesis/: synthesizes final %mor and %gra output
  • lang_en.rs, lang_fr.rs, lang_ja.rs: language-specific mapping rules
  • mapping_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 %gra dependency 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:

MetricPython masterRust (before)Rust (after)
MWT ~ joins on %mor6441298*

* 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)

ComponentFileDescription
MWT eligibilitybatchalign/worker/_stanza_loading.py:40should_request_mwt(alpha2, capability_table): capability-driven, replaces the deleted MWT_LANGS static
Stanza capability tablebatchalign/worker/_stanza_capabilities.pyCached snapshot of stanza.resources.common.load_resources_json(); _ISO3_OVERRIDES at :50 handles Stanza-specific iso3 cases
Stanza config builderbatchalign/worker/_stanza_loading.py:126load_stanza_models(): chooses tokenizer mode, wires postprocessor
MWT contraction rulebatchalign/inference/_tokenizer_realign.py:120_is_contraction(): English+apostrophe → True (replicates BA2 ud.py:680-685)
Tokenizer realignmentbatchalign/inference/_tokenizer_realign.py:148_realign_sentence(): character-position merging; merged tokens get (text, bool) tuples
Postprocessor factorybatchalign/inference/_tokenizer_realign.py:67make_tokenizer_postprocessor(): creates the Stanza callback; captures alpha2 in closure
Batch callbackbatchalign/inference/morphosyntax.py:201batch_infer_morphosyntax(): sets/clears TokenizerContext.original_words

Layer 2: Rust (UD → %mor/%gra)

All paths below are under crates/batchalign-transform/src/morphosyntax/.

ComponentFileDescription
MWT grouping (merge mode)sentence_mapping.rs:81::map_ud_sentenceUdId::Range groups MWT components under one CHAT word index
MWT grouping (expand mode)sentence_mapping.rs:24::map_ud_sentence_expandedPer-component MOR for --retokenize
Clitic assemblymapping_helpers.rs:60::assemble_morsJoins MWT components with ~ (post-clitic) or $ (pre-clitic)
POS mappingmor_word.rs:13::map_ud_word_to_morUD UPOS → CHAT category; clean_lemma at mor_word.rs:81 with empty-string fallback
English ruleslang_en.rsIrregular verbs (200+), suffix patterns per POS
French ruleslang_fr.rsPronominal clitics, APM, case agreement
Japanese ruleslang_ja.rsVerb 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>, produces Vec<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.rs function retokenize() (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/%gra alongside.
  • Input shape: CHAT AST Utterance + Stanza output (tokens + MWT + UD annotations).
  • Output shape: mutated Utterance with new word boundaries and injected morphosyntax tiers.
  • Source: crates/batchalign-transform/src/retokenize/ module (entry point retokenize_utterance() in mod.rs).
  • Gated: opt-in via --retokenize CLI 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:

  1. 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.
  2. 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.
  3. 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:

  1. BA2’s tokenizer_processor() + auxiliaries joining 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 fileRoleBA3 location
morphosyntax/en/irr.py (170 verbs)Past-tense irregularity flagtalkbank-transform/morphosyntax/lang_en.rs::IRREGULAR_VERBS
morphosyntax/fr/case.pyNominative/accusative pronoun liststalkbank-transform/morphosyntax/lang_fr.rs::{PRON_NOM, PRON_ACC}
morphosyntax/fr/apm.py + fr/apmn.py (158)Auditory plural markingtalkbank-transform/morphosyntax/lang_fr.rs::APM_NOUNS
morphosyntax/ja/verbforms.py (50+ patterns)Verb-form POS/lemma patchestalkbank-transform/morphosyntax/lang_ja.rs
Cantonese normalization (Python OpenCC)OpenCC s2hk + 31 domain replacementstalkbank-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 rulePhenomenonBA2 location
French jusqu' / puisqu' / quelqu' / aujourd'huiElision-prefix clitic handlingud.py:422-431, 684-693
French polyapostrophic splitter (d'l'attraper)Multi-level apostrophe expansionud.py:684-693
French c'est / l'ami / qu'il preservationBasic clitic elisionvia auxiliaries pass
Italian ll' / gliel' / d' / c' / qual' / l'Clitic + host joiningud.py:410-419
Italian le + i → lei repairStanza mis-split fixud.py:667-672
Portuguese d'águaIdiomatic MWT forceud.py:673-674
English apostrophe join (except o'clock)Contraction handlingud.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:

CaseStanza behaviorBA2 would have
dell_opera_in_contextparlapar + la (fake lemma), 4 UD for 3 CHATJoined via auxiliaries
parla_3sg_storia_contextlail + i, 7 UD for 6 CHATHandled via le+i → lei repair family
parla_imperative_forteSentence-initial parla splits even with no cliticRejected by auxiliaries rules
arancione_noun_bogus_verbNoun arancionearancio + ne (fake verb+clitic)Wouldn’t apply, arancione not in clitic list
piccolo_adj_bogus_verbAdjective piccolopicco + loSame
gomitolo_noun_bogus_verbNoun gomitologomito + loSame
divano_noun_bogus_verbNoun divanodiva + 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 Gold and new CandidateClass variants 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.golden tier 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&lt;Utterance&gt;"]

    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 %wor after retokenize: per architecture/asr-token-pipeline.md, %wor is 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 %wor or 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.rs covering two allowlist families: IT_MIS_SPLIT_OVERRIDES (parla, arancione, piccolo, gomitolo, divano, la) collapses Stanza’s bogus UdId::Range expansions 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 chapter
  • reference/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 defects
  • architecture/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:

InputOutput
iI
i'llI'll
i'mI'm
i'veI've
i'dI'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:

FamilySurfaces
TitleDr., Mr., Mrs., Prof.
PlaceSt., Mt., Ave.
Timea.m., p.m.
InitialismU.S., J.F.K.
DegreePh.D., M.D.
Technicaletc., 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 utteranceOutput 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:

  1. Title-period strip on elements (before stage 3), stage 3 splits on . and would fragment Dr.. Must run first.
  2. I-cap on words (in finalize_words_to_chunks), runs after number expansion and before retokenize. Per-word rewrite.
  3. 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::tests cover 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 from AsrOutput through build_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:

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.
  • @s words. hola@s:spa tags 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'tdo + n't (two UD words inside one Multi-Word Token, “MWT”).
  • gonnagon + 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 .
  • %mor emits a single clitic-joined item: aux|do-Fin-Ind-Pres-S3~part|not
  • Mapped by map_ud_sentence() in crates/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 .
  • %mor emits 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 by retokenize_utterance() in crates/batchalign-transform/src/retokenize.rs:195 for the main tier rewrite (with parse_helpers.rs and rebuild.rs as siblings under crates/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, then retokenize_utterance() rewrites the main tier to match Stanza’s tokenization before calling inject_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:

  1. A thread-local TokenizerContext holding original_words for the current batch. This is populated in batch_infer_morphosyntax() in batchalign/inference/morphosyntax.py (around line 346) and cleared immediately after nlp() returns.
  2. MWT hint preservation. Stanza’s tokenizer natively emits (text, True) tuples to ask its MWT processor to expand a token (English contractions, French au, Italian dal, 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 in stanza-limitations.md for 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.

ConcernBA2 (ud.py)BA3
Stanza modetokenize_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 branchSingle 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 fixInline regex on emitted %mor string at line 826: `re.sub(r“~part|s verb|(\w+)-Ger-S“, r“~auxis verb
Character-DP alignerInternal 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 rewriteText 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 preservationNot 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 routingMultilingualPipeline 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, @wp prefixes). BA3 never serializes in the middle of the pipeline; the main tier is rewritten at the AST layer.
  • BA2’s re.sub on the %mor string (line 826) is an ad-hoc patch for a specific Stanza wrong-analysis of 's Ger-S. BA3 addresses this at the UD layer in map_ud_sentence and assemble_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=True for 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 in stanza-limitations.md covers the failure mode.

Code map

FileRole
crates/batchalign-transform/src/morphosyntax/types.rsTokenizationMode enum (:106); top-level morphosyntax types
crates/batchalign-transform/src/inject.rsTop-level inject_morphosyntax(): tier insertion, shared by both modes
crates/batchalign-transform/src/morphosyntax/payload.rsPer-utterance payload collection sent to Python
crates/batchalign-transform/src/retokenize.rsretokenize_utterance() entry point (:195); build_word_token_mapping() (:67); inline #[cfg(test)] tests
crates/batchalign-transform/src/retokenize/rebuild.rsrebuild_content() (:47): AST walk that splices Stanza tokens into the UtteranceContent
crates/batchalign-transform/src/retokenize/parse_helpers.rsFragment-parser helpers (re-uses tree-sitter fragment parsing)
crates/batchalign-transform/src/morphosyntax/sentence_mapping.rsmap_ud_sentence() (merge, :81) and map_ud_sentence_expanded() (per-component, :24)
crates/batchalign-transform/src/morphosyntax/mapping_helpers.rsassemble_mors() (:60): clitic-join MOR construction
batchalign/inference/_tokenizer_realign.pyTokenizerContext, make_tokenizer_postprocessor, _realign_sentence, _conform
batchalign/inference/morphosyntax.pybatch_infer_morphosyntax(): dispatches Stanza per language, sets original_words
batchalign/worker/_stanza_loading.pyPer-language Stanza pipeline construction (pretokenized vs postprocessor)

Known limitations

  • %wor bullets become invalid after retokenize. %wor timing is per-CHAT-word; if a CHAT word is split, per-word timing does not survive cleanly. Retokenize-mode output should be re-aligned with align before %wor is trusted. See reference/wor-tier.md.
  • %wor validation breaks on re-transcription. Files re-transcribed after a main-tier rewrite will fail downstream %wor count checks. This is a deliberate invariant: the old %wor is stale.
  • Phrasal-verb MWTs. Cases like wake@s up@s produce verb|wake part|up with COMPOUND-PRT GRA 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=True is used and _tokenizer_realign.py is 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.retokenize is true and the job language is zho/cmn, batch_infer_morphosyntax lazy-loads a second pipeline with tokenize_pretokenized=False so Stanza’s neural segmenter can resegment Latin+CJK mixed text. See reference/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_merge at :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.py cover 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: TokenizationMode enum definition.
  • crates/batchalign-transform/src/inject.rs: top-level inject_morphosyntax() shared by both modes.
  • crates/batchalign-transform/src/retokenize.rs:195: retokenize_utterance entry point; sibling helpers under crates/batchalign-transform/src/retokenize/{parse_helpers,rebuild}.rs.
  • crates/batchalign-transform/src/morphosyntax/sentence_mapping.rs: map_ud_sentence (:81) and map_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 the ctx.original_words dispatcher around it.
  • batchalign/worker/_stanza_loading.py: per-language Stanza pipeline construction; should_request_mwt at :40; load_stanza_models at :126.
  • crates/batchalign/tests/ml_golden/morphotag/golden.rs:158 and crates/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, %mor regex patch, _build_nlp, tokenizer_processor rules) were read against a private archive copy of batchalign2-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 have 1.12.0 added 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.1 added to their version lists; defect 3 not re-evaluated. The new en_combined 's/her lemma 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 have 1.13.0 added to their version lists (zero XPASS on the Italian MWT-probe xfail markers); 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 %mor regeneration is warranted. Separately, the Spanish del_alone MWT drift sentinel was re-locked from 2 to 1 UD words: isolated del stopped 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.py against 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:

WorduposlemmadeprelHead
sinkNOUNsinknmod:possoverflowing
’sPART’scasesink
overflowingNOUNoverflowroot0

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:

WorduposlemmadeprelHead
sinkNOUNsinknsubjoverflowing
’sAUXbeauxoverflowing
overflowingVERBoverflowroot0

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, is is the finite verb, -ing is present participle in progressive aspect.
  • Possessive reading (rare, requires broader clausal context): “the sink’s overflowing [is problematic]”, <noun>'s is possessor, -ing is 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 's as AUX. The pronoun he probably helps, his would be the possessive form.
  • the stool's going over: Stanza correctly tags 's as AUX. going gets 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 -ing word 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 -ing word is a compound modifier; a different noun holds the root position (e.g., the lady's washing dishes: Stanza makes dishes root). The -ing word is promoted to root, the former root is demoted to obj, 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:

  1. Temporarily disable the rewrite (return sentence.clone() unconditionally in rescue_english_copula_progressive).
  2. 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.
  3. 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 's in copula-progressive contexts as AUX/be/Fin (and the following -ing word 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.py against 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_postprocessor API 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'tdo + 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.py pins 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:

  1. Re-run test_stanza_mwt_copula_observations.py. It still requires Stanza to emit (text, True) for English contractions.
  2. 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.py GREEN 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 associated test_control_token_leak_propagation.py mitigation-removal sentinel was retired alongside it); the standalone reproducer test_stanza_fi_mwt_sos_leak.py is 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 public Document API, appearing as literal substrings on word.text and word.lemma. Observed on Finnish when the MWT processor splits the word tollei in 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 (tolleitos + 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
  • tollei appears as a non-boundary token (3+ whitespace-separated tokens total; se tollei and tollei se do 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.py and batchalign/tests/pipelines/morphosyntax/test_control_token_leak_propagation.py were deleted alongside the mitigation.

Re-evaluation criteria (when Stanza upgrades)

  1. 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.
  2. 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.
  3. 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_LANGS include set in batchalign/worker/_stanza_loading.py that 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 with UnsupportedProcessorError. 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:

LanguageOld hardcoded listCapability tableNet change
Swedish (sv)True (wrong)FalseMWT no longer requested → bootstrap succeeds (was crashing)
Hebrew (he)FalseTrueMWT requested where it produces real splits
Greek (el)FalseTrueMWT requested where it produces real splits
Estonian (et)FalseTrueMWT requested but does not fire on conversational input (effective no-op; see “Estonian no-op” below)
Russian (ru)FalseFalseunchanged
Japanese (ja)FalseFalseunchanged
English / French / German / Italian / Spanish / Dutch / Finnish / etc.TrueTrueunchanged

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:

InputExpected (UD-EDT)Stanza-1.11.1 output
pole tähtisei+ole tähtispole tähtis (no split)
polnud aegaei+olnud aegapolnud aega (no split)
ma pole näinudma ei+ole näinudma pole näinud (no split)
ta polegi tulnudta ei+ole+gi tulnudta 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::TestShouldRequestMwt pins should_request_mwt against 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::TestMwtCapabilityDriven asserts the runtime decision against the actual installed Stanza catalog, Swedish False, English True, and uses an AST scan to forbid re-introduction of a hardcoded MWT_LANGS set.
  • Hebrew/Greek/Estonian MWT split observation tests (golden, real Stanza): batchalign/tests/pipelines/morphosyntax/test_stanza_he_el_et_mwt_splits.py pins 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.py runs batchalign3 morphotag --sequential on minimal Hebrew and Greek CHAT fixtures, asserts %mor contains tilde-joined splits for the contraction forms, and verifies the output passes chatter validate.

Re-evaluation criteria (when Stanza upgrades)

  1. Re-run the live-catalog tests. If should_request_mwt flips for any language, the capability table picked up an upstream change automatically; no BA3 code change needed.
  2. 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.
  3. 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 xfail markers 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 %mor entry per CHAT word using ~ / +, so the count invariant holds. The content of that %mor entry 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 as verb stem + enclitic pronoun, with a bogus stem lemma, regardless of actual part of speech. The defect fires not only on verb forms like parla (imperative of parlare) but also on common nouns like arancione (orange), seggiola (chair), gomitolo (ball of yarn), cavallone (big horse), cielo (sky), bottone (button), on adjectives like piccolo / piccola (small), and on diminutive/augmentative baby-talk forms (coccole, babbolo, pettole). Most of the non-verb hits are tagged with Part Past features, 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 %morFeatures on stem
parla (speak!, imp)verb|par~pron|laFin Imp Pres S2
arancione (orange, noun)verb|arancio~pron|nePart Past
seggiola (little chair)verb|seggio~pron|laPart Past
piccolo (small, adj)verb|picco~pron|loPart Past
piccola (small, fem adj)verb|picco~pron|laPart Past
gomitolo (ball of yarn)verb|gomito~pron|loPart Past
divano (sofa)verb|diva~pron|noPart Past
cielo (sky)verb|cie~pron|lo (cie is not a word)Inf Ind Imp S2
bottone (button)verb|botto~pron|nePart Past
cavallone (big horse)verb|cavallo~pron|nePart 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:

  1. 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 like dammela → da/dare + me + la (where head lemma dare ≠ head text da). When it fires, rewrite the merged %mor with a principled substitute (e.g., surface-form lemma plus a bare verb|… POS without the junk enclitic), OR refuse to emit a %mor entry and mark the utterance so the user knows Stanza gave up.
  2. Stanza retrain on conversational Italian where parla as 2sg imperative is marked with its correct parlare lemma.
  3. 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 + la split at UD level). These pins are Stanza-behavior observations, NOT injection-gate failure indicators, the actual %mor tier 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 %mor content for parla forte matches the correct verb|parlare-Imp-S2 adj|forte-S1. Adding such a probe requires either a manually-curated expected-%mor fixture per case or an oracle (e.g., CLAN’s Italian MOR output). Flagged as a gap.

Re-evaluation criteria (when Stanza upgrades)

  1. 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.
  2. If the split shifts to a different surface form (e.g., stops on parla but starts on guarda → guard+a), extend the probe case list and re-document.

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:

InputStanza UD structure (after MWT expansion)Actual %mor emittedLinguistic correctness
parla fortepar/par/VERB + la/la/PRON + forte/forte/ADJ wrapped as Token(1,2)+Token(3,)`verbpar-Inf-S~pron
parla più fortepar/par/VERB + la/la/PRON + più/più/ADV + forte/forte/ADJ`verbpar-Inf-S~pron
parla dell'opera nuovapar/par + la/la + dell'/(di+l') + opera + nuovacompound `verbpar-…~pron
la storia parla di un bambinola/(il+i) + storia + parla/parlare/VERB + di + un + bambino`detil-Masc-Def-Art-Sing~det
dammelada/dare/VERB + me/me/PRON + la/la/PRON wrapped as Token(1,3)`verbdare-Inf-Ind-Imp-S2~pron
per favore dammelaper/ADP + favore/NOUN + dammela/dammelo/ADJ (no MWT expansion mid-sentence)`adpper noun

Three conclusions, all pipeline-verified:

  1. The %mor count invariant is NOT violated by any of these. Stage 3’s assemble_mors collapses MWT Range components into a single compound %mor using ~/+, so every CHAT word gets exactly one %mor item. The mor_count_parity_reference_corpus.rs test still passes. Defect 6 is not an injection-gate failure , it’s a linguistic-content failure downstream of Stanza’s POS layer.
  2. dammela alone is handled correctly. Stanza produces the right imperative+clitic analysis (dare lemma + me + la clitics), Stage 3 assembles the compound %mor, and the output matches UD convention for Italian clitic compounds. No bug here.
  3. dammela mid-sentence (per favore dammela) fails for a different reason. Stanza misclassifies the entire compound as ADJ with lemma dammelo, 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 xfail markers 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_mors collapses the bogus 2-word expansion into a single compound %mor entry per CHAT word, so the count invariant holds. The content is wrong: det|il-Masc-Def-Art-Sing~det|il-Masc-Def-Art-Plur for a feminine-singular article carries wrong lemma, wrong features, and wrong number agreement.
  • Construction: Sentence-initial feminine singular article la (as in la storia, la casa) is wrapped by Stanza in an MWT Token whose inner words are il + i, both tagged DET and both lemmatized to il. This is spurious, la is a single-morpheme article that should be analyzed as det|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 (spurious la → il + i split at UD level). The xfail is a Stanza-behavior pin, not an injection-gate failure indicator, the actual %mor tier 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, %mor content will also improve automatically.

Re-evaluation criteria (when Stanza upgrades)

  1. 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.
  2. If the expansion shifts to a different surface form (e.g., le or li also start expanding), extend the probe case list and re-document.

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; the ewt and gum POS packages show the same behaviour for whoops and differ only on doggy)
  • 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 Stanzawhoopsdoggy
whoops / where 's the doggy (no terminator)INTJNOUN
whoops . / where 's the doggy ?NOUN Number=Plur, lemma whoopADJ 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 .cut files 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 pinned TalkBank/mor commit recorded in the file’s source block, fails closed on any line it cannot parse, and validates the result through the runtime’s own constructor. Ambiguous forms (whoop is co, n and v; 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; the 0affix.cut derivations 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 keeps sticky (stick + -y), building and feeling (verb + -ing) and lives (live + -s) out of the verdicts; without it every one of them was wrongly rewritten in the first Bates run. It is also why doggy (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.cut is 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 .cut parser, verdict derivation with the derivability guard, and the validated LexiconVerdicts type.
  • 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 (gonna arrives as gon + na, and na is 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_load pins that the shipped data loads, carries whoops and banana, and does NOT carry whoop, doggy, sticky or building.
  • invariants::lexicon_category::tests::whoops_with_a_period_is_a_communicator_in_mor, banana_read_as_an_interjection_is_a_noun_in_mor and doggy_is_derivable_and_therefore_left_to_stanza run 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, MWT gum)
  • 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 Stanzawhole-token analysisexpanded 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, advmodhave VERB root, do aux of have
lemme see .lemme INTJ, discourse of seelet root, me obj, see xcomp
I dunno .dunno VERB, lemma dunnoknow 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_mor and hafta_gra_has_the_host_as_root run Stanza 1.14.0’s exact whole-token analysis through the production dispatcher and mapping and pin the %mor and 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_dunno pin each shape; every_contraction_has_one_host checks the table; tokens_stanza_already_expanded_are_untouched and unknown_words_are_untouched pin 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_utterance re-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 omitted 0word) is speech, not silence: it clears a pending break and keeps the word before it off the utterance edge, so my 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 in injection.rs beside the payload’s words and handed to apply_grammatical_invariants.
  • crates/batchalign-transform/data/eng_lexicon_verdicts.json and lexicon.rs: a third verdict, CommunicatorAmongOthers, for the 90 forms co.cut shares 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 %mor item), and an isolated word with a communicator verdict whose Stanza category cannot stand alone (exhaustive match on UniversalPos) becomes intj|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::tests parse CHAT and pin the isolation for a pause before the final word, a predicate okay, an initial word before a pause, a one-word utterance, a filler the payload drops, xxx slots, and a comma.
  • invariants::discourse_marker::tests::an_isolated_okay_tag_is_an_interjection_in_mor runs 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_word and 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 xfail markers 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_verb
    • test_italian_defect8_dammelo_mid_sentence_becomes_verb
    • test_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 , runs batchalign3 morphotag on a CHAT fixture whose @Languages: header declares ita, content per favore dammela, and asserts the output %mor carries dare, not adj|dammelo. (Morphotag has no --lang flag; 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:

inputanalysis of dammela
per favore dammelaADJ, lemma dammelo, amod; no MWT expansion at all
per favore dammela .VERB dare root + PRON me + PRON la
per favore da me lada 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:

  1. 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.
  2. Add a section here following the format above.
  3. 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).
  4. 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

  1. Disable the BA3 mitigations (comment out the override entry points or run with a feature flag).
  2. Run all tests in this document’s “Tests” sections.
  3. 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.
  4. 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

LanguageCodeDedicated PageKey Special Treatment
CantoneseyueCantonese4 ASR engines, text normalization, PyCantonese word segmentation, jyutping FA
Mandarincmn/zhoMandarinStanza neural word segmentation, Chinese number expansion
JapanesejpnJapaneseStanza combined package, retokenize merge/split
HebrewhebHebrewFine-tuned Whisper, RTL punctuation, HebBinyan/HebExistential features
FrenchfraFrenchNative Stanza MWT + char-DP realign (BA2 elision/multi-clitic hacks removed)
ItalianitaItalianNative 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”
PortugueseporPortugueseNative Stanza MWT + char-DP realign (d'água ForceMwt hack removed)
DutchnldDutchNative Stanza tokenization ('s-suffix SuppressMwt hack removed as dormant)
MalayalammalMalayalamNo 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.

FamilyCoverage
43 codegenned via num2wordsEnglish, Spanish, French, German, Italian, Portuguese, Dutch, Scandinavian languages, Russian, Polish, Czech, Turkish, Thai, Telugu, Bengali, Kannada, Indonesian, …
4 hand-curatedMalayalam, Greek, Basque, Croatian
Chinese (Simplified)num2chinese (一万), Mandarin
Chinese (Traditional)num2chinese (一萬), Cantonese, Japanese
English-onlyOrdinals (13th → “thirteenth”), decades (1950s → “nineteen fifties”), years
Validator-permits-digitsWelsh, Vietnamese, Min Nan, Hakka, no expansion needed

All other languages pass digits through and trip E220 at validation.

See Also


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 stageCantonese-specific behavior
ASR5 engine options: FunASR/SenseVoice (default), Tencent Cloud, Aliyun NLS, Qwen3-ASR, Whisper
Text normalizationSimplified → Traditional + 31-entry domain replacement (automatic)
Number expansionTraditional Chinese characters (五, 四十二, 一萬)
Character tokenizationPer-character splitting for timestamp alignment
Word segmentationPyCantonese segment() via --retokenize
Utterance segmentationPolyU 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 alignmentJyutping 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.

EngineTypeCredentialsWord outputStrength
FunASR/SenseVoice (default for yue)LocalNonePer-characterNo cloud, VAD built-in, lowest measured CER on child speech
Tencent CloudCloudRequiredPer-characterSpeaker diarization, strong on clean adult speech
Aliyun NLSCloudRequiredPer-characterReal-time streaming
Qwen3-ASRLocalNonePer-characterAlibaba open-weight ASR; competitive on per-utterance Cantonese child speech in external evaluations (unverified on TalkBank’s longer-form fixtures)
WhisperLocalNonePer-characterGeneral-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 zh model 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, --retokenize is 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 StageMandarin-Specific Behavior
ASRRev.AI by default; paraformer (FunASR) is the common Mandarin choice, and every other engine works too. See ASR engines.
Text normalizationNone (Cantonese normalization is yue-only)
Number expansionChinese number system (num2chinese with simplified script for both cmn and zho)
Utterance segmentationtalkbank/CHATUtterance-zh_CN for cmn / zho in standalone utseg and transcribe pre-CHAT segmentation
Word segmentationStanza neural tokenizer via --retokenize
MorphosyntaxStanza Chinese (zh) model; @s Mandarin words in mixed-language files use the same Chinese morphosyntax path
Forced alignmentWave2Vec MMS (standard)

Language Codes

ISO 639-3StanzaNotes
cmn (Mandarin)zhStandard Mandarin
zho (Chinese, generic)zhMaps 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):

InputStanza OutputCorrect?
我去商店买东西我 去 商店 买 东 西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:

CodeModel
cmntalkbank/CHATUtterance-zh_CN
zhotalkbank/CHATUtterance-zh_CN

This model is used in two places:

  1. transcribe pre-CHAT segmentation for eng / cmn / zho / yue
  2. standalone utseg when 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:

CodeScriptExample
zhoSimplified5 → 五, 10000 → 一万
cmnSimplified5 → 五, 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

WhatTestResult
Stanza zh tokenizer segments wordstest_stanza_chinese_tokenizer_segments_multichar_wordsGroups 商店 correctly
pretokenized=True preserves charstest_stanza_pretokenized_true_preserves_chars7 chars stay as 7 tokens
Language code mappingtest_language_code_mappingcmn → zh, zho → zh

Open Questions

  1. Paraformer output format: does Paraformer actually produce per-character tokens for Mandarin, or does it attempt some word segmentation? (Conflicting reports from users.)
  2. Child Mandarin speech: how does Stanza’s tokenizer perform on child language data?
  3. 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

FileRole
batchalign/worker/_stanza_loading.pyload_stanza_retokenize_model() for lazy zh retok pipeline (:251)
batchalign/inference/morphosyntax.pyMandarin retokenize path in batch_infer_morphosyntax()
crates/batchalign-transform/src/asr_postprocess/num2chinese.rsChinese 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 StageJapanese-Specific Behavior
ASRWhisper (default), no Japanese-specific alternatives
Text normalizationNone, Japanese characters passed through as-is
Number expansionChinese number system (num2chinese with simplified script)
RetokenizeStanza combined package merges/splits CJK tokens
MorphosyntaxStanza ja with forced combined package for all processors
MWTExcluded, Japanese has no contractions
Forced alignmentWave2Vec MMS (standard, no language-specific preprocessing)

Stanza Configuration

Japanese uses two distinct Stanza configurations depending on --retokenize:

PropertyKeep-Tokens (default)Retokenize
Stanza tokenizerBypassed (pretokenized=True)Runs (no_ssplit=True)
Packagecombined (all 4 processors)combined (all 4 processors)
Word boundariesPreserved from CHATStanza may merge/split
MWT processorNot loadedNot 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 in crates/batchalign-transform/src/retokenize.rs (e.g., deterministic_mapping_succeeds_for_split_and_merge at :287).
  • Stanza combined package loads correctly for ja (batchalign/worker/_stanza_loading.py:196-207).
  • MWT exclusion: should_request_mwt() reports has_mwt=False for ja; the retokenize/utseg path uses a local mwt_exclude = {"zh", "ja", "ko", "th", "vi", "my"} set at batchalign/worker/_stanza_loading.py:310. The global _MWT_EXCLUSION was retired alongside MWT_LANGS (see Stanza Limitations Defect 5).

Open Questions

  1. Would a Japanese-specific ASR model improve accuracy? Whisper handles Japanese reasonably but a dedicated model might improve CER.
  2. How does retokenize affect timing? When words are merged/split, existing %wor timing 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

FileRole
batchalign/worker/_stanza_loading.pyJapanese combined package selection (:196-207); MWT eligibility via should_request_mwt()
batchalign/inference/morphosyntax.pyStanza inference (shared with all languages)
crates/batchalign-transform/src/retokenize.rs + crates/batchalign-transform/src/retokenize/{parse_helpers,rebuild}.rsAST rewrite for merged/split tokens
crates/batchalign-transform/src/morphosyntax/lang_ja.rsPOS 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 StageHebrew-Specific Behavior
ASRopenai/whisper-large-v3 via --asr-engine whisper (no per-language fine-tune wired today)
Text normalizationRTL punctuation → ASCII (؟→?, ۔→., ،→,)
Number expansionNot supported: digits pass through unexpanded
MorphosyntaxMWT enabled (preposition+article contractions), HebBinyan/HebExistential features
Forced alignmentWave2Vec MMS (standard)

ASR

EngineModelNotes
--asr-engine whisperopenai/whisper-large-v3Default; 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.AICloud APISupports 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):

RTLASCIIUnicode
؟?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

TestWhat
test_hebrew_verb_hebbinyanHebBinyan=PAAL → lowercase suffix
test_hebrew_verb_hebexistentialHebExistential=True → lowercase suffix
test_hebrew_3letter_code_works“heb” maps to Stanza “he” correctly

Open Questions

  1. Hebrew number expansion: should we add a Hebrew number table? What are the conventions for Hebrew CHAT transcripts (digits vs word forms)?
  2. Hebrew-specific ASR errors: are there systematic Stanza POS/dep errors for Hebrew that need workaround rules (like English/French/Japanese)?

Source Files

FileRole
crates/batchalign-transform/src/morphosyntax/features.rs:43,46HebBinyan/HebExistential extraction
crates/batchalign-transform/src/morphosyntax/types.rs:38hebhe Stanza alpha-2 mapping
crates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rsHebrew integration tests (:15, :45, :268)
batchalign/worker/_stanza_loading.pyStanza 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:

PatternExampleStanza output
au / aux / du / desva au cinémaà + le + cinéma (MWT expansion, 1 CHAT word → 2 UD words, Range preserved)
c’est / n’est / qu’ilc'est vraiMWT split to c' + est (Range preserved)
l’X (noun, vowel-initial)l'ami, l'eau, l'EcosseMWT split l' + ami, 1 CHAT word → 2 UD words
Elision prefixjusqu'à, puisqu'il, quelqu'un, aujourd'hui1 CHAT word → 2 UD words; the DP realigner in align_tokens merges any over-split back
Multi-cliticd'l'attraper, qu'l'onStanza 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

RuleWhat it didAudit finding
Exact("au") → ForceMwtForce MWT expansion on auRedundant. 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 splitFor tokens matching jusqu', puisqu', quelqu', aujourd', split into (prefix, False) + (suffix, False) with no MWT hintActively 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 splitSame split logic for multi-apostrophe clitic stacks like d'l'attraperSame 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: typed ProbeCase fixtures (elision prefixes, clitic contractions, MWT natives, aujourd'hui, plus the seed 040802:1620 utterance).
  • Matrix harness: batchalign/tests/investigations/test_stanza_mwt_probe_matrix.py runs every case through paired (free-tokenize vs postprocessor) pipelines. Invoke with uv run pytest batchalign/tests/investigations/ -m golden.

References


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

DefectShapeHandled now byNotes
6 (parla/arancione -> fake verb+clitic)spurious splitpolicy: suppressed at sourcereconciler table retained as dormant backstop; no Range reaches it
7 (la -> il+i)spurious splitpolicy: suppressed at sourcesame
8 (dammela mid-sentence, no expansion)withheld splitpolicy: forced at sourcearrives as a Range and gets full decomposition natively; allowlist dormant backstop
9 (dagliela head ADP/da)wrong head POS on a real Rangereconciler rewrite, STILL ACTIVEthe policy validates split SHAPE, not component analyses
10 (posala head lemma posa)wrong head lemma on a real Rangereconciler rewrite, STILL ACTIVEposare-specific
12/13 (aprilo -> `verbaprilare`)withheld split, fabricated lemmapolicy: forced at source
Singleton skips (soffioni, pettole, babbolo)spurious split, unlistedpolicy: suppressed at sourcecorrect without listing; verified whole with real lemmas
non-UD iob relation%gra contentrepaired on the production path (2026-07-28), and since reported as a typed relation_alias repair that the file’s ud_repairs= countswas 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:

PatternExampleStanza output
al / del / nel / sul / dalal cinemaa + il + cinema (MWT expansion, 1 CHAT word → 2 UD words, Range preserved)
della / dello / degliparla della casadi + la + casa (MWT)
l’X (common nouns)l'amico, l'opera, l'uomo, l'anno, l'oggetto1 UD word with accented apostrophe preserved; character-DP in align_tokens merges any Stanza over-split back to 1
preposition+cliticall'amore, nell'anno, sull'ora1 MWT expansion; 1 CHAT word stays 1
lei (3sg.f pronoun)dice lei, lei mangia1 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 forteverb|par~pron|la (should be verb|parlare-Imp-S2).
  • Common nouns: arancione (orange) → verb|arancio~pron|ne with Part 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 (cie is not a word).
  • Adjectives: piccolo/piccola (small, m/f) → verb|picco~pron|lo / verb|picco~pron|la with Part 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 (diglieladi/VERB/dire, portaglielaporta/VERB/portare, prendiglielaprendi/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 of soffione, dandelion), obscure
  • coccolo: dialectal / obscure
  • pettole: dialectal / obscure
  • babbolo: likely a typo or child-speech approximation
  • tecala: 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%morCorrect?
dammela`verbdare-Inf-Ind-Imp-S2~pron
dammelo(same shape with la → lo)Yes
portalo`verbportare-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&lt;Mor&gt;<br/>(each chunk countable)"]
        Gras["Vec&lt;GrammaticalRelation&gt;<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:

  1. 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).
  2. head: how to resolve the GRA relation’s head index (Root, FromUd(ud_id), or OwningMorMain).
  3. 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&lt;GrammaticalRelation&gt;
    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::Root was 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:

AllowlistHook pointActionBacking type
IT_MIS_SPLIT_OVERRIDESRange branch, before assemble_morsCollapse Range to 1 MorMisSplitOverride
IT_COMPONENT_REWRITESRange branch, before assemble_mors (after mis-split check)Mutate component 0 in place, fall through to assemble_morsComponentRewriteOverride
IT_COMPOUND_IMPERATIVESSingle branchSynthesize main verb + post-clitic MorsCompoundImperativeOverride + CliticSpec[]

Shared invariants across all three:

  1. 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).
  2. 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.
  3. 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_REWRITES even though their Stanza-error signatures differ.
  4. Retirement workflow. Empty all three allowlists in lang_it.rs and 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 textOverride POSOverride lemmaSource
par + laparlaVERBparlaresentence-initial 2sg/3sg indicative
arancio + nearancioneNOUNarancioneBurgato/23
picco + lopiccoloADJpiccoloCalambrone/Martina/020322
gomito + logomitoloNOUNgomitoloTonelli/Marco/011026
diva + nodivanoNOUNdivanoTonelli/Marco/010803
pallo + nepalloneNOUNpallonecorpus scan (94× in CHILDES-ita)
basto + nebastoneNOUNbastonecorpus scan (48×)
cappe + locappelloNOUNcappellocorpus scan (56×)
diffici + ledifficileADJdifficilecorpus scan (46×)
seggio + laseggiolaNOUNseggiolaaudit (4× in committed JSON)
picco + lapiccolaADJpiccoloaudit (4×); fem of already-handled piccolo
trotto + latrottolaNOUNtrottolaaudit (3×)
botto + nebottoneNOUNbottoneaudit (2×)
cie + locieloNOUNcieloaudit singleton (common word)
norma + lenormaleADJnormaleaudit singleton (common word)
cavallo + necavalloneNOUNcavalloneaudit singleton (augmentative of cavallo)
cocco + lecoccoleNOUNcoccoleaudit singleton (child-speech)
il + i (as expansion of la sentence-initial)laDETilDefect 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 POSVerb lemma overrideSource
dammelaADJdaredirect probe
dammeloADJdaredirect probe
prendiloADJprenderecorpus scan (52× in CHILDES-ita)
prendilaADJprenderefamily (sibling of prendilo)
prendiliADJprenderefamily
prendileADJprenderefamily
aprilaNOUNapriredirect probe (-ire family)
apriliNOUN (homograph aprile)apriredirect probe
finilaADJfiniredirect 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 parentDefectStanza head (POS/lemma)Rewritten headSource
dagliela9ADP / daVERB / daredirect probe
posala10VERB / posaVERB / posareaudit (1×)
posalo10VERB / posaVERB / posarefamily (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_merged guards against regression.
  • guardala, toccala, aspettala, mangiala, chiamala, lasciala, cambiala, provala, giocala, portala, suonala, chiudila (Defect 10 controls), all probed Stanza-correct. Only the posare paradigm 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, portalo when 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.py continue 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 like dammela).
  • Synthetic UD integration tests in crates/batchalign/src/chat_ops/nlp/mapping/tests/italian_defects.rs (search for test_italian_defect6_), confirm the reconciler collapses known mis-split UdSentence shapes into the correct single Mor.
  • End-to-end golden in batchalign/tests/pipelines/morphosyntax/test_italian_defect6_end_to_end.py , runs batchalign3 morphotag on a CHAT fixture whose @Languages: header declares ita; the fixture contains all six allowlist words in context. Asserts no junk verb|STEM~pron|CLITIC pattern appears in the output %mor tier. (Morphotag has no --lang flag; 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 %mor with no ~ clitic: parla → verb|parlare, arancione → noun|arancione, piccolo → adj|piccolo, gomitolo → noun|gomitolo, divano → noun|divano.
  • dammela continues 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 MappingContext sees no behavior change (explicit lang2(&ctx.lang) == "it" gate).
  • Unit tests in lang_it.rs cover the allowlist semantics directly (check_italian_mis_split for 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:

  1. 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.
  2. In-context committed-damage signature scan. The language-resolved audit enumerates the single-word signature; committed in-context damage (det|il~det|il for la, verb+enclitic items on multi-word utterances) needs its own signature in lang_audit for exact enumeration. Regenerating all Italian-primary files cures it regardless.
  3. Reconciler allowlist retirement. IT_MIS_SPLIT_OVERRIDES (23 entries, Defects 6/7) and IT_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.
  4. 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.
  5. 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 to fine, not finire).
  • 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,mwt pipeline 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:

RuleWhat it didAudit finding
MwtTaggedExact("l'") → SuppressMwtFlip any Stanza-tagged MWT l' token to non-MWTDormant. 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 mergeWhen the aligned buffer had le followed by i, collapse them into a single lei tokenHarmful. 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: typed ProbeCase fixtures covering l'X, preposition+clitic, lei variants, adjacent le 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 %mor injection failures, injection succeeds with junk content. See each case’s XfailMark.reason for the distinction.
  • Matrix harness: batchalign/tests/investigations/test_stanza_mwt_probe_matrix.py runs every case through paired (free-tokenize vs postprocessor) pipelines. Invoke with uv 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, das
  • no → em + o, na → em + a, nos, nas
  • pelo → por + o, pela, pelos, pelas
  • ao → 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:

PatternExampleStanza output
do / da / dos / dasperto do riode + o + rio (MWT, 1 CHAT word → 2 UD words, Range preserved)
no / na / nos / nasna cidadeem + a + cidade (MWT)
ao / aos / à / àsvou ao mercadoa + o + mercado (MWT)
d'água (idiomatic elision)copo d'água, d'água standaloneStanza 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

RuleWhat it didAudit finding
Exact("d'água") → ForceMwtForce MWT expansion on the idiomatic elision d'águaNet 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: typed ProbeCase fixtures covering d'água standalone and in context, plus native MWTs (do, da, na).
  • Matrix harness: batchalign/tests/investigations/test_stanza_mwt_probe_matrix.py runs every case through paired pipelines. Invoke with uv 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 's suffixes 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:

PatternExampleStanza output
Possessive 's on proper nameClaus's, Maria's, Jan's1 UD word, no MWT expansion
Pseudo-contraction het's / er'shet's koud1 UD word per whitespace-separated token
Leading 's time idiom's-avonds, 's-morgens1 UD word (hyphen-joined form preserved)
Short contraction't is koud, 'n huis1 UD word per token
Plain nounhuis1 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

RuleWhat it didAudit finding
EndsWith("'s") → SuppressMwtFor any Dutch token ending in 's, flip the MWT hint to False to prevent expansionDormant. 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: typed ProbeCase fixtures covering possessives, contractions, time idioms, and control nouns (grep -c ProbeCase against the file is the live count).
  • Matrix harness: batchalign/tests/investigations/test_stanza_mwt_probe_matrix.py runs every case through paired pipelines. Invoke with uv 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 StageMalayalam-Specific Behavior
ASRHuggingFace Whisper fine-tune via --asr-engine whisper_hub
Text normalizationNone, Malayalam script passed through as-is
Number expansionRust-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.
RetokenizeNot applied, retokenize maps a Stanza-tokenized word list back to ASR tokens; without a Stanza pipeline there is nothing to map.
MorphosyntaxNot available: Stanza ships no Malayalam pipeline
UtsegNot available: same reason
Forced alignmentStock 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.

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.

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:177 chunk_length_s=25).
  • Output CHAT can be edited by hand and re-run through other TalkBank tooling; the absence of %mor / %utseg does 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:

LanguageCodeSegmentation EngineHow It Works
CantoneseyuePyCantonese segment()Dictionary-based Cantonese word segmentation
Mandarincmn, zhoStanza neural tokenizerJointly-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

EngineWord SegmentationRecommendation
Tencent Cloud ASRPer-character tokens (verified 2026-03-23: 25 words, 0 multi-char)Use --retokenize
FunASR / SenseVoicePer-character tokens (verified)Use --retokenize
Paraformer (Mandarin)Per-character tokens, one timestamp each (verified 2026-09-15 against real FunASR output); see ASR token pipelineUse --retokenize
WhisperVariable (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.

PropertyKeep-TokensRetokenize
Stanza tokenizerBypassed (pretokenized)Runs (no_ssplit)
Word boundariesPreservedMay change
Token mapping1:1N:M (DP alignment)
Whitespace artifactsRareCommon
Use caseMorphotag on existing transcriptsFull 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();
    }
}
InputUPOSResulting POS
PUNCTcm
PUNCTcm
,PUNCTcm
PUNCTcm

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):

CategoryCountExamples
Conditional/subjunctive conjunctions3ちゃ→ば, なきゃ, じゃ
Auxiliary verbs~8られる, ちゃう, おう, たら
Interjections~9はい, うん, おっ, ほら, あのね
Pronouns~2あたし
Verb lemma corrections~11撮る, 貼る, 混ぜる, 釣る, 帰る
Noun overrides~8バツ, ブラシ, 引き出し, マヨネーズ
Adjective specializations~3速い
為る context overrides~5Verb/noun/aux disambiguation
Participles and other~16Various 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

ConceptFileAnchor
Capability-driven MWT exclusion (retired the historical _MWT_EXCLUSION frozenset)batchalign/worker/_stanza_loading.pyshould_request_mwt() @40
combined package forcing for Japanesebatchalign/worker/_stanza_loading.py:196-209 (if alpha2 == "ja" branch)
Stanza config (keep-tokens vs no-MWT) modesbatchalign/worker/_stanza_loading.pyload_stanza_models() @126
Token text whitespace stripcrates/batchalign-transform/src/morphosyntax/injection.rsretokenize-mode token sanitizer
Lemma whitespace stripcrates/batchalign-transform/src/morphosyntax/ud_types.rssanitize_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 → cmcrates/batchalign-transform/src/morphosyntax/mor_word.rsinside map_ud_word_to_mor @13
is_clitic() (no JA entries)crates/batchalign-transform/src/morphosyntax/mor_word.rs@200
Verb form overridescrates/batchalign-transform/src/morphosyntax/lang_ja.rsjapanese_verbform()
Retokenize algorithmcrates/batchalign-transform/src/retokenize.rs (+ retokenize/{rebuild,parse_helpers}.rs)full module
@s: marker extraction../chatter/crates/talkbank-transform/src/extract.rsWordLanguageMarker 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
hebheStandard mapping

ASR

Hebrew uses a fine-tuned Whisper model for the HuggingFace engine:

EngineModel
--asr-engine whisperivrit-ai/whisper-large-v3 (fine-tuned for Hebrew)
Rev.AICloud 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:

RTLASCIIUnicode
؟?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: PAALpaal, HIFILhifil.

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: Truetrue.

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

FileWhat
crates/batchalign-transform/src/morphosyntax/features.rs:43,46HebBinyan/HebExistential extraction
crates/batchalign-transform/src/morphosyntax/types.rs:38hebhe code mapping (Rust ISO-3 → Stanza ISO-1)
crates/batchalign/src/chat_ops/nlp/mapping/mod.rsCore mapping module that consumes the extracted features
crates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rsHebrew integration tests
batchalign/worker/_stanza_loading.pyStanza pipeline configuration for Hebrew

Test Coverage

TestFileWhat
test_hebrew_verb_hebbinyancrates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rs:15HebBinyan=PAAL → lowercase suffix
test_hebrew_verb_hebexistentialcrates/batchalign/src/chat_ops/nlp/mapping/tests/lang_de_es_he.rs:45HebExistential=True → lowercase suffix
test_hebrew_3letter_code_workscrates/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 tokenOutput (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.ºsquinquagé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 (, 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-7 or 5-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:

  1. Rev language ID succeeds before transcript submission, so the request itself becomes the explicit-English path.
  2. 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:

  1. 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).
  2. Punctuation-based fallback: Rust-side retokenization over typed ASR words. Used for unsupported languages and as cleanup after model-backed segmentation.
  3. 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

LanguageCodeModelSourceArchitecture
Englishengtalkbank/CHATUtterance-enTalkBank fine-tunedBERT token classification
Mandarincmn/zhotalkbank/CHATUtterance-zh_CNTalkBank fine-tunedBERT token classification
CantoneseyuePolyU-AngelChanLab/Cantonese-Utterance-SegmentationHong Kong PolytechnicBERT 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:

ClassMeaning
0Normal (continue)
1Capitalize next
2Period (.)
3Question mark (?)
4Exclamation mark (!)
5Comma (,)

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).

.  ?  !  +...  +/.  +//.  +/?  +!?  +"/.  +".  +//?  +..?  +.  ...  (.)

Additional Normalizations

Before splitting:

  • Japanese period (。) → .
  • Spanish inverted punctuation (¿, ¡) → removed
  • RTL punctuation (؟, ۔, ،, ؛) → ASCII equivalents

Split Rules

  1. If a word is a terminator → flush the current utterance
  2. If a word ends with a terminator character → split the word, flush
  3. If no terminator is found → auto-append . at the end
  4. 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:

  1. Collect annotated conversational data with utterance boundaries
  2. Fine-tune a BERT token classification model (6 classes: normal, capitalize, period, question, exclamation, comma)
  3. Upload to HuggingFace Hub
  4. Add the language, model id and pinned commit to UTSEG_BOUNDARY_MODELS in crates/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
  5. Add any language-specific preprocessing (e.g., character-level tokenization for CJK, particle-based chunking)

Source Files

FilePurpose
crates/batchalign-transform/src/asr_postprocess/mod.rsTyped ASR normalization + punctuation retokenization
batchalign/models/utterance/infer.pyBA2-style utterance model runtime
batchalign/worker/_model_loading/utterance.pyUtterance model bootstrap
batchalign/inference/utseg.pyWorker-side utseg dispatch (typed assignments or Stanza trees)
batchalign/models/utterance/evidence.pyClosed model-action and per-word evidence states
crates/batchalign-types/src/worker_v2/utseg_evidence.rsCanonical Rust IPC evidence types
crates/batchalign/src/utseg_evidence.rsVersioned 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 tiercleaned_text (in %wor)Notes
a::nanLengthening : removed
hel^lohelloSyllable pause ^ removed
som(e)thingsomethingShortening expanded
°softer°softerCA delimiters removed
⌈word⌉wordOverlap points removed
&-uhuhCategory 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+creamicecreamCompound marker + removed

Inclusion Rules

Words INCLUDED in %wor

The %wor tier includes spoken main-tier word tokens:

FormExampleIn %wor?cleaned_text
Regular wordswant, cookieYeswant, cookie
Fillers&-uh, &-umYesuh, um
Fragments&+fr, &+wNo,
Nonwords&~gaga, &~umNo,
Untranscribed placeholdersxxx, yyy, wwwNo,
Words with error marksgoed [*]Yesgoed
Words inside retrace groups<I want> [/] I needYes (all 4 words)I, want, I, need
Words inside reformulation groups<I want> [//] I needYes (all 4 words)I, want, I, need
Words inside quotations+"/.+".Yesword text
Words inside phonological groups[pho]Yesword text
Words inside special form groups[sin]Yesword text

Words EXCLUDED from %wor

FormExampleWhy excluded
Omitted words0is, 0detNever spoken (WordCategory::Omission)
CA-style omissions(word) in CA modeNever spoken (WordCategory::CAOmission)
Untranscribed placeholdersxxx, yyy, wwwNo alignable phoneme sequence; CTC alignment cannot produce timings for unknown material
Fragments&+fr, &+wIncomplete phoneme sequences; FA engine cannot reliably anchor partial phonological material (matches batchalign2 policy)
Nonwords&~gaga, &~umInteractional/gestural sounds without stable lexical phoneme content (matches batchalign2 policy)
Timing tokens100_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:

  • \u0015 is the Unicode control character U+0015 (NAK), used as the CHAT bullet delimiter
  • start_ms and end_ms are 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
ComponentRequiredNotes
Language codeNoInherited from main tier’s [- code]
WordsYesFlat list of cleaned_text values
Timing bulletsNoPer-word, optional
TerminatorYesSame 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::CountMatchedWorTimingCorrespondence::CorroboratedWorTimingSequence::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

  1. Forced alignment engines extract %wor word slots from the main tier AST via collect_fa_words()
  2. The FA model processes the audio and returns per-word [start_ms, end_ms] pairs (or null for unaligned words)
  3. Timings are injected back into the AST via inject_timings_for_utterance(), stored on each word’s timing_alignment field
  4. Post-processing (postprocess_utterance_timings) heals small gaps between words unless --pauses was given (WordGapHealing), and conditionally clamps word timings to the utterance bullet range. Clamping only applies when BOTH conditions hold: the bullet is BulletSource::Authoritative (not a runtime UTR hint) AND a %wor tier already exists (indicating this is a re-alignment, not a first-time run). On first-time alignment, e.g., after transcribe + 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-evidence policy 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 %wor refresh always uses compatibility preservation; rebuilding there would change audio windows and invalidate the controlled comparison.
  5. MainTier::generate_wor_tier() walks the AST one final time, collecting each spoken word slot’s cleaned_text and timing_alignment into a flat WorTier
  6. The WorTier is serialized via WriteChat into 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)IncludedExcluded
Nonwords (&~gaga)ExcludedExcluded
Fragments (&+fr)ExcludedExcluded
Untranscribed (xxx, yyy, www)ExcludedExcluded
Retraced groups (<...> [/])IncludedExcluded
Replacement (word [: repl])Original spoken wordReplacement text
Regular wordsIncludedIncluded
Omissions (0word)ExcludedExcluded
Tag separators (,, , )IncludedIncluded (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 of UtteranceContent and BracketedItem; 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/, WorSlotMembershipPolicy and its admits() 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() (uses walk_words)
  • FA word extraction: crates/batchalign/src/chat_ops/fa/extraction.rs, collect_fa_words() (uses walk_words)
  • Timing injection: crates/batchalign/src/chat_ops/fa/injection.rs, inject_timings_for_utterance() (uses walk_words_mut)
  • Timing postprocessing: crates/batchalign/src/chat_ops/fa/postprocess.rs, postprocess_utterance_timings() (uses both walk_words and walk_words_mut)
  • Word categories: talkbank-model/src/model/content/word/category.rs, WordCategory enum
  • Untranscribed status: talkbank-model/src/model/content/word/untranscribed.rs, UntranscribedStatus enum
  • Tier domains: talkbank-model/src/alignment/helpers/domain.rs, TierDomain enum

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

DirectionRust functionLocation
TextGrid → CHATpraat_to_chat(content) and praat_to_chat_with_options(...)crates/talkbank-clan/src/converters/praat2chat.rs:199, :204
CHAT → TextGridchat_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-clan crate: owns the TextGrid parser, serializer, and converters.
  • No Python runtime dependency: the previous Python implementation (batchalign/formats/textgrid/generator.py and the batchalign_core.extract_timed_tiers PyO3 binding) was retired when the converter moved into Rust. The praatio package still appears in pyproject.toml for 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

ExtensionCan soundfile read?Conversion needed?
.wavYesNo
.mp3YesNo
.flacYesNo
.oggYesNo
.mp4NoYes
.m4aNoYes
.webmNoYes
.wmaNoYes

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

  1. Check extension: if .wav/.mp3/.flac/.ogg, return unchanged.
  2. Check ffmpeg: if not on PATH, return a clear error with install hint.
  3. Fingerprint: stream every source byte through BLAKE3 and retain its full digest in the whole-pcm16-mono16k-strict-v3 recipe 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.
  4. Cache lookup: check the media cache directory for {fingerprint}.wav. If it exists, return immediately (cache hit).
  5. Lock: acquire exclusive fs2 file lock on {fingerprint}.wav.lock to prevent concurrent ffmpeg invocations for the same source file. This is important for parallel FA processing where multiple groups reference the same audio.
  6. Re-check: another task may have completed conversion while we waited.
  7. 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.
  8. Atomic rename: publish the produced slot while retaining its lock; release the lock only after the rename succeeds.

ffmpeg Arguments

FlagPurpose
-yOverwrite output without asking
-nostdinPrevent an unattended conversion from consuming terminal input
-v errorEmit error diagnostics, which prevent output admission
-xerrorStop decoding at the first error
-i sourceInput file (mp4, m4a, etc.)
-acodec pcm_s16le16-bit signed PCM (what soundfile reads natively)
-ar 1600016 kHz sample rate (FA/ASR model input rate)
-ac 1Mono (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 PathFilePurpose
FA (align)runner/dispatch/fa_pipeline.rsBefore audio identity + FA inference
Transcriberunner/dispatch/transcribe_pipeline.rsBefore ASR inference
Benchmarkrunner/dispatch/benchmark_pipeline.rs:process_one_benchmark_fileBefore Rust benchmark orchestration dispatches ASR
Media analysisrunner/dispatch/media_analysis_v2.rsBefore 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 read
  • output_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.

VolumeMP4MP3WAV
CHILDES7,98820,92411,042
aphasia2,9733,140601
ca1,8014,6964,139
phon1,4379,3129,018
fluency1,2171,12458
class4382619
tbi262145149
rhd1984251
asd1014737
slabank835,4783,649
open8200
homebank652,32022,455
psychosis36979479
samtale207372
dementia156,1172,456
psyling1300
biling0315228
motor000

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-cache or 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-cache for 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/--output omitted means direct-write behavior for mutating commands.
  • --file-list is 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-place is available on commands that use CommonOpts.

Exceptions:

  • batchalign3 opensmile INPUT_DIR OUTPUT_DIR
  • batchalign3 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.

AspectLocal CLIExplicit remote --server
Input files.cha files in IN_DIR.cha content sent over HTTP
Input mediaAudio referenced by @Media: header, found adjacent to .cha or via --media-dirServer 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 populatedSame .cha returned to the client, which writes it to the requested output path
MutationIf 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-cacheAll 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.

AspectLocal CLI / direct hostExplicit remote --server
Input files.mp3, .mp4, .wav files in IN_DIRMedia filenames only; the server must resolve the audio on its own filesystem
Extensions filter["mp3", "mp4", "wav"]Same
OutputNew .cha files (audio extension replaced: foo.wavfoo.cha)Same .cha files returned to the client, which writes them locally
MutationNever 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-sizeSame

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:

  1. Language ID succeeds before transcript submission: BA3 resolves the request to English up front, and the Rev request path matches explicit --lang eng.
  2. 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.

AspectLocal CLIServer (--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/replacedSame .cha returned as text
MutationIf OUT_DIR = IN_DIR: overwrites original .cha in place.Same
Key options--retokenize, --skipmultilang, --lexicon <CSV>, --override-media-cache, --merge-abbrevAll 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.

AspectLocal CLIServer (--server)
Input files.cha files in IN_DIR.cha content sent as text
Extensions filter["cha"]Same
Output.cha with utterance boundaries recomputedSame
MutationIf OUT_DIR = IN_DIR: overwrites original .cha in place.Same
Key options--lang, --num-speakers, --merge-abbrev, --utseg-fallback-stanzaAll 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.

AspectLocal CLIServer (--server)
Input files.cha files in IN_DIR.cha content sent as text
Extensions filter["cha"]Same
Output.cha with translation tiersSame
MutationIf OUT_DIR = IN_DIR: overwrites original .cha in place.Same
Key options--merge-abbrevPassed

What changes in the .cha: Translation tier added to each utterance.

No media involved.


7. coref

Purpose: Add coreference annotations to transcripts.

AspectLocal CLIServer (--server)
Input files.cha files in IN_DIR.cha content sent as text
Extensions filter["cha"]Same
Output.cha with coreference annotationsSame
MutationIf OUT_DIR = IN_DIR: overwrites original .cha in place.Same
Key options--merge-abbrevPassed

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.

AspectLocal CLIServer (--server)
Input files.cha files in IN_DIR.cha content sent as text
Gold filesFILE.gold.cha in same directory as FILE.chaGold 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 metricsSame, client writes both files to OUT_DIR
MutationIf OUT_DIR = IN_DIR: overwrites original .cha in place. Gold files are never modified.Same
Key options--lang, --merge-abbrev, --override-media-cacheAll 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.

AspectLocal CLIExplicit remote --server
Input files.mp3, .mp4, .wav files in IN_DIRMedia filenames only; the server must resolve the audio on its own filesystem
Extensions filter["mp3", "mp4", "wav"]Same
OutputNew .cha files with ASR output + eval metricsSame files returned to the client, which writes them locally
MutationNever mutates input. Creates new .cha files.Same
Key options--asr-engine, --lang, --num-speakers, --wor/--noworAll 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.

AspectLocal CLIExplicit remote --server
Input files.mp3, .mp4, .wav files in INPUT_DIRMedia 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
MutationNever mutates input. Creates new .opensmile.csv files in OUT_DIR.Same
Key options--feature-set (eGeMAPSv02, etc.), --langAll passed

Special output: This command produces non-CHAT output.


11. avqi

Purpose: Calculate Acoustic Voice Quality Index from paired .cs/.sv audio files.

AspectLocal CLIExplicit remote --server
Input filesPaired .cs.* and .sv.* audio files in input pathsMedia filenames only; the server must resolve the partner files on its own filesystem
Output.avqi.txt with metrics per file pairSame .avqi.txt files returned to the client, which writes them locally
MutationNever 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.

AspectLocal CLIExplicit remote --server
Input files.mp3, .mp4, .wav files in input pathsMedia filenames only; the server must resolve the audio on its own filesystem
Extensions filter["mp3", "mp4", "wav"]Same
OutputOne <media-stem>.turns.json file per inputSame JSON artifacts returned to the client, which writes them locally
MutationNever mutates input. Does not read or write CHAT.Same
Key options--num-speakers, --speaker-engine, --langSame

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)

CommandInputWhat changes
alignExisting .cha + audioAdds %wor tier, updates bullet times
morphotagExisting .chaAdds/replaces %mor + %gra tiers
utsegExisting .chaRecomputes utterance boundaries
translateExisting .chaAdds translation tier
corefExisting .chaAdds coreference annotations
compareExisting .cha + gold .chaWrites 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)

CommandInputOutput created
transcribeAudio files (.mp3/.mp4/.wav)New .cha files
benchmarkAudio filesNew .cha files with eval metrics
diarizeAudio filesNew .turns.json files
opensmileAudio filesNew .opensmile.csv files
avqiPaired .cs/.sv audioNew .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.

DirectionText/CHAT commands (morphotag, compare, …)Explicit remote audio commands (align, transcribe, opensmile, …)
Client → ServerFull .cha text (~2KB each).cha text for align, or media filenames for media-input commands
Server → ClientProcessed .cha textProcessed outputs returned over HTTP and written locally by the client
MediaNo media transferExecution 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_mode is set by the CLI dispatch layer. It is true for the local-daemon path and for an auto-detected loopback server; it is false for an explicit --server URL, even if that URL happens to resolve to localhost.
  • released_command_supports_paths_mode(command) is the authoritative predicate, defined at crates/batchalign/src/commands/mod.rs and re-exported from batchalign::lib.rs. It reads each command’s io_profile (the io_profile field of the command’s CatalogEntry, declared in crates/batchalign/src/recipe_runner/catalog.rs) and returns true for the PathsModeText and PathsModeAudio variants.
  • is_local_server(url) at crates/batchalign/src/cli/dispatch/single.rs returns true only for localhost, 127.0.0.1, and ::1 (loopback). Any non-loopback host is treated as remote, so a --server http://<your-server>:8001 submission 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

Commandio_profileWhy
alignPathsModeAudioForced alignment needs audio paths the server can open
transcribe / transcribe_sPathsModeAudioASR runs on server-visible media files
benchmarkPathsModeAudioComposite of transcribe + compare over server-visible media
diarizePathsModeAudioRuns speaker inference over server-visible media and writes turns JSON
avqiPathsModeAudioReads paired .cs/.sv audio directly from the filesystem
morphotagPathsModeTextServer-side runner reads CHAT input from source_paths
utsegPathsModeTextSame runner as morphotag
translatePathsModeTextSame runner as morphotag
corefPathsModeTextSame runner as morphotag
comparePathsModeTextReads main .cha + gold .cha pair by path
opensmileContentOnlyIntentional: 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

Directionpaths_mode=true (local daemon)paths_mode=false (remote --server)
Request bodyJobSubmission { 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 readRunner 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 writeRunner writes outputs directly to output_paths[i] on the shared filesystemRunner 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 listStructural ceiling; operators raise max_body_bytes_mb for large remote payloads
Where DirectHost fits inDirectHost 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 hopn/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_DIR to OUT_DIR for 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:

CaseCommandsWritten
@Options: dummyalign, morphotag, utseg, translate, corefUnchanged
@Options: NoAlignalignUnchanged
@Options: CA, where the command declines to analyzemorphotagUnchanged apart from the decision-tier strip the command declares
No analyzable payload collectedutseg, translateUnchanged

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

CommandI/O parityOptions parityDirect local pathNotes
alignFullFullFullMedia resolution differs (local path vs server lookup) but equivalent
transcribeFullFullFullWith 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_sFullFullFullTriggered by --diarize; follows the same local-daemon-vs-explicit-server rules as transcribe
morphotagFullFullFullLexicon CSV read on client, sent as parsed dict
utsegFullFullFull
translateFullFullFull
corefFullFullFull
benchmarkFullFullFullPrefers the local daemon when auto_daemon is enabled; explicit --server stays the fallback if the daemon path is unavailable
diarizeFullFullFullProduces anonymous turns JSON; shares validated raw/derived speaker evidence with integrated transcription
opensmileFullFullFullSpecial CSV output handling on both sides
compareFullFullFullGold file resolved locally or server-side
avqiFull (local)Full (local)FullPrefers 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

PathPurposeDefined in
~/.batchalign.iniUser 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.yamlServer/daemon configurationcrates/batchalign/src/types/config/ (directory module: layout.rs, server.rs, resolve.rs, tests.rs)

Runtime data

PathPurposeDefined in
~/.batchalign3/logs/Structured CLI run logs (run-*.jsonl) and exported log zipscrates/batchalign/src/cli/logs_cmd.rs
~/.batchalign3/server.pidHandshake for the main server: the PID and the port it actually bound, written by the server after its bind succeedscrates/batchalign/src/server_handshake.rs
~/.batchalign3/sidecar-server.pidThe same, for the transcribe sidecar daemoncrates/batchalign/src/server_handshake.rs
~/.batchalign3/server.logstderr log for manual batchalign3 serve startcrates/batchalign/src/cli/serve_cmd.rs
~/.batchalign3/daemon.jsonmain auto-daemon statecrates/batchalign/src/cli/daemon.rs
~/.batchalign3/daemon.lockmain auto-daemon startup lockcrates/batchalign/src/cli/daemon.rs
~/.batchalign3/daemon.logmain auto-daemon stderr logcrates/batchalign/src/cli/daemon.rs
~/.batchalign3/sidecar-daemon.jsonsidecar-daemon state for transcribe-heavy workloadscrates/batchalign/src/cli/daemon.rs
~/.batchalign3/sidecar-daemon.locksidecar-daemon startup lockcrates/batchalign/src/cli/daemon.rs
~/.batchalign3/sidecar-daemon.logsidecar-daemon stderr logcrates/batchalign/src/cli/daemon.rs
~/.batchalign3/jobs/per-job staging directoriescrates/batchalign/src/lib.rs, crates/batchalign/src/routes/jobs/mod.rs
~/.batchalign3/jobs.dbSQLite job persistence databasecrates/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 examplePath
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.rs
  • crates/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 examplePath
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.rs
  • crates/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.

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 timingNone (invisible to aligner)Automatic (two-pass recovery)
Backchannel %mor/%worNoneYes (own tiers)
Countable as utteranceNoYes
Main speaker alignmentUnaffectedUnaffected
Readability at densityPoor (3+ &* per line)Clean
Requires migrationNo (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:

  1. Pass 1: Build the global alignment reference from non-+< utterances only. Main-speaker words align correctly without backchannel interference.
  2. Pass 2: For each +< utterance, search the previous utterance’s audio window with adaptive widening to recover the backchannel’s timing.
  3. 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.

ReadingMeans
cwer well below werThe words are right and the PLACEMENT is wrong. Look at diarization and the merge stage.
cwer close to werThe 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:

CoverageMeaningLeftover main words
CompleteThe gold is a full reference for this recordingCharged as insertions
PartialThe gold covers only a slice, one timepoint, one speakerNot 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 (airplaneair plane), contraction expansion (he'she is), filler normalization (all fillers → um), abbreviation expansion (FBIF 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 fileExpected gold file
interview.wavinterview.cha
sample.mp3sample.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

OptionMeaning
--asr-engine NAMEASR engine (default: rev). --help prints the list, which is generated from the engine set.
--asr-engine-custom NAMEDeprecated alias for --asr-engine, still honoured, hidden from help
--lang CODE3-letter ISO language code (default: eng)
--num-speakers NNumber of speakers (default: 2)
--wor / --noworToggle %wor tier output
--merge-abbrevMerge abbreviations in output
--bank NAMELegacy remote media selector (unsupported in the current CLI; pass filesystem paths instead)
--subdir PATHLegacy 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 pycountry to resolve language names. Unknown codes raise ValueError.
  • Benchmark does not run utterance segmentation or forced alignment, it only transcribes and compares.

Implementation Details

ComponentFileKey function
Orchestratorcrates/batchalign/src/benchmark.rsprocess_benchmark() (line 44)
Per-file dispatchercrates/batchalign/src/runner/dispatch/benchmark_pipeline.rsprocess_one_benchmark_file() (line 185)
Gold file resolutioncrates/batchalign/src/benchmark.rsgold_chat_path_for_audio() (line 72)
WER computationcrates/batchalign-transform/src/compare/engine.rscompare() (line 173)
Word normalizationcrates/batchalign-transform/src/wer_conform.rsconform_words() (line 96)
CSV outputcrates/batchalign-transform/src/compare/metrics.rsformat_metrics_csv() (line 284)
%xsrep / %xsmor injectioncrates/batchalign-transform/src/compare/materialize.rsinject_comparison() (line 266)

See also


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 @s words yielded aggregate dispatch rate is high enough (well above 99% across 19 evaluated language pairs), the --experimental-l2-morphotag flag was removed and replaced with --no-l2-morphotag (opt-out for legacy L2|xxx behavior). 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 .cha files contain @s markers
  • 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

FormMeaningFrequencyExample
@sBare shortcut, toggles to secondary language from @Languages~74% of usesfilm@s
@s:CODEExplicit language code (ISO 639-3)~25%tienda@s:spa
@s:CODE+CODEMultiple languages (code-mixing at sub-word level)229 filesripiado@s:eng+spa
@s:CODE&CODEAmbiguous between languages290 fileswrap_o@s:eng&cym

Common Code-Switching Patterns

  1. Single isolated word: the tienda@s:spa is close .: one foreign word embedded in primary-language sentence
  2. Contiguous span: los@s:spa niños@s:spa: multi-word foreign phrase (a noun phrase, in this case)
  3. Utterance-initial: time@s out@s , kenne ich nicht .: English phrase at start of German utterance
  4. Mixed secondary languages: ok@s:eng damelo@s:spa in a Tzutujil utterance, two different foreign languages
  5. 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

OutputCross-linguistic?Rationale
deprel (dependency relation)YesUD relations are language-universal by design. advmod means “adverbial modifier” in every language.
Head indexYesSyntactic attachment is structural. muy attaching to nice is valid in any language.
UPOS (universal POS tag)MostlyDefined 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)YesIf the is det of tienda, then tienda is definitively a noun head.
XPOS (language-specific POS)NoEnglish Penn Treebank tags are meaningless for Spanish words.
LemmaNoUnknown words get identity-lemmatized.
Morphological featuresNoGender, 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 det dependent → it is a noun (definitively)
  • If a word has an nsubj dependent → it is a verb or adjective
  • If a word has an advmod dependent → it is a verb, adjective, or adverb
  • If a word has a case dependent → 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:

FieldValueCross-lingually valid?
UPOSADJ (wrong as English morphology)Partly, position suggests ADV
deprelamod or advmodYes
head4 (nice)Yes
lemmamuy (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 to adv|wake when the primary parser tagged it as advmod (common for German parsing English roots).
  • The particle (up) was always tagged adp|up because 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

VariantPolicyRationale
Single(lang)Dispatch to langUnambiguous target
Multiple(langs)Fall back to `L2xxx`
Ambiguous(langs)Fall back to `L2xxx`
UnresolvedFall back to `L2xxx`

Validation and normalization policy

Per-word L2 dispatch and transcript repair are intentionally separate concerns:

  • explicit @s:LANG still dispatches to LANG when possible, even if LANG is missing from @Languages, but validation emits warn-only E254 so the header drift is visible
  • whole-utterance same-language all-@s runs are rejected as E255; the canonical CHAT representation is utterance-level [- lang]
  • chatter debug fix-s is the normalization tool: it rewrites qualifying whole-utterance @s runs, clears the matching per-word shortcuts on fillers and nonwords as well as on regular words (a bare @s resolves 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 POSHead’s POSUpgraded deprel
ADVADJADVMOD
ADVVERBADVMOD
ADJNOUNAMOD
DETNOUNDET
NOUNVERB (with case dep)OBL
NOUNVERB (no case dep)OBJ
NOUNNOUNNMOD

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 wordWithout retokenizeWith retokenize
it's@s:engL2|xxx or pron|itspron|it~aux|be
don't@s:engL2|xxx or adv|dontaux|do~part|not
working@s:engnoun|workingnoun|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

  1. Isolated word POS ambiguity partially mitigated. The deprel constraint narrows POS for most words, but flat and root deprels leave the constraint set broad.
  2. Memory cost. Secondary models must be loaded alongside the primary model. Each Stanza model adds ~200-500 MB.
  3. Not all languages supported. Stanza covers ~70 languages, but some @s targets (e.g., @s:nan Taiwanese, @s:sun Sundanese , possibly mistagged in some corpora) have no model. In those cases the dispatcher falls back to L2|xxx; there is no silent wrong-analysis failure mode.
  4. GRA upgrade is heuristic. The FLAT→correct-deprel upgrade covers common cases but may miss language-specific constructions.
  5. MWT coverage inherits Stanza’s per-language MWT support. Contractions in @s words expand via the same mechanism as non-@s contractions (retokenize=true to the secondary model). Languages with Stanza MWT processors (English, French, Italian, Spanish, plus ~45 others) expand correctly; Swedish and a few others don’t.
  6. Phrasal-verb coverage is Stanza-model-dependent. The merge honors Stanza’s compound:prt analysis 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 return advmod, so the merge produces verb|look adv|after rather than verb|look part|after. Fixing these requires either a curated phrasal-verb lexicon or Stanza model improvements.

Implementation history

The implementation landed in four chunks:

DateCommit scopeWhat
(initial)feat: experimental L2 morphotag for @s code-switched wordsCore 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 expansionThree 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:prtPriority 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-onRenamed --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.

  • L2 Morphotag Literature Review, prior art survey
  • Transcriber $POS Hints, complementary post-pass that overrides %mor POS with transcriber annotations (default on; opt out via --no-pos-hints). Attacks the same FeaturePosMismatch error 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,eng at 99.8% and eng,yue,zho at 99.9%; 99.96% aggregate) triggered removal of --experimental-l2-morphotag and 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

  1. POS resolution priority: copula check → constraint agreement → closed-class function word override → NOUN/PROPN override → primary structural fallback → constraint best guess

  2. Secondary model’s NOUN/PROPN always trusted over primary deprel constraint (primary assigns wrong deprels to foreign words)

  3. GRA correction: when resolved POS contradicts primary deprel, infer correct deprel from POS + head POS

  4. 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 diagrams
  • l2-morphotag-literature.md: 11-citation literature survey
  • l2-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-@s patterns at validation time; transcripts should use [- lang].
  • E254 is warn-only when explicit @s:LANG names a language absent from @Languages; dispatch still uses the explicit target language.
  • chatter debug fix-s now repairs both transcript-side issues: it rewrites the qualifying whole-utterance @s pattern, 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:

  1. 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@sadv|give adp|up).
  2. The particle’s UPOS ADP was trusted by Priority 3 (closed-class) as adp|up, not the CHAT-conventional part|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 to Part, set corrected_deprel to compound:prt so the CHAT %gra tier becomes COMPOUND-PRT;
  • the current word is a phrasal-verb head (some sibling has deprel compound:prt with 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 on wake up / give up / pick up / time out, asserting verb|X part|up for the first three and noun|time adp|out for 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:engpron|it~aux|be, don't@s:engaux|do~part|not
  • golden_l2_morphotag_eng_spa: Spanish-English code-switching, ~90% acceptable
  • golden_l2_morphotag_deu_eng: German-English, ~95% acceptable
  • golden_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:

  1. "en" missing from MWT_LANGS → English pipeline loaded without MWT processor (dead English-specific branch in _stanza_loading.py)
  2. The Rust injection layer at crates/batchalign-transform/src/morphosyntax/injection.rs included Range parent tokens in the token vector → MOR count mismatch → retokenize_utterance() (at crates/batchalign-transform/src/retokenize.rs:195) failed
  3. 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:engpron|it~aux|be and don't@s:engaux|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 familyUD UPOSNotes
nNOUN
n:propPROPNrefinement crosses UPOS boundary
n:gerund, n:deverbal, …NOUNother n:* refinements stay NOUN
vVERB
adj, adj:att, …ADJ
adv, adv:temp, …ADV
pro, pro:per, pro:dem, pro:sub, pro:int, pro:relPRONsubtype isn’t tracked in UPOS
det, det:dem, det:poss, det:artDET
prep, postADPpostpositions for Hindi/Tamil/etc.
conjCCONJdefault coordinating
compSCONJcomplementizer (e.g. कि, “that”)
partPART
mod, auxAUX
qnDETUD has no separate quantifier UPOS
numNUM
co, int, intjINTJ
symSYM
punct, cm, end, begPUNCT
anything elseNoneunmapped, 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

FieldPreserved?
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 categoryoverwritten on disagreement
%gra relations✓ unchanged
Post-clitics (`~auxbeafterpron
%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

  1. Only applies to utterances with a %mor tier. If Stanza skipped an utterance due to MOR-vs-main count mismatch (MWT, comma-handling, etc.), no %mor exists, so no hint can apply. The hint pass records these as NoMorItem but takes no action. On the Hindi POC 36% of utterances fell into this category, a bigger quality issue than the hint feature addresses.
  2. Unknown CLAN tags are silent. The mapping is intentionally conservative: unknown tags return None, the record is logged as UnmappedCLAN, and Stanza’s POS is kept. Widening the mapping is a matter of adding entries to clan_ud_mapping.rs and the corresponding unit tests.
  3. Refinements don’t become UD features. $pro:dem could plausibly propagate PronType=Dem to %mor features, but today only POS category is overridden. A future revision could handle refinements → features.
  4. Transcriber errors propagate. If the transcriber wrote $v on a word Stanza tagged DET with full determiner features, the hint wins and produces verb|the…Det-features. A cross-check warning (feature vs POS consistency) is a candidate followup.
  5. No %gra deprel upgrade. Changing a word’s POS can make its %gra deprel inconsistent (e.g., NOUNVERB on an item with deprel OBJ). 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.

  • L2 Morphotag design, the feature the hint pass augments; $POS hints 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

TechniqueSourceOur implementation
Two monolingual models combinedSolorio 2008Primary (matrix lang) + secondary (embedded lang) Stanza
Language ID as POS constraintSoto 2018@s marker provides perfect LID
Monolingual models as foundationBhat 2018Stanza models for each language
UD framework for outputBhat 2018, BiLingua 2025Full UD-to-CHAT mapping pipeline

What Appears Novel

  1. 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 advmod must be an adverb in any language), but this property has not been exploited for code-switching POS tagging in the literature.

  2. 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 los as English proper noun “Los”). Not described in prior work.

  3. GRA deprel correction from resolved POS. Upgrading the primary model’s structural parse (e.g., FLAT to DET, OBL to DET) 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.

  4. 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).

  5. Integration with CHAT/TalkBank ecosystem. The @s marker 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

  1. 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.

  2. 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.

  3. 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.

  4. 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

  1. 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.

  2. 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.”

  3. Consider evaluation on HIENCS (Hindi-English CS UD treebank) to quantitatively validate our structural merge against gold-standard code-switching annotations.

  4. 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.


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 graphemes
  • WordContent::Shortening – elided material restored (e.g., (be)cause becomes because)

All other variants are excluded:

Excluded variantCHAT surface formExample
OverlapPointoverlap markersword (with embedded overlap points removed)
CAElementprosodic markersremoved
CADelimiterpaired prosodic markersremoved
StressMarkerprimary/secondary stressremoved
Lengtheningsyllable lengthening :removed
SyllablePausesyllable pause ^removed
UnderlineBegin/Endunderline control charsremoved
CompoundMarkercompound +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:

  1. Compound merging (3,584 known compound pairs after dedup; see crates/batchalign-transform/src/asr_postprocess/compounds.rs)
  2. Cantonese normalization (simplified to traditional + domain replacements, lang=yue only), once per monologue, before any splitting
  3. Multi-word splitting (space-separated tokens get timestamp interpolation)
  4. Number expansion via crates/batchalign-transform/data/num2lang.json (46 languages today) plus num2chinese.rs for CJK
  5. Long turn splitting (>300 words)
  6. Retokenization (split into utterances by punctuation)
  7. Disfluency replacement (um -> &-um, 'cause -> (be)cause)
  8. 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 PreparedRevProviderMedia artifact, with retry logic
  • Language specification (ISO 639-1 code or "auto" for auto-detection)
  • Speaker count hint for diarization
  • skip_postprocessing option

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):

  1. Rust parses CHAT into AST via parse_lenient()
  2. Rust extracts words via collect_utterance_content() using TierDomain::Mor
  3. Each word’s cleaned_text() is collected – this strips all CHAT markers
  4. Word texts are sent as Vec<String> in the MorphosyntaxBatchItem
  5. 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 classtokenize_pretokenizedtokenize_no_ssplittokenize_postprocessorMWT
should_request_mwt() == False (e.g. Japanese, Chinese, Korean, Swedish today)TrueTrueNoneNo
should_request_mwt() == True (e.g. English, French, German, Italian, Hebrew, Greek)(model decides)TrueCustom realignerYes (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\n as 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 (--retokenize with cmn/zho): A separate Stanza pipeline with tokenize_pretokenized=False is loaded. Words are joined without spaces ("".join(words)) and Stanza’s neural tokenizer segments them.

  • Cantonese retokenize mode (--retokenize with yue): PyCantonese’s segment() 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., word containing 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 get X (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:

  1. ferrous-opencc crate: simplified to traditional Chinese conversion
  2. 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

EngineTaskInput TypeText FormatPre-tokenized?CHAT Markers Safe?
Whisper ASRASRAudio onlyN/AN/AN/A
Rev.AIASRAudio onlyN/AN/AN/A
Stanza (morphosyntax)POS/lemma/depSpace-joined wordscleaned_text()Yes (most langs)No
Stanza (utseg)ConstituencySpace-joined wordscleaned_text()YesNo
Stanza (coref)CoreferenceSpace-joined sentencescleaned_text()YesNo
Whisper FAAlignmentSpace-joined words + audiocleaned_text()N/A (decoder input)No
Wave2Vec FAAlignmentWord list + audiocleaned_text(), lowercasedN/A (char-level CTC)No
Cantonese FAAlignmentWord list + audiocleaned_text(), jyutpingN/ANo
Tencent ASRASRAudio onlyN/AN/AN/A
Aliyun NLSASRAudio onlyN/AN/AN/A
FunASRASRAudio onlyN/AN/AN/A
PyCantonese (seg)SegmentationJoined charscleaned_text(), joinedOwn tokenizerNo
PyCantonese (POS)POS taggingWord listStanza output textN/ANo
Google TranslateTranslationJoined textcleaned_text()N/ANo
Pyannote/NeMoDiarizationAudio onlyN/AN/AN/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:

MarkerExampleMeaning
Category prefixes&-um, &~well, &+oh, 0wordFilled pause, filler, phonological fragment, omitted word
Form-type suffixesword@c, word@b, word@s:engChild form, babbling, second language
Overlap points(Unicode markers within words)Conversational analysis overlap
CA prosodic markers(Unicode markers within words)Pitch, stress, prosody
Lengtheningwo:rdLengthened syllable
Syllable pausewo^rdPause within word
Compound markerice+creamCompound word boundary
Stress markers(Unicode markers)Primary/secondary stress
Shortening parens(be)causeOmitted 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

CrateConsumed byPurpose
talkbank-modelbatchalign (runtime)CHAT data model, validation, alignment types
talkbank-parserbatchalign (runtime)CHAT parsing via tree-sitter
talkbank-transformbatchalign, batchalign-pyo3Pipelines, CHAT↔JSON, alignment, morphosyntax, Cantonese normalisation, ASR post-processing, tokenizer realignment
batchalign-typesbatchalign, batchalign-pyo3Shared domain newtypes + V2 worker IPC contracts

Compatibility Rules

  1. One workspace, one CI gate. Changes that cross talkbank-* and batchalign- crate boundaries land in the same PR; CI runs the whole workspace.
  2. Single-source build commands. PyO3 rebuilds go through uv run maturin develop -m crates/batchalign-pyo3/Cargo.toml -F pyo3/extension-module or the make batchalign-build-wheelmake batchalign-python-prepare chain. The standalone Rust CLI is cargo build -p batchalign (or make build for the dashboard-embedded release path).
  3. Cross-language IPC drift is gated by tests, not docs. The IPC contract is enforced by crates/batchalign/tests/worker_protocol_v2_compat.rs on the Rust side and batchalign/tests/test_worker_protocol_v2_types.py on the Python side, plus the scripts/check_ipc_type_drift.sh CI 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 LICENSE and pyproject.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)

PlatformTierCIWheelNotes
Linux x86_64AFull CI + clean-wheel CLI/server smokeYesPrimary CI platform
Linux ARM64BNative release buildYesBuilt on a native ARM64 runner; no execution smoke
macOS ARM (Apple Silicon)BClean-wheel CLI/server smokeYesRelease-smoke platform
macOS x86_64 (Intel)BNative release buildYesNo execution smoke
Windows x86_64BClean-wheel CLI smokeYesProcess lifecycle uses Unix APIs (pre_exec, setsid, killpg); server/worker mode is not supported

Dashboard (React)

PlatformTierNotes
All (web browser)BProduction bundle built in CI and embedded in release wheels; browser behavior is exercised by the dashboard test suite

Batchalign Desktop (experimental)

PlatformTierNotes
macOS / WindowsC (Experimental)In-repo Tauri shell only; not a supported public release surface
LinuxC (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.toml classifiers 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 build and make build-dashboard, which rebuild the embedded dashboard bundled into the Rust binary.
  • maturin – Required only if you modify the Rust batchalign_core extension.
  • 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, python and python3 may not exist outside a venv. Always use uv run to 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 changedWhat 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 changesmake 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:

  1. batchalign_core.so: the PyO3 extension (gives Python access to Rust CHAT parsing, alignment, etc.)
  2. 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:

  1. Packaged binary at batchalign/_bin/batchalign3: this is what GitHub Release installers and downloaded wheels use. The binary is bundled inside the wheel.
  2. Dev checkout at target/{debug,release}/batchalign3: for developers who built the CLI with cargo build.
  3. Cargo fallback: execs cargo run -p batchalign to 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 (or target/debug/batchalign3) into batchalign/_bin/ before running make batchalign-python-prepare if you need the installed-package experience. Most developers skip this and rely on the dev-checkout fallback (target/debug/batchalign3).
  • CI: A dedicated build-cli job 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 through BATCHALIGN_BIN. Every consuming job fetches it through the .github/actions/cli-binary composite 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.rs keeps the small command-shape enum used by command metadata.
  • crates/batchalign/src/text_batch.rs keeps reusable text-batch helper types for commands such as utseg, translate, and coref.
  • 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] not List[str], str | None not Optional[str].
  • Any and object are banned as type annotations. Use specific types. For ML library types that are expensive to import, use TYPE_CHECKING guards with the real type.
  • Use from __future__ import annotations for forward references where needed.
  • Prefer TYPE_CHECKING imports 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 batchalign3 CLI (validate, to-json, command-specific subcommands) and consume its structured output, or raise/catch batchalign_core.CHATValidationException at 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
  1. 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 test should never crash your machine.

  2. 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 with provenance::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_suite for wire formats, manifests, workflow helpers, and other fast contracts;
  • cli_integration_suite for CLI and server behavior using the shared test server fixture;
  • worker_integration_suite for 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 WaitSubject carries its own budget, and WaitBudget is 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 ProgressSnapshot is 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 PreparedWorkers backend 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:

LayerWhatCatches
ml-golden cargo featureml_golden carries required-features, so a plain cargo test cannot build itRoutine dev runs
make batchalign-test-ml-goldenML tests serialized via --test-threads=1Explicit opt-in
Global worker capmax_total_workers (RAM / 6GB, clamped to [2, 32])Multi-key pool explosion
WorkerPool::DropKills idle workers when pool is droppedTest cleanup on panic/exit
PID file reaper~/.batchalign3/worker-pids/ scanned on startupOrphans from crashed servers
pytest OOM guard (configure)Forces -n 0 when -m golden on < 128 GB machineStandard golden invocation
pytest OOM guard (collection)Aborts if golden tests collected with -n > 0 on < 128 GBOverridden addopts
pytest OOM guard (fixture)Per-test _guard_golden_oom autouse fixture fails in xdist workers on < 128 GBBelt-and-suspenders; cannot be bypassed
Claude Code guard hooksBlock workspace cargo test under memory pressure, and any cargo run concurrent with another in the same workspaceAI 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 Failed where Completed was asserted (~30).
  • HTTP 400 on content-job submission (~10).
  • Snapshot drift on the compare and coref goldens.
  • A rejection-message assertion now stale: the test expects an unsupported-language message, but morphotag now rejects job-level --lang outright (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:

SubmoduleWhatModels
goldenText NLP golden snapshotsStanza
golden_audioAudio transcription/alignmentWhisper, Wave2Vec, pyannote
golden_parityBatchalign2 output parityStanza
live_server_fixtureFull server with live workersMixed
profile_verificationWorker pool profile groupingWave2Vec, Stanza
option_receiptOption propagation differential testsStanza, Wave2Vec
error_pathsGraceful failure under live serverMixed

Test categories

CategoryToolCommandModelsRuntimeDefault
Rust unit testscargocargo test --workspaceNone~5sYes
PyO3 unit testscargocargo test --manifest-path crates/batchalign-pyo3/Cargo.tomlNone~3sYes
Python unit testspytestuv run pytestNone~2sYes
Worker protocolcargocargo test -p batchalign --test worker_integration_suite worker_protocol_matrix::None (test-echo)~5sYes
Server integrationcargocargo test -p batchalign --test cli_integration_suite integration::None (test-echo)~5sYes
Network fault (turmoil)cargocargo test --test turmoil_netNone<1sYes
Workflow helperscargocargo test -p batchalign --test contract_suite workflow_helpers::None~2sYes
JSON compatcargocargo test -p batchalign --test contract_suite json_compat::None~1sYes
ML tests (all)cargomake batchalign-test-ml-goldenMixed~5minNo
Python goldenpytestuv run pytest -m goldenbatchalign_core~10sNo
Python integrationpytestuv run pytest -m integrationWorker~5sNo
Cantonese ASR enginespytestuv run pytest batchalign/tests/languages/cantonese/FunASR+~2minNo

When to run ML tests

Run ML tests based on what changed, not as a habit:

What you changedRun
Rust unit logic (parser, DP, postprocess)Fast tests only
Workflow-family modules or compare/benchmark materializersworkflow_helpers + focused CLI tests
Python inference module--profile ml
Worker protocol or IPC typesworker_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 refactorFull --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 testRust counterpartWhat they verify
test_ipc_type_conformance.pyscripts/check_ipc_type_drift.sh (CI gate)Schema field parity between Rust and Python models
test_worker_ipc.pyworker_protocol_v2_compat.rsJSON roundtrip through both language’s serializers
test_worker_protocol_v2_types.pyworker_protocol_matrix.rsV2 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"):

  1. Tries to acquire a LiveServerSession with a warm worker pool
  2. Checks if the required InferTask is available (model installed)
  3. 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 + xtask lint-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

  1. No concurrent dispatch stress tests. The worker pool, job registry, and media walker have complex concurrency paths exercised only by test-echo integration 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.

  2. 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.

  3. No cross-platform CI. Tests run only on macOS (local) and Linux (CI). Windows is a supported platform but has no automated test coverage.

  4. 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>/):

FileMeaning
<ts>.logFull stdout+stderr. Ends with === TEST-BG COMPLETED: exit=N duration=Ns ===.
<ts>.statusExit code. File’s presence is the unambiguous “done” signal.
<ts>.metacmd, 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 testsWhat it does NOT test
HTTP request/response under partitionWorker subprocess crashes (use SIGKILL tests)
SSE stream behavior when client disconnectsSQLite WAL corruption or SQLITE_BUSY
Server restart and recoveryMemory gate and sysinfo behavior
Concurrent client requestsPython model loading failures
Message delay and reorderingLaunchAgent restart behavior
Health check timeout behaviorReal 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

TestWhat it exercisesFault injected
health_check_basicAdapter correctness, server responds over simulated TCPNone (baseline)
health_check_under_partitionPartition → timeout → repair → recoveryturmoil::partition() / repair()
health_check_with_message_holdDelayed response arrives after messages releasedturmoil::hold() / release()
concurrent_clients_health_check3 clients hit the server simultaneouslyConcurrency (no explicit fault)
server_crash_and_recoveryServer crash, bounce, new client reconnectssim.crash() / sim.bounce()

an operator/a user scenario tests

These model specific production problems the team experienced:

TestReal-world scenarioFault injected
one_way_partition_client_sends_but_no_responsea user submits jobs but never gets progress updates, responses are droppedturmoil::partition_oneway()
rapid_reconnection_burst_after_restartDeploy restarts server, 5 dashboard clients (an operator, a user, fleet monitors) reconnect simultaneouslysim.crash() / sim.bounce() + concurrent clients
network_flap_rapid_partition_cyclesFleet machines on unstable WiFi, Tailscale connection flaps repeatedly5x partition() / repair() cycles
slow_response_eventually_arrivesa user’s machine on slow WiFi, responses take 10+ seconds but eventually arrive intactturmoil::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).

TestReal-world scenarioFault injected
real_app_submit_and_poll_joban operator submits a job from the dashboard, polls for completionNone (baseline lifecycle)
real_app_partition_during_job_processingan operator submits a job, Tailscale drops, reconnects later, job completed during outageturmoil::partition() / repair()
real_app_sse_nonexistent_job_returns_404an operator bookmarks a deleted job URL, should get 404, not a hanging streamNone (error path)
real_app_health_reports_workersHealth endpoint must report actual worker stateNone (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 time
  • getrandom / HashMap randomization, 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:

  1. Submitting a job (needs ConnectInfo workaround or separate router)
  2. Connecting to the SSE stream
  3. Dropping the client connection mid-stream
  4. 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 ConnectInfo limitation
  • 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


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

kindCatches
no_zero_duration_wor_wordsFA emits all words in %wor but with start_ms == end_ms, or omits per-word bullets entirely.
min_wor_word_duration_msDP 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_msLast-word cutoff: the closing word of the utterance gets squished into a sliver. Threshold is per-fixture.
max_wor_word_duration_proportionFirst-word dominance: one word eats >N% of the utterance bullet.
max_main_tier_lead_before_first_wor_msStale 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_msMain-tier cutoff/overrun mismatch: the last timed %wor word ends far past the utterance bullet end. Threshold is per-fixture.
min_main_tier_utterance_countWhole-output under-segmentation: transcribe or utseg collapses the clip into too few main-tier utterances. Threshold is per-fixture.
max_first_main_tier_word_countFront-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_countDiarization regression: a fixture that requests diarize=true collapses back to too few distinct speaker labels. Threshold is per-fixture.
media_header_matches_input_basenameOutput-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

  1. Get a small, reproducible input. For CHAT-first commands, use a structured CHAT/audio trim tool that preserves timing bullets, rewrites the @Media header, and rebases word timings to the trimmed audio. For audio-first commands such as transcribe, stage the minimal audio clip the bug needs. Do not hand-roll a clip with ffmpeg, head, tail, or any other improvised pipeline, the trim helper handles CHAT header preservation, timing-bullet rebasing, @Media rewriting, and audio re-encoding fallback, and reinventing any of that produces fixtures that look right but silently mis-time alignment.

  2. Stage the command input into a new directory under test-fixtures/<command>/regressions/<command>-regression-NNN/ in your local checkout. Use input.cha for CHAT-first fixtures and input.<ext> for the required audio file. If the command consumes CHAT, rewrite the @Media line so the staged audio resolves locally.

  3. Run the command in the production CLI to capture the buggy output as actual.cha for 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
    
  4. 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.rs and the regression_fixtures::harness assertion runner, and document it in the table above.

  5. Write source.json with the manifest fields and README.md describing 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.

  6. 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_fixture does discovery, staging, dispatch, and assertion checking. If the fixture directory is not present locally the test skips cleanly.

  7. Run the test, confirm RED:

    cargo test -p batchalign --features ml-golden --test ml_golden \
          -E 'test(transcribe::regressions::transcribe_regression_001)'
    
  8. 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, or source.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:

  1. 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 of TalkBank/<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.
  2. Loads source.json into the typed FixtureManifest. If neither location has a source.json for the requested (command, bug) pair, the test reports SKIP rather than FAIL.
  3. Acquires a LiveDirectSession from the shared ml_golden worker pool. Skips cleanly if the relevant InferTask is unavailable.
  4. Stages the command’s primary input into the session’s state directory: input.cha for CHAT-first commands, the audio file for audio-first commands like transcribe. When a staged CHAT also has sidecar audio, the runner copies that alongside it so @Media resolves locally.
  5. Runs the command via submit_paths_and_complete_direct with the manifest’s language and a CommandOptions constructed from the command type.
  6. Parses the output CHAT via talkbank_transform::parse::parse_lenient (at ../chatter/crates/talkbank-transform/src/parse.rs:17) into a typed ChatFile AST. Asserts no parse errors.
  7. 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:

  1. 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.
  2. 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.
  3. 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: the LANGUAGE_MATRIX registry.
  • 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't into two words?
  • Does our postprocessor suppress the expansion for Catalan l'home?
  • Does Stanza’s Italian tokenizer keep arancione as 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 iI change Stanza’s POS for that word? (Answer: no, both tag as PRON.)
  • Does stripping the period from Dr.Dr produce a different POS? (Answer: no, both tag as PROPN.)
  • Does stripping the period from 3.143 change 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:

CommandRuns
uv run pytestFast 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 goldenAll 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:

  1. Add LanguageKey in _cases/__init__.py.

    CAT = LanguageKey(alpha2="ca", alpha3="cat")
    
  2. 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_free query Stanza’s runtime resources via _processors_for(lang) and include mwt only if the language has a model. No need to hardcode.

  3. Wire the language key into both resolvers (post_pipeline_for and free_pipeline_for). Add an entry to each resolver’s fixture-name dict.

  4. Write _cases/<lang>.py with a tuple of ProbeCase. Start with observe-only (no expected_post_mwt_count), the golden run tells you what Stanza produces; lock afterwards.

  5. Register the language in LANGUAGE_MATRIX in _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:

  1. Enumerate every rule in the reference system.
  2. For each, identify a probe in BA3 that exercises the same behavior.
  3. If no probe exists, add one.
  4. Run the golden matrix. Observe per-rule outcomes.
  5. 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=2 for don't asserts 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_&lt;lang&gt;.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 in asr_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 in crates/batchalign-transform/src/morphosyntax/lang_it.rs (two allowlists + map_ud_sentence plumbing), with synthetic-UD tests in crates/batchalign/src/chat_ops/nlp/mapping/mod.rs and end-to-end golden coverage in batchalign/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.

  • 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:

  1. Every model family auto-downloads on first use through the upstream library’s standard mechanism (Stanza’s DownloadMethod.REUSE_RESOURCES, HuggingFace’s from_pretrained(), torchaudio’s pipelines.MMS_FA.get_model()).
  2. No code in BA3 may opt out of these defaults. Specifically banned: local_files_only=True, HF_HUB_OFFLINE / TRANSFORMERS_OFFLINE forced 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.)
  3. Any download that would block the worker for more than a second emits a progress_v2 event with user-facing wording, propagated to every UI surface. See the time transparency principle.
  4. 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.

#FamilyLoad siteLibraryCache root
1Stanza morphosyntaxbatchalign/worker/_stanza_loading.py:99 load_stanza_modelsstanza.Pipeline(download_method=REUSE_RESOURCES)Stanza DEFAULT_MODEL_DIR
2Stanza utseg_stanza_loading.py:280 load_utseg_builder(same)(same)
3Stanza Chinese retok_stanza_loading.py:235 load_stanza_retokenize_model(same)(same)
4Stanza coref (lazy)batchalign/inference/coref.py:66-68stanza.Pipeline(...)(same)
5Whisper ASRbatchalign/inference/asr.py:119 load_whisper_asrtransformers.pipeline + WhisperProcessor.from_pretrainedHF
6Whisper FAbatchalign/inference/fa.py:114 load_whisper_faWhisperForConditionalGeneration.from_pretrained + WhisperProcessor.from_pretrainedHF
7Wave2Vec FAbatchalign/inference/fa.py:198 load_wave2vec_fatorchaudio.pipelines.MMS_FA.get_model()torchaudio hub
8Cantonese FAbatchalign/inference/languages/cantonese/_cantonese_fa.py load_cantonese_faWav2Vec2ForCTC.from_pretrainedHF
9SeamlessM4T translationbatchalign/worker/_model_loading/translation.py::_load_seamless_translateAutoProcessor.from_pretrained + SeamlessM4TModel.from_pretrainedHF
9bNLLB-200 translationbatchalign/worker/_model_loading/translation.py::_load_nllb_translateAutoTokenizer.from_pretrained + AutoModelForSeq2SeqLM.from_pretrained (facebook/nllb-200-distilled-1.3B, ~5 GB)HF
10pyannote diarizationbatchalign/inference/speaker.py:350Pipeline.from_pretrained("talkbank/dia-fork")HF
10bPyannote speaker embeddingbatchalign/inference/speaker_embedding.py::load_speaker_embedding_modelPretrainedSpeakerEmbedding(<pinned local ONNX path>)HF
11NeMo speaker (fallback)batchalign/inference/speaker.py (NeMo branch)EncDecSpeakerLabelModel.from_pretrained(...)NeMo cache
12BERT utterance (boundary model)batchalign/worker/_model_loading/utterance.py::load_utterance_model resolves the pinned snapshot; batchalign/models/utterance/infer.py::BertUtteranceModel loads itresolve_pinned_snapshot, then AutoTokenizer.from_pretrained + BertForTokenClassification.from_pretrained on the resolved LOCAL PATHHF
13PyCantonese(bundled),(none, wheel)
14Model manifestcrates/batchalign/src/model_manifest.rsNone: 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)
15Pinned Hugging Face snapshotbatchalign/worker/_model_loading/pinned_hub.py::resolve_pinned_snapshothuggingface_hub.snapshot_download(model_id, revision=…), then the commit is read off the resolved directoryHF

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.

ExitMeaning
0Every checkable pin still matches upstream.
1Some upstream has MOVED away from its pin; the report names both revisions.
2Some 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 three iic/... 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() calls build_stanza_capability_table(), which calls stanza.resources.common.load_resources_json().
  • On ResourcesFileNotFoundError (a subclass of FileNotFoundError), _bootstrap_and_retry() calls stanza.resources.common.download_resources_json(), emits start/complete progress_v2 events, 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).
  • ImportError on import stanza is 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 via try_to_load_from_cache; emits only when the model is genuinely about to download. Wraps every from_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=True in any from_pretrained() call
  • HF_HUB_OFFLINE or TRANSFORMERS_OFFLINE set inside BA3-controlled environment construction (test environments may set them externally, which is fine)
  • DownloadMethod.NONE in any Stanza Pipeline() 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.

LayerStorageTTLLocation
Hotmoka in-memoryPer-process lifetimeRAM
ColdSQLitePersistent~/.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.json absent + download succeeds → populated table returned.
  • test_bootstrap_raises_typed_error_on_download_failure: absent + download fails → StanzaCatalogDownloadError.
  • test_stanza_not_installed_returns_none: ImportErrorNone (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 suiteFirst-run downloadSubsequent runs
uv run pytest (default)PyCantonese: 0s (bundled)< 1s
uv run pytest -m goldenStanza English: ~2 min, Stanza Chinese: ~2 min< 30 s
cargo test -p batchalign --features ml-golden --test ml_goldenStanza + Whisper: ~5-10 min< 2 min

Adding a new model load site

  1. Identify the upstream library’s auto-download API (from_pretrained, Pipeline, get_model, etc.). Use it as-is. Do not pre-flight-check.
  2. 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 through resolve_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.
  3. Add a progress_v2 emit 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).
  4. Add a size-hint entry to _HF_SIZE_HINTS_GB if the model is > 100 MB, so the user sees a useful estimate.
  5. Update the user-facing chapter table with the new family + size + first-run wait estimate.
  6. Update this page’s inventory table.
  7. Add a golden-marked test that exercises a fresh download path.

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 batchalign3 command-line interface (subcommands, options, output formats, exit codes)
  • the typed CHAT files written to disk by batchalign3 commands
  • the OpenAPI schema exported by batchalign3 openapi for 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.providers re-export module
  • the batchalign_core PyO3 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:

  1. A new worker-side inference backend such as a new ASR or FA engine.
  2. 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.py
  • crates/batchalign-types/src/worker_v2/ (re-exported by crates/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() in worker/_stanza_loading.py

4. Wire dispatch and capability advertisement

Update:

  • batchalign/worker/_execute_v2.py to route the task or engine
  • batchalign/worker/_text_v2.py if the task belongs to the shared batched text host
  • batchalign/worker/_handlers.py to advertise infer_tasks, and, for forced alignment only, to name the engine in engine_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: translated and resolved items carry engine, and analyzed items carry model with 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 a None report. 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_supported in capability.rs, which decides what /health advertises and which jobs are accepted. Whether the FA engine is NAMED is read at dispatch: WorkerPool::ensure_command_capabilities loads the task on the selected worker and returns LoadedCapabilities (the task it loaded and the report taken after that load), and the forced-alignment arm in runner/routing.rs reads the engine with FaCacheNamespace::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 accepts align; dispatch loads FA and reads the engine then. FaCacheNamespace is a plain newtype over ReportedEngineName, 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 in dependencies so that a standard install gives users everything.

  • Built-in engines with extra runtime dependencies: add them to dependencies if 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:

EnumFileRole
AsrEngineNamecrates/batchalign/src/types/engines.rsUser-facing type. Wire name, parsing, dispatch-key lookup.
AsrBackendV2crates/batchalign-types/src/worker_v2/requests.rsIPC 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.
AsrWorkerModecrates/batchalign/src/transcribe/types.rsServer-side dispatch selector that bridges the other two.

Helpers each variant must appear in:

FunctionFilePurpose
AsrEngineName::wire_name()types/engines.rsRust→string for JSON/SQLite.
AsrEngineName::try_from_wire_name()types/engines.rsString→Rust at boundaries.
AsrEngineName::dispatch_override_name()types/engines.rsPool key (must equal wire_name or None).
AsrWorkerMode::from_engine_name()transcribe/types.rsWire-string → worker-mode variant.
AsrWorkerMode::as_v2_backend()transcribe/types.rsWorker-mode → IPC backend.
AsrBackend::provenance_name()transcribe/types.rsCanonical engine identity for production transcript provenance and warnings.
asr_backend_engine()crates/batchalign/src/worker/pool/execute_v2.rsMaps 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.rsMatch 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:

ObligationWhereWhy it is not optional
The ASR result carries AsrModelIdentityV2your worker-side runner builds it; typed in crates/batchalign-types/src/worker_v2/responses.rsmodel 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 modelcrates/batchalign/src/model_manifest.rsAn 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 SpeakerAttributionV2responses.rs::AsrMonologueV2::speakerA 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):

  • AsrEngine in batchalign/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() in crates/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:

  1. test_<engine>_wire_roundtrip: AsrEngineName::<Engine>.wire_name() roundtrips through try_from_wire_name. Already a common idiom; add the variant to the existing test module.
  2. test_<engine>_worker_mode_lowers_correctly: AsrWorkerMode variant lowers to the right AsrBackendV2 and back.
  3. test_<engine>_loader_dispatch: worker bootstrap’s load_asr_engine() routes engine_overrides["asr"]=="<engine>" to your new loader function.
  4. 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 expect generate() to receive. This is the test that catches the fine-tune trap above.
  5. test_<engine>_resolves_model_id_for_seeded_language: if your engine uses per-language defaults from resolve.py, pin the seed entry with a direct assertion on resolve("<engine>", lang).
  6. 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.
  7. test_<engine>_result_carries_model_identity: build the result your runner returns and assert its model names every model the engine loads, each at the revision it was OBSERVED at rather than the one that was requested.
  8. 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.
  9. test_<engine>_monologue_speaker_attribution: if your engine produces monologues, assert an undiarized result carries the undiarized attribution 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:

  1. Add a match arm inside crates/batchalign-pyo3/src/worker_asr_exec.rs::run_asr that routes the new variant. The compiler will catch the missing arm if you let it, but only after you rebuild the PyO3 extension.
  2. Rebuild batchalign_core: make batchalign-python-prepare (which produces a fresh wheel via the maturin backend declared in pyproject.toml and reinstalls it into the dev environment), or run any uv run … command to trigger an incremental rebuild against [tool.maturin] profile = "dev". Running make build alone (which does cargo build --workspace --release) compiles the PyO3 crate but does not install the resulting .so into the Python environment that workers import from. Without the install step, workers silently load the previously installed batchalign_core extension, 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.
  3. Also update the Python AsrBackendV2 enum in batchalign/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 regenerated ipc-schema/, so run bash scripts/generate_ipc_types.sh after 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:

  1. crates/batchalign-types/src/command_spec.rs: Add a CommandSpec entry to COMMAND_SPECS. Then run cargo xtask gen-runtime-toml to regenerate batchalign/runtime_constants.toml (the generated file is the shared Rust/Python source of truth; do not edit it directly).
  2. batchalign/worker/_handlers.py: Add the InferTask to _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.
  3. batchalign/worker/_model_loading/: Register the dynamic runtime host for the new task if it depends on loaded model state or engine-specific wiring. Reserve batchalign/worker/_execute_v2.py for 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.

CapabilityHow to checkAffects
ISO 639-3 codepycountry, talkbank-types::LanguageCode3Everything 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.mdDefault ASR engine choice
Stock Whisper quality?Same: run a representative sample, evaluateFallback ASR engine choice
HuggingFace fine-tune available?Search HF Hub for whisper-*-{lang} checkpointswhisper_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.rsWhether E220 fires on Whisper digit emissions
PyCantonese / language-specific tools?Per-language: relevant for CJK, possibly othersSpecial-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.rs and crates/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>.rs module: 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 by num2chinese in crates/batchalign-transform/src/asr_postprocess/num2chinese.rs.
  • English ordinals/years/decades: handled by crates/batchalign-transform/src/asr_postprocess/ordinal_year_eng.rs via deterministic composition rules.
  • All other cases: per-language NUM2LANG table at crates/batchalign-transform/data/num2lang.json. The table is the offline-codegenned output of a num2words sweep; 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:

  1. Add a hand-curated overlay (digits 0-9 and the common compounds you need) following the procedure in the number-expansion page.
  2. Add the language to the digit-allowed list via language_allows_numbers in ../chatter/crates/talkbank-model/src/validation/context.rs:34 (consulted by the validator through mixed_language_allows_numbers in ../chatter/crates/talkbank-model/src/validation/word/language/helpers.rs:57, which gates digits.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:

  1. Stock Whisper (--asr-engine whisper): fast, broad coverage, no per-language config. Good baseline.
  2. HuggingFace fine-tune via whisper_hub: when stock Whisper or Rev.AI underperform on extended recordings. Configure model resolution in crates/batchalign/src/model_manifest.rs::WHISPER_HUB_DEFAULTS, naming the hub commit to pin it to; that is what a planned job resolves from.
  3. Rev.AI (--asr-engine rev): only if it produces clean output for this language. Many languages return garbage from Rev.AI; see book/src/batchalign/reference/revai-language-quality-strategy.md for the canonical Malayalam-failure case study.
  4. 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”:

  1. ASR: short audio (< 60s), confirm transcribed text matches expected script.
  2. Number expansion: feed an utterance containing a spoken number (“I have three books”), confirm output is spelled, not digits.
  3. 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.
  4. Morphotag (if Stanza-supported): confirm %mor and %gra tiers inject without count-mismatch errors.
  5. FA (if attempted): confirm word-level timings appear and %wor tier is generated.

Any failure on steps 1-3 means the language is not yet ready for user-visible support, adjust integration before merging.

  • book/src/batchalign/reference/languages/overview.md: language index
  • book/src/batchalign/reference/revai-language-quality-strategy.md: when to switch away from Rev.AI
  • book/src/batchalign/reference/whisper-hub-asr.md: HuggingFace fine-tune routing
  • crates/batchalign/CLAUDE.md: batchalign crate map
  • Number Expansion, protocol for refreshing crates/batchalign-transform/data/num2lang.json and the hand-curated overlay (the historical scripts/codegen_num2lang.py script 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.

CommandFamilyBest exampleWhat to read
AudioSequential (one media file at a time)alignALIGN_RECIPE, runner/dispatch/fa_pipeline.rs
BatchedText (pooled batch-infer)morphotagMORPHOTAG_RECIPE, runner/dispatch/infer_batched.rs
ReferenceProjection (compare against gold)compareCOMPARE_RECIPE, compare.rs
MediaAnalysis (non-CHAT artifact out)opensmileOPENSMILE_RECIPE, runner/dispatch/media_analysis_v2.rs
Composite (reuses other recipes)benchmarkBENCHMARK_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:

StepFileWhat you add
1batchalign-types/src/domain.rsReleasedCommand::YourCommand variant
2batchalign/src/recipe_runner/recipes.rsYOUR_COMMAND_RECIPE: the ordered stages
3batchalign/src/recipe_runner/catalog.rsone CatalogEntry declaring every field
4batchalign/src/your_command.rs or shared runner codeCore logic (ML dispatch, post-processing)
5batchalign/src/cli/args/commands.rsCLI arg struct
6batchalign/src/cli/args/mod.rsCommandProfile match arm
7batchalign/src/cli/args/options.rsCommandOptions 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:

  1. Add an InferTask variant (crates/batchalign-types/src/worker.rs) and its stable snake_case label in worker/target.rs::task_name
  2. Add a WorkerProfile mapping in crates/batchalign/src/worker/registry.rs
  3. 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 RustFile
_find_best_segment(): bag-of-words window searchtalkbank_transform::compare::find_best_segmentcrates/batchalign-transform/src/compare/engine.rs:72
CompareEngine.process(): local window alignment + token statustalkbank_transform::compare::compare()crates/batchalign-transform/src/compare/engine.rs:173
CompareAnalysisEngine.analyze(): metrics CSVCompareMetricsCsvTable / CompareMetricsCsvRowcrates/batchalign-transform/src/compare/metrics.rs:8,103
gold document projectionproject_gold_structurally()crates/batchalign-transform/src/compare/materialize.rs:209
compare data model (bundle, utterances, metrics, word matches)ComparisonBundle / UtteranceComparison / CompareMetrics / GoldWordMatchcrates/batchalign-transform/src/compare/model.rs:75,27,38,92
tier serialization modelsXsrepTierContent / XsmorTierContentcrates/batchalign-transform/src/compare/serialize.rs:186,228
Document / Utterance / Form modelChatFile AST + dependent tierstalkbank-model
CLI dispatch morphosyntax -> compare -> compare_analysisbuild_comparison_artifacts() + released/main-annotated materializerscrates/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:

  1. 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 BA2
    • compare(&main, &gold) (engine.rs:173) → ComparisonBundle with main/gold compare views, structural word matches, and metrics
    • project_gold_structurally() (materialize.rs:209), AST-first gold projection
    • XsrepTierContent / XsmorTierContent (serialize.rs:186,228), typed compare-tier models lowered once at the UserDefinedDependentTier boundary
    • CompareMetricsCsvTable / CompareMetricsCsvRow (metrics.rs:8,103), typed metrics rows serialized through the Rust csv crate
  2. crates/batchalign/src/compare.rs: orchestration:

    • build_comparison_artifacts(): morphotag main only, parse gold raw, call compare()
    • materialize_released(): released compare output path
    • materialize_main_annotated(): internal benchmark/main output path
  3. execution/: recipe-driven server integration (new model):

    • dispatch_compare_job() builds a JobPlan and runs ExecutionKernel
    • CompareStageExecutor handles recipe stages: plan work units, read inputs, morphosyntax, compare-align, materialize outputs
    • Resolves gold file from *.gold.cha companion via planner

How to extend structural gold projection

The gold materializer is no longer a stub. Extend it by working with typed data:

  1. Edit project_gold_structurally() in crates/batchalign-transform/src/compare/materialize.rs:209 (the actual implementation; crates/batchalign/src/compare.rs:27,140 only re-exports and calls it).
  2. Use ComparisonBundle.gold_word_matches and AST accessors, not %xsrep / %xsmor strings, as the projection source.
  3. Keep the current safety rules explicit: exact matches may copy %mor / %gra / %wor; full gold-word coverage may project %mor; partial %gra / %wor needs chunk-safe mapping before it is allowed.
  4. 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)

  1. crates/batchalign-transform/src/compare/: compare core (engine.rs, model.rs, materialize.rs, serialize.rs, metrics.rs)
  2. crates/batchalign/src/compare.rs: orchestration + materializers
  3. crates/batchalign/src/execution/: recipe-driven dispatch (replaces old compare_pipeline.rs)
  4. crates/batchalign/src/planning/: build_job_plan() for typed execution plans
  5. book/src/batchalign/migration/ba2-compare-migration.md: BA2-master compare to BA3 map
  6. 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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: AlignArgsUTR/FA engine flags, strategy, fuzzy, buffer params
Options buildercrates/batchalign/src/cli/args/options.rs:130-194 (inline dispatch)Maps AlignArgsCommandOptions::Align(AlignOptions)
Command definitioncrates/batchalign/src/commands/align.rs: AlignCommandCommandDefinition impl, pre-validation gate
FA pipelinecrates/batchalign/src/runner/dispatch/fa_pipeline.rsPer-file FA orchestration: UTR → grouping → FA → injection
UTR dispatchcrates/batchalign/src/runner/dispatch/utr.rsResolved strategy construction and per-recording grouping context
UTR librarycrates/batchalign/src/chat_ops/fa/utr.rsrun_utr_pass(), inject_utr_timing(), partial-window logic
FA librarycrates/batchalign/src/chat_ops/fa/Grouping, extraction, DP alignment, injection, postprocessing
Worker boundarybatchalign/worker/_fa_v2.py + crates/batchalign-pyo3/src/worker_fa_exec.rsRust owns request validation and V2 response shaping; Python hosts model callbacks
Model callbackbatchalign/inference/fa.pyWhisper token onsets or indexed Wave2Vec word intervals with optional model score
Durable evidencecrates/batchalign/src/types/traces.rs, runner/debug_dumper.rsVersioned, 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:

  1. Operator-reported alignment regressions on real files
  2. 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.
  3. Two-pass algorithm was only tuned on 4 corpora, not broadly validated

Explicit overrides:

  • --utr-strategy globalGlobalUtr (single-pass monotonic recovery)
  • --utr-strategy two-passTwoPassOverlapUtr (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.

See Incremental Processing.


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() reads FaEngineName::max_group_ms(), which is the max_group field of that engine’s row in FA_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 in grouping.rs, counted through the LabelBytes newtype). Whisper’s CTC FA refuses more than 448 label TOKENS and raises a hard Python ValueError. 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):

  1. Narrow bullet rescue: utterances whose bullets were pre-expanded before grouping (see “Pre-grouping preparation steps”)
  2. FA word timing injection: word boundaries, timing drops, speech gaps
  3. Experimental bullet repair: only if --bullet-repair flag is enabled
  4. 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, or inference) 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 by FaTimingDecisionTrace::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, UTR
  • crates/batchalign/tests/: integration tests for the FA pipeline


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: TranscribeArgsTyped ASR and speaker engines, diarization, lang, num-speakers
Options buildercrates/batchalign/src/cli/args/options.rs:195-243 (inline dispatch)Maps TranscribeArgsCommandOptions::Transcribe(TranscribeOptions)
Catalog entrycrates/batchalign/src/recipe_runner/catalog.rsthe CatalogEntry for transcribe
Stage recipecrates/batchalign/src/recipe_runner/recipes.rsTRANSCRIBE_RECIPE
Pipeline orchestrationcrates/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 dispatchcrates/batchalign/src/runner/dispatch/transcribe_pipeline.rsConcurrent file orchestration bounded by semaphore
ASR post-processingcrates/batchalign-transform/src/asr_postprocess/mod.rs8 stages: compound merge, MWT split, number expand, Cantonese norm, long-turn split, retokenization, disfluency, retrace detection
Pre-CHAT utterance segmentationcrates/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 assemblycrates/batchalign-transform/src/build_chat/mod.rs:41: build_chat()Assembles ChatFile AST from TranscriptDescription (typed bridge)
Speaker projectioncrates/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 cachecrates/batchalign/src/transcribe/evidence_cache.rsSeparate raw/derived identities and envelopes, per-key lease, typed miss authorization, durable commits, fakeable inference boundary
Shared speaker operationcrates/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 plancrates/batchalign/src/runner/dispatch/plan.rs: MediaAnalysisDispatchPlan::DiarizeCarries an already-resolved backend, optional expected count, and cache policy; execution cannot invent or change them
Same-job turn retentioncrates/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 schemacrates/batchalign/src/runner/dispatch/diarize_turns.rsSerializes chatter-compatible turns with backend-derived provenance
ASR worker IPCbatchalign/inference/asr.pyPython-hosted ASR engines; Rev is Rust-owned
Raw Rev evidence cachecrates/batchalign/src/revai/evidence_cache.rsProvider-media identity, exact transcript JSON or typed legacy envelope, miss authorization, durable commit, fakeable Rev boundary
Speaker worker IPCbatchalign/inference/speaker.py: infer_speaker_prepared_audio()Exhaustive dispatch over pyannoteAI, local Pyannote, and NeMo, returning a backend-specific evidence variant
Local Pyannote graphbatchalign/inference/local_pyannote_model.json + pyannote_local.pyOne validated manifest pins the pipeline, segmentation, and embedding commits; Rust hashes the same manifest into the evidence revision
pyannoteAI adapterbatchalign/inference/pyannote_ai.pyTyped 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

  1. Compound merging: rejoin compound words split by ASR

    • Language-specific: English phrasal verbs, CJK terms, etc.
    • Implemented: compounds::merge_compounds()
  2. 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-opencc crate + the 31-entry replacement table
    • Implemented: cantonese::AlignedNormalization, the only route to normalized Cantonese text, applied by prepare_words_pre_expansion()
    • A conversion that changed the character count refuses the file rather than re-cutting words away from their timings
  3. Multi-word token splitting: split tokens containing spaces, interpolate timestamps

    • Normalizes ASR outputs that glue multiple words together
    • Distributes timing proportionally by text length
  4. Number expansion: convert digit strings to word form

    • Cardinals: 47 languages via NUM2LANG static table (data/num2lang.json)
    • CJK: specialized num2chinese path
    • Ordinals/decades: English-specific ordinal_year_eng composer
    • Currency, percent, dash-ranges: dedicated Rust handlers
    • Runtime: Pure Rust table lookup (Python num2words involved at build time only for codegen; removed from runtime 2026-04-26)
  5. Long-turn splitting: chunk monologues >300 words

    • Prevents unbounded utterance lengths in downstream processing
  6. Retokenization: punctuation-based utterance splitting

    • Splits by CHAT-legal sentence terminators (. ? ! +... etc.)
    • Handles long-pause splitting when ASR omits punctuation
  7. 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()
  8. N-gram retrace detection: detect repeated n-grams, wrap in <...> [/] annotation

    • Identifies speaker self-corrections (rephrasings)
    • Implemented: cleanup::apply_retrace_detection() over shared analyze_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 from UTSEG_BOUNDARY_MODELS in crates/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 a matches!(lang.as_ref(), "eng" | "cmn" | "zho" | "yue") here that was one of three copies of that set, the others being a list in utseg_route.rs and the key set of _RESOLVER["utterance"] in batchalign/models/resolve.py, which the worker used to decide whether to refuse and which no longer exists
  • A language whose route is StanzaFallback takes 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_job refuses the job before ASR is dispatched (DispatchPlanRefusal::UtsegUnavailable). Under --lang auto the language is unknown until ASR returns, so the same route resolution refuses here instead, which is as early as it can be known
  • Workflow:
    1. Prepare ASR chunks (stages 1-8 above)
    2. If dedicated diarization ran, project its segments onto timed words with project_speakers_onto_chunks() and split chunks at speaker changes
    3. 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
    4. Apply split_prepared_chunk_by_assignments() to split chunks at boundaries
    5. When --debug-dir is enabled, atomically persist the versioned pre_chat evidence before it can be erased to assignments
    6. 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:

  • Disabled makes neither pass reachable.
  • PreChatOnly is 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:

ChoiceMeaning
bothApply the selected policy on both production passes.
pre-chat-onlyApply it before CHAT and omit the post-CHAT pass. This changes topology.
policy-on-pre-chat-onlyKeep both passes; apply the selected policy only before CHAT.
policy-on-post-chat-onlyKeep 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:

  1. Acquire the process-local lease for the semantic cache key.
  2. Validate and replay derived segments when present.
  3. On a derived miss, validate retained raw evidence, normalize it under the current SpeakerNormalizationRevision, and commit a new derived envelope.
  4. Only when raw evidence is also absent, produce a typed SpeakerEvidenceMiss and consume it into SpeakerInferenceAuthorization.
  5. Split the authorization into a single-use run and commit permit; reread and verify the source digest, producing VerifiedSpeakerEvidenceRun.
  6. Prepare worker PCM from the verified run’s owned bytes and cross the SpeakerEvidenceInference boundary exactly once.
  7. 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 lang field in response (e.g., “spa”, “fra”, “eng”)
  • This becomes the resolved language for CHAT headers and NLP stages (utseg, morphosyntax)
  • Implemented: resolved_asr_language() in crates/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 with lang_detect::detect_utterance_language()
  • Detected language stored in Utterance.lang field
  • 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


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: MorphotagArgs, MorphotagPolicyArgsI/O, tokenization, multilingual handling, lexicon, and analysis policy (--no-l2-morphotag, --no-pos-hints, --ca-policy)
Options buildercrates/batchalign/src/cli/args/options.rs (inline dispatch)Maps CLI values to wire-compatible MorphotagOptions
Command definitioncrates/batchalign/src/commands/morphotag.rsCommandDefinition impl, pre-validation gate
Runtime policycrates/batchalign/src/types/params.rs: MorphotagExecutionPolicyLowers wire booleans into typed L2, $POS, and CA policies
CA dispositioncrates/batchalign/src/morphosyntax/mod.rs: MorphotagDispositionProduces Analyze or PassThroughCa once from the parsed header and submitted policy
Morphosyntax orchestrationcrates/batchalign/src/morphosyntax/Cross-file batching, worker dispatch, result injection
Batch dispatchcrates/batchalign/src/runner/dispatch/infer_batched.rsPools all files into a single ML call
Injectioncrates/batchalign-transform/src/morphosyntax/injection.rsinject_results(): writes %mor/%gra from typed UD annotations
Retokenizationcrates/batchalign/src/retokenize/Character-level DP for Stanza word splits/merges
Payload collection & injectioncrates/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 IPCbatchalign/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 + regex
  • batchalign/inference/morphosyntax.py: call site inside batch_infer_morphosyntax after doc.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 reproducer
  • batchalign/tests/pipelines/morphosyntax/test_control_token_leak_propagation.py , integration test through batch_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:

LayerHandlesResponse
Python ingress filterUpstream library produces bad individual tokensStrip + log; file lands clean
Rust failure propagationPool-level failure produced no response at allPer-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:

  1. clear_morphosyntax replaces the Mor/Gra entries in place with empty MorTier::new_mor(Vec::new()) and GraTier::new_gra(Vec::new()). Original tier position is retained.
  2. inject_results writes into the same slots.
  3. remove_empty_morphosyntax_placeholders is called at serialize time to remove any still-empty placeholders (utterances where no morphosyntax was produced).
  4. crates/batchalign/src/chat_ops/fa/mod.rs::add_wor_tier (line 244) uses replace_or_add_tier instead of the old remove_wor_tier + push sequence, 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&lt;L2DeferredPosition&gt;<br/>(@s words + primary UD)
    Orch->>Spans: group_deferred_into_dispatch_spans()
    Spans-->>Orch: Vec&lt;DispatchSpan&gt;<br/>(contiguous same-lang)
    loop For each target language
        Orch->>Secondary: infer_batch(retokenize=true)<br/>per-span batch items
        Secondary-->>Orch: Vec&lt;UdResponse&gt;
        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/):

ModuleResponsibility
extract.rsWalks primary UD response, picks out @s-word positions and their primary structural info (deprel, head, UPOS, dependents).
spans.rsGroups deferred positions into contiguous same-language spans for per-span Stanza dispatch.
merge.rsPOS resolution priority chain including Priority 0 (compound:prt phrasal-verb recognition) and Priority 1-6 (constraint-based).
deprel.rsUdDeprel newtype, deprel→POS constraint mapping, deprel inference from resolved POS.
splice.rsReplaces L2|xxx with the merged MOR + corrected GRA in the CHAT AST.
crates/batchalign/src/morphosyntax/batch.rsThin 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-@s patterns. Morphotag does not auto-normalize those utterances; the transcript must use [- lang].
  • E254 is warn-only for explicit @s:LANG markers whose LANG is absent from @Languages. Dispatch still uses the explicit target language; the warning is about header drift, not routing failure.
  • chatter debug fix-s is the companion repair tool. It rewrites qualifying whole-utterance @s runs 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 $POS annotations found
  • hints_agreed: transcriber POS matched Stanza output (no change needed)
  • hints_overridden: Stanza POS overridden by transcriber annotation
  • hints_unmapped: transcriber POS code not in the CLAN↔UPOS mapping table
  • hints_skipped_no_mor: utterance had no %mor tier 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 %mor tier has a corresponding %gra tier
  • 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::


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: UtsegArgslang, num-speakers
Catalog entrycrates/batchalign/src/recipe_runner/catalog.rsthe CatalogEntry for utseg
Stage recipecrates/batchalign/src/recipe_runner/recipes.rsUTSEG_RECIPE
Utseg orchestrationcrates/batchalign/src/utseg.rsThe 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 IPCbatchalign/inference/utseg.pyReturns direct model assignments with evidence, or Stanza trees
Python model evidencebatchalign/models/utterance/evidence.pyClosed actions, fixed-point probability, omission/bypass states
Canonical IPC evidencecrates/batchalign-types/src/worker_v2/utseg_evidence.rsRust wire enums and validated probability newtype
Evidence artifactscrates/batchalign/src/utseg_evidence.rsVersioned pre/post-CHAT transcribe traces, atomic sink, and the admission that reads one back
Evidence replaycrates/batchalign/src/cli/eval_cmd/utseg_replay.rseval utseg-replay: reapplies a retained sidecar and compares the result with the retained output
Boundary applicationcrates/batchalign-transform/src/utseg.rsMaps 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 carries boundary_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_MODELS is 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: a matches! in pipeline/transcribe.rs, a BOUNDARY_MODEL_LANGUAGES list in utseg_route.rs, and the key set of _RESOLVER["utterance"] in batchalign/models/resolve.py, which the worker consulted to decide whether to refuse. Availability is now a CONSEQUENCE of the pin (has_boundary_model asks 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 an utterance family 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 UtsegRoute are runnable. A Refused variant would have been a value every consumer had to remember to reject, which is the shape that let the old refusal be skipped; as an Err it 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


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: TranslateArgs--translate-engine flag, parsed straight into TranslateEngineName by engine_selection_parser::<TranslateEngineName>()
CLI → wirecrates/batchalign/src/cli/args/options.rs: Commands::Translate armNo 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 entrycrates/batchalign/src/recipe_runner/catalog.rsthe CatalogEntry for translate
Stage recipecrates/batchalign/src/recipe_runner/recipes.rsTRANSLATE_RECIPE
Translate orchestrationcrates/batchalign/src/translate.rsThe 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 injectioncrates/batchalign-transform/src/translate.rsTranslationSource (what the speaker produced) and render(), its one renderer; TranslationText (a translation with something to apply) and %xtra injection
Job dispatchcrates/batchalign/src/execution/translate.rs: dispatch_translate_jobReached 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 typecrates/batchalign/src/types/engines.rs: TranslateEngineNameWire-format enum (google / seamless / nllb / tencent / aliyun), EngineBackend impl, EngineOverrides.translate field
Engine resolution (server)crates/batchalign/src/types/options.rs: TranslateOptions::effective_translate_enginePrecedence: shared --engine-overrides {"translate":"..."} > --translate-engine flag > Google default
Engine bootstrapbatchalign/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_enginePure function from engine_overrides dict → TranslationBackend; default Google
Worker IPCbatchalign/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:

RuleSentNot sent
Words (TranslatableWordText::of_produced_word)Ordinary words and filled pauses (without the &- prefix); a replaced word contributes its replacement0-prefixed and CA omissions, &~ nonwords, &+ fragments, xxx / yyy / www
Separators (TranslatableSeparator::of_separator)The commaThe 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:

  1. common.engine_overrides.translate: set by --engine-overrides '{"translate":"<engine>"}'.
  2. TranslateOptions.translate_engine: TranslateEngineName: set by --translate-engine google|tencent|aliyun|nllb|seamless. Defaults to Google via default_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.idTencentSecretId
  • engine.tencent.keyTencentSecretKey
  • engine.tencent.regionTencentRegion

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 ID
  • engine.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

ConcernBA2-jan9BA3
CLI shapebatchalign translate IN_DIR OUT_DIR (separate dirs)batchalign3 translate <dir-or-file> (in-place by default)
Default enginegoogletrans (dispatch.py: "translate": "gtrans")googletrans, with explicit per-host opt-in to Seamless via server.yaml default_translate_engine or --translate-engine seamless
ConcurrencySequential per utterance, with time.sleep(1.5) on GoogleBatched cross-file dispatch, multiple worker groups per language, 1.5s sleep retained per-item on Google only
Re-run behaviorSkip already-translated utterancesOverwrite existing %xtra
What is sentutterance.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, detokenizedThe 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 preprocessingInline 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 translationDropped at injection: generator.py wrote %xtra only when the text was not "", ".", "!" or "?", leaving the utterance with no tierRefused 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 failureAborts 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::


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: CorefArgsmerge-abbrev only; coref has no --lang
Language shapecrates/batchalign/src/dispatch_language.rsDeclares coref per-file, and mints the proof a job-level dispatcher needs
Coref dispatchcrates/batchalign/src/execution/coref.rs: dispatch_coref_jobPlans, tracks, batches across files, writes; takes no language
Catalog entrycrates/batchalign/src/recipe_runner/catalog.rsthe CatalogEntry for coref
Stage recipecrates/batchalign/src/recipe_runner/recipes.rsCOREF_RECIPE
Coref orchestrationcrates/batchalign/src/coref.rsFull-document context assembly, worker dispatch, sparse injection
Injectioncrates/batchalign/src/coref.rsWrites sparse %xcoref: tiers
Worker IPCbatchalign/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


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: CompareArgslang, num-speakers
Command definitioncrates/batchalign/src/commands/compare.rsCommandDefinition impl, gold-file discovery
Compare enginecrates/batchalign-transform/src/compare/engine.rscompare(): produces ComparisonBundle
Compare orchestrationcrates/batchalign/src/compare.rsPairs each main transcript with its gold companion, runs the comparison, drives the materializers
Part-of-speech evidencecrates/batchalign-transform/src/compare/pos.rsGoldPos: 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 statescrates/batchalign/src/compare.rs: private artifacts moduleMorphotaggedMain and ComparisonArtifacts, with private fields and one constructor each
Released materializercrates/batchalign/src/compare.rs: materialize_released()Projects %mor/%gra/%wor from main to gold, injects %xsrep/%xsmor
Benchmark materializercrates/batchalign/src/compare.rs: materialize_main_annotated()Annotates main transcript with %xsrep/%xsmor (internal to benchmark)
CSV writercrates/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 file
  • speaker: speaker code
  • tokens: 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 is from_proof(PostValidated). It consumes morphotag’s own post-validation proof and continues in the document that proof carries.
  • ComparisonArtifacts, whose single constructor is build(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:

  1. Match completeness: Every gold word position must have exactly one match
  2. Uniqueness: Matches must map to distinct gold positions
  3. Mono-utterance: All matches must originate from a single main utterance
  4. Word parity: Compared word counts must match between gold and the source main utterance
  5. Alignable parity: Alignable word counts (same universe) must match
  6. No errors: All tokens must have Match status (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


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: BenchmarkArgsasr-engine, lang, num-speakers, wor/nowor
Catalog entrycrates/batchalign/src/recipe_runner/catalog.rsthe CatalogEntry for benchmark
Stage recipecrates/batchalign/src/recipe_runner/recipes.rsBENCHMARK_RECIPE
Benchmark pipelinecrates/batchalign/src/runner/dispatch/benchmark_pipeline.rsOrchestrates transcribe → compare → materialize
Benchmark compositioncrates/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:

  1. transcribe_pipeline.rs: produces the hypothesis ChatFile
  2. compare.rs: produces ComparisonBundle from 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::validate refuses a CHAT source for a command whose planner is PlannerKind::BenchmarkPairs, naming the file. Without this, a submitted .cha became an audio work unit whose gold was itself, and prepare_asr_media_input handed the transcript to ensure_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 .cha under it is its own gold. It is NOT a proxy for “takes audio”: align and speaker_identify are PlannerKind::AudioInputs and CommandIoProfile::PathsModeAudio yet 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.

  • BenchmarkWorkUnit has private fields and a pub(super) constructor, so plan_benchmark_pairs is the only place one is built. benchmark_pipeline.rs and planning/ read it through audio() and gold_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


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: OpensmileArgsPositional input/output dirs, feature-set, lang
Catalog entrycrates/batchalign/src/recipe_runner/catalog.rsthe CatalogEntry for opensmile
Stage recipecrates/batchalign/src/recipe_runner/recipes.rsOPENSMILE_RECIPE
Audio prepShared media prep in crates/batchalign/src/runner/Converts audio to mono PCM artifact
Worker IPCbatchalign/inference/opensmile.py: extract_features()Loads openSMILE, returns feature dict
CSV writercrates/batchalign/src/commands/opensmile.rsTyped 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, ... }
}


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

LayerLocationResponsibility
CLI argscrates/batchalign/src/cli/args/commands.rs: AvqiArgsPositional input/output dirs, lang
Command definitioncrates/batchalign/src/commands/avqi.rsCommandDefinition impl, paired file discovery
Audio prepShared media prepConverts .cs.* and .sv.* to typed PCM artifacts
Worker IPCbatchalign/inference/avqi.py: calculate_avqi()parselmouth + torchaudio analysis
Output writercrates/batchalign/src/commands/avqi.rsWrites .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.



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

  1. Read the user-facing CLI reference.
  2. Read the Rust workspace map.
  3. Read the Rust CLI and Server for dispatch architecture and command-creation checklist.
  4. Read the migration book if you need historical context from Batchalign2.
  5. 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 on batchalign-types and on the talkbank-* crates for CHAT parsing/validation/transform.
  • crates/batchalign-pyo3/: the PyO3 bridge, building the batchalign_core Python 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 batchalign or make build-rust
  • batchalign or crates/batchalign-pyo3/ changes: make build-python
  • the fast contributor loop: run cargo build -p batchalign once, then uv run batchalign3 ... will use the repo CLI fallback in a source checkout after a slim make 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/ (and crates/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 runtime batchalign crate 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.

CrateLocationPurpose
batchalign-pyo3crates/batchalign-pyo3/Worker runtime, what Python imports as batchalign_core. Deps: batchalign-types + talkbank-transform
batchaligncrates/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-typescrates/batchalign-types/Shared domain types and worker IPC types (depended on by both batchalign and batchalign-pyo3)
batchalign-transformcrates/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.

ModulePurpose
lib.rsModule registration
worker_protocol.rsIPC message dispatch (health, capabilities, infer, execute_v2)
worker_execute.rsShared executor control plane: request validation, failure taxonomy, response building
worker_text_exec.rsBatched text-task executors (morphosyntax, utseg, translate, coref)
worker_asr_exec.rsASR execution (Whisper, Cantonese providers)
worker_fa_exec.rsForced alignment execution
worker_media_exec.rsSpeaker diarization, OpenSMILE, AVQI
worker_text_results.rsText task normalization + align_tokens
worker_artifacts.rsPrepared artifact loading from IPC attachments
cantonese_asr_bridge.rsCantonese provider projection + normalization
py_json_bridge.rsPython ↔ 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

FunctionPurpose
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

FunctionPurpose
align_tokens(...)Map Stanza tokenizer output back to CHAT words
Cantonese bridge functionsProject 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:

  • ParsedChat class 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:

  1. Release GIL, walk Rust data, collect inputs.
  2. Acquire GIL, call the Python callback.
  3. 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:

  1. Decide where the work belongs. Pure CHAT/AST work belongs in the sibling batchalign crate (Rust), not in batchalign-pyo3. Only ML inference and provider-side glue go in the PyO3 layer.
  2. If the work is ML inference, add the executor in the appropriate worker_*_exec.rs file under crates/batchalign-pyo3/src/ and wire it into the IPC dispatch.
  3. Add Rust tests in the same crate.
  4. If you changed shared types in batchalign-types, regenerate any IPC type mirrors.
  5. Rebuild batchalign_core (make batchalign-python-prepare, or just uv run … for incremental dev rebuilds).
  6. 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

CrateResponsibility
batchalignthe 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-typesshared domain, protocol, and scheduling types (newtypes, worker IPC contracts), the small low-dependency crate everything else builds on.
batchalign-pyo3builds the batchalign_core Python extension module via maturin. Workspace deps are batchalign-types + talkbank-transform; it does NOT depend on the runtime batchalign crate.
xtaskrepo-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/%gra injection 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

  1. crates/batchalign/src/cli/mod.rs:251: run_command(), the canonical command router.
  2. crates/batchalign/src/cli/: CLI dispatch (explicit server vs. auto-daemon).
  3. crates/batchalign/src/execution/: server-side task routing and dispatch shapes.
  4. crates/batchalign/src/routes/: Axum HTTP routes.
  5. crates/batchalign/src/worker/: worker pool and IPC.
  6. crates/batchalign/src/commands/: released-command specs and command-owned wrappers.
  7. 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.rs for the ordered stages each entry points at
  • recipe_runner/command_spec.rs for what a CommandFamily implies
  • crates/batchalign/src/command_family.rs for the workflow-family metadata
  • crates/batchalign/src/text_batch.rs for 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:

  • TextBatchFileInput for one named file plus its owned CHAT payload
  • TextBatchFileResults for one batch’s named file outcomes
  • TextWorkflowFileError for 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.

  • AudioSequential for one media file at a time (GPU lane, bounded file-level parallelism).
  • BatchedText when work is pooled across files into shared infer batches (CPU lane, one dispatch per job).
  • ReferenceProjection when two artifacts are jointly primary (paired inputs).
  • MediaAnalysis when the output is not CHAT (IO lane).
  • Composite when you are composing existing command flows (all policy delegated to children).
  • Use text_batch.rs and 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_RECIPE
  • align: AudioSequential, ALIGN_RECIPE
  • morphotag: BatchedText, MORPHOTAG_RECIPE
  • compare: ReferenceProjection, COMPARE_RECIPE
  • benchmark: 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 raw
  • ComparisonBundle is the compare IR: main/gold utterance views, structural gold-to-main word matches, and metrics
  • the released materializer writes projected-reference %xsrep / %xsmor through typed tier-content models, then lowers once to UserDefinedDependentTier
  • the internal benchmark/main materializer is separate from the compare command
  • projection must stay AST-first rather than rebuilding tiers from %xsrep / %xsmor strings
  • .compare.csv comes 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

  1. Add the stage recipe to crates/batchalign/src/recipe_runner/recipes.rs.
  2. Declare one CatalogEntry in crates/batchalign/src/recipe_runner/catalog.rs. That is the whole registration; there is no second place.
  3. Reuse an existing runner family when possible; only widen runner/dispatch/ when the command shape is genuinely new.
  4. Keep the command-specific orchestration in Rust helper modules, not in pyo3.
  5. 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

  1. Keep provider selection at the control-plane boundary.
  2. Keep engine-specific transport or worker protocol code in the provider or worker layer.
  3. 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:

  1. strips legacy %xalign and %xrev tiers from the CHAT model; and
  2. 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 familyTyped outcome/record todayTrace todayDurable per-file decision evidence today
Forced alignmentYesYesYes, in FA debug evidence when requested
MorphotagYesYes for anomaly recordsNo; the injection collection is not yet serialized
Utterance segmentationTyped outcomes and adapters existNot a complete production sinkNo
CoreferenceTyped outcomes and adapters existNot a complete production sinkNo

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

  1. Add a variant to the narrowest module-specific strategy enum.
  2. Add its stable name in that enum’s exhaustive as_str() match.
  3. Construct the record at the point where the decision is made, using a typed line index and a structured key/value reason.
  4. Set needs_review only when a human can usefully adjudicate the outcome.
  5. Thread the record into the command’s typed result. For FA, add a field to FaDecisions if it is a new producer; do not append it independently in the full and incremental paths.
  6. 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

CategoryVariantCHAT TokenUnicode
StandardPeriod.
Question?
Exclamation!
InterruptionTrailingOff+...
Interruption+/.
SelfInterruption+//.
InterruptedQuestion+/?
SelfInterruptedQuestion+//?
BrokenQuestion+!?
TrailingOffQuestion+..?
BreakForCoding+.
QuotationQuotedNewLine+"/.
QuotedPeriodSimple+".
CA IntonationCaRisingToHigh(arrow)U+21D7
CaRisingToMid(arrow)U+2197
CaLevel(arrow)U+2192
CaFallingToMid(arrow)U+2198
CaFallingToLow(arrow)U+21D8
CA BreakCaTechnicalBreak(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:

  1. Content items are written (including InternalBullet(bullet1))
  2. Terminator is written (None for CA, so skipped)
  3. 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:

  1. fa_pipeline.rs parses CHAT text once → ChatFile
  2. run_utr_pass(&mut ChatFile, ...) mutates the AST in place, no serialization
  3. run_fa_from_ast(ChatFile, ...) receives the AST directly, no re-parse
  4. 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 touches TierContent.terminator. It only modifies word inline_bullet and TierContent.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 as Option<String> – safe for None.

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

  1. Groups utterances into 20-second windows
  2. Strips ALL punctuation (both ENDING_PUNCT and MOR_PUNCT) from the text sent to Whisper
  3. Whisper returns timestamped words WITHOUT terminators
  4. Character-level DP alignment maps words back
  5. 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

AspectBA2BA3
Terminator storageLast Form in content listSeparate Option<Terminator> field
Terminator typeString from ENDING_PUNCT20-variant enum
CA arrowsNot recognized as terminatorsEnum variants exist but grammar doesn’t wire them
Missing terminatorImplicit . defaultExplicit None
Bullet modelRegex on serialized textAST: InternalBullet vs TierContent.bullet
FA terminator treatmentStrip before WhisperExclude from word extraction
Grammar-level distinctionN/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/.

FileBehaviorNotes
talkbank-transform/src/asr_postprocess/mod.rsDEFAULTS to .Appends period when no punctuation
talkbank-transform/src/build_chat/mod.rsCREATES from last wordDefaults to Period for unrecognized
talkbank-transform/src/utseg.rsCREATES PeriodSplit utterances always get period
talkbank-transform/src/morphosyntax/payload.rsDEFAULTS to "."Payload string includes silent default
talkbank-transform/src/morphosyntax/injection.rsREADS, patchesCopies utterance terminator into MOR tier
talkbank-transform/src/inject.rsPRESERVESTop-level injection entry; passes through to retokenize
talkbank-transform/src/retokenize/parse_helpers.rsPRESERVESKeeps original when Stanza differs
talkbank-transform/src/retokenize/rebuild.rsPRESERVESCarries expected_terminator through
batchalign/src/compare.rsREADSCollects as Option<String>
talkbank-transform/src/translate.rsREADSLists all variants for spacing
talkbank-transform/src/validate.rsCHECKS (CA-aware)Pre-validation exempts CA
talkbank-transform/src/validate.rsCHECKS (NOT CA-aware)Post-validation always warns
talkbank-transform/src/morphosyntax/sentence_mapping.rsASSUMES +1GRA index assumes terminator exists
talkbank-transform/src/morphosyntax/gra_validate.rsASSUMESSkips last GRA as terminator PUNCT
batchalign/src/fa/PRESERVESForced-alignment never touches terminator

Known Issues

  1. Grammar bug: CA intonation arrows not in terminator rule – causes double bullets and InternalBullet misclassification.

  2. Post-validation not CA-aware: validate_output() warns about missing terminators even for CA files.

  3. 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.

  4. NLP mapping +1 assumption: MOR/GRA coordination assumes a terminator PUNCT relation exists. CA utterances without terminators may have wrong GRA indices.

  5. 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.

FeatureDefaultCLI override
Two-pass overlap UTRGated: 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 narrowingEnabled (when two-pass active)--utr-ca-markers disabled
Density-aware fallback30% threshold--utr-density-threshold
Tight buffer for pass-2500ms--utr-tight-buffer
End-time overlap clampingAlways onN/A
%wor suppression for CAAuto 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+&lt; 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 &amp;* 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:

  1. &* markers embed one speaker’s words inside another’s utterance, creating text positions that don’t match audio positions.
  2. 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:

  1. Take the 2265_T4 input file (636 utterances, 4 speakers).
  2. Produce 4 single-speaker CHAT files (one per speaker, same audio).
  3. Run existing align on each single-speaker file against the full audio.
  4. Measure timed-utterance coverage per speaker.
  5. 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 typeCurrent untimedAfter backbone extraction
Moderate &*, no restructuring5-15%~0-5% (good recovery)
Dense &*, no restructuring15-25%~5-15% (meaningful improvement)
Dense &* + heavy restructuring (2265_T4 class)35%+~25-30% (modest improvement)
No &* markers0-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 typeCurrent untimedAfter per-speaker UTR
Moderate &*, no restructuring5-15%~0-2%
Dense &*, no restructuring15-25%~0-5%
Dense &* + heavy restructuring (2265_T4 class)35%+~5-10% (if within-speaker order is preserved)
No &* markers0-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_ai carries the completed provider job ID, output object, and optional warning before normalization;
  • pyannote carries local Pyannote segments; and
  • nemo carries 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 from batchalign-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

  1. Rust owns all workflow semantics.
  2. Python owns only irreducible runtime/model semantics.
  3. Large binary inputs must not ride inside generic JSON request bodies.
  4. The protocol should describe model-ready inputs, not CLI commands.
  5. Cache formats may change freely; old cache compatibility is not required.
  6. The legacy config file remains important. Provider credentials in ~/.batchalign.ini still 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 by crates/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_payload means the request or prepared artifact metadata was invalid before the model host could do useful work
  • missing_attachment / attachment_unreadable mean the referenced prepared artifact was absent or unreadable
  • model_unavailable means the selected backend is not loaded in this worker
  • runtime_failure means 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 types ExpandNumbersRequestV2, ExpandNumbersResultV2, NumberExpansionModeV2, and the InferenceTaskV2::ExpandNumbers enum 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_whisper must use PreparedAudioInput
  • cloud providers may keep ProviderMediaInput temporarily if Rust has not replaced their transport yet
  • revai is 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.py now executes live speaker requests through execute_v2(task="speaker") using Rust-prepared audio attachments only
  • crates/batchalign/src/worker/speaker_request_v2.rs now 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
  • speaker remains a low-level infer-task name rather than a CLI command; both integrated transcribe_s and standalone diarize compose this task
  • opensmile and avqi now use the same execute_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_id plus item_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’s failed outcome, 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
TaskOutcomes
morphosyntaxanalyzed (raw_sentences, the model that produced them, and the repairs it made to their UD relations), no_words (no identity, nothing repaired), failed (error)
translatetranslated (raw_translation plus engine), blank_input (no identity), failed (error)
corefresolved (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_id is 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_short is 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 reported min_num_samples and refuses; the Rust SpeakerEmbedding constructor 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 batchalign crate will want a dedicated prepared-artifact subsystem rather than ad-hoc temp-file helpers
  • the batchalign crate may gain more raw provider normalization helpers
  • the talkbank-* core crates (model / parser / transform) may need ergonomic Rust-side hooks if batchalign benefits 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.rs now narrows full-file and incremental FA orchestration behind a shared transport adapter instead of letting each path assemble legacy batch_infer(task="fa") payloads inline
  • crates/batchalign/src/worker/request_builder_v2.rs now builds staged V2 FA requests from existing FaInferItem values
  • 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.py now provides the thin Python wrapper over Rust-owned prepared text JSON and prepared PCM audio readers
  • crates/batchalign-pyo3/src/worker_fa_exec.rs now owns the live V2 FA executor control plane, while batchalign/worker/_fa_v2.py stays as a thin Python host wrapper
  • crates/batchalign/src/worker/fa_result_v2.rs now maps those typed V2 FA results straight back into the established Rust FA alignment domain
  • crates/batchalign/tests/worker_v2_fa_roundtrip.rs now proves the staged Rust request builder, Rust-owned FA executor control plane, and staged Rust result adapter already form one coherent cross-language seam
  • batchalign/worker/_execute_v2.py now exposes a live execute_v2 stdio handler that routes forced-alignment requests into the typed V2 executor
  • crates/batchalign/src/worker/handle/ (mod.rs + spawn.rs + ipc.rs + lifecycle.rs + protocol.rs) and crates/batchalign/src/worker/pool/mod.rs now carry that execute_v2 op 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_media inputs 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.rs now builds typed ASR V2 requests with either Rust-owned prepared full-file audio artifacts or typed provider-media inputs
  • crates/batchalign-pyo3/src/worker_asr_exec.rs now owns the live V2 ASR executor control plane, while batchalign/worker/_asr_v2.py stays as a thin Python host wrapper for local Whisper, Tencent, Aliyun, and FunASR
  • crates/batchalign/src/worker/asr_result_v2.rs now maps typed V2 ASR responses back into the established Rust AsrResponse domain
  • batchalign/worker/_execute_v2.py now routes all live Python-hosted ASR through the execute_v2 stdio boundary
  • crates/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.py facade 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:

  1. move the remaining Cantonese provider ASR engines onto typed V2 request shapes instead of the legacy batch-infer bag
  2. 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
  3. 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-flight execute_v2 calls per worker at gpu_thread_pool_size so Rust dispatch concurrency matches Python’s ThreadPoolExecutor capacity. Permit is acquired before pending.insert() and before the tokio::time::timeout wrap, 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 interleave
  • pending: 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:

Devicegpu_thread_pool_sizeWhy
CUDA / real GPU (releases GIL on native calls)2-4True parallel inference inside one Python process
Apple Silicon CPU (MPS excluded for batchalign3)1GIL-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:

FunctionFormula
recommend_gpu_thread_pool_size4 on functional GPU, 1 otherwise
recommend_force_cpu!gpu.is_functional_for_batchalign()
recommend_max_total_workersclamp(ram_total_mb / 6 GB, 2, 32); fallback 4 when ram = 0
recommend_max_concurrent_jobstier-and-CPU bounded (CPU clamped to [1, 8] against tier max_suggested_workers)
recommend_max_workers_per_jobper-command formula honoring gpu_thread_pool_size cap
recommend_max_workers_per_keyper-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:

KnobTypeSentinel migration
gpu_thread_pool_sizeOption<u32>0 -> None via zero_as_none
force_cpuOption<bool>no shim, false is meaningful
max_total_workersOption<u32>0 -> None
max_concurrent_jobsOption<u32>0 -> None
max_workers_per_jobOption<u32>0 -> None
max_workers_per_keyOption<u32>0 -> None (uniform fan-out across profiles)
memory_gate_mbOption<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 --check exits non-zero only if --warnings-as-errors is set.
    • GpuThreadPoolSizeAboveOneOnCpu
    • MaxConcurrentJobsAboveRamBudget
    • MaxTotalWorkersAboveRamBudget
    • ForceCpuFalseOnNonFunctionalGpu
  • Errors (fatal): override would deterministically crash. Server refuses to start; doctor --check exits non-zero. Today’s variant:
    • MaxConcurrentJobsWouldDeterministicallyOom: fires when configured * 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 --sequential so 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

  1. Recommendation: add recommend_NEW(facts: &HostFacts) -> T in recommendations.rs with table-driven unit tests covering the relevant fact shapes (Apple Silicon, CUDA, no-GPU).
  2. Override slot: add NEW: Option<T> to ConfigOverrides in effective.rs and to EffectiveConfig (resolved value).
  3. Resolve: extend EffectiveConfig::resolve with the merge line NEW: overrides.NEW.unwrap_or(r.NEW).
  4. ServerConfig field: add NEW: Option<T> with zero_as_none if T is integer-shaped (#[serde(default, deserialize_with = "zero_as_none", skip_serializing_if = "Option::is_none")]); for bool use plain #[serde(default, skip_serializing_if = "Option::is_none")].
  5. Bridge: extend From<&ServerConfig> for ConfigOverrides to populate the new field.
  6. Consumers: production builders (serve_cmd::start, dispatch::build_direct_pool_config) read from effective.NEW when populating WorkerRuntimeConfig / PoolConfig.
  7. Tests: 4 RED -> GREEN serde tests in types/config/tests.rs (legacy zero, explicit, absent, round-trip). Plus the propagates_to_config_overrides shape if relevant.
  8. Validation (if the knob has a contradiction-with-fact mode): add a ConfigWarning::NEW { ... } variant with a self-explaining Display, the rule in validate(), and 5 tests (above, equal, below, None, Display contract).
  9. Doctor: add an arm to explain_knob in crates/batchalign/src/cli/doctor_cmd.rs so doctor --explain NEW works. The explain_handles_every_documented_knob test catches forgotten arms.
  10. Pyinfra render (if the knob is rendered into server.yaml by pyinfra): add an Optional[T] field to BatchalignServerConfig in automation/src/talkbank_automation/batchalign_render.py with omit-when-None render policy, and an _optional_host_TYPE reader in automation/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:

CrateRole
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:

  • ReleasedCommand in crates/batchalign-types/src/domain.rs:36 is the closed released command vocabulary for contributor-facing Rust code. Parse external strings into this enum as early as possible; keep the old string-backed CommandName only at wire/storage boundaries.
  • CommandProfile in crates/batchalign/src/cli/args/mod.rs:148 keeps the command identity, language, file extensions, and speaker count together as a typed profile instead of a positional tuple.
  • DispatchRequest in crates/batchalign/src/cli/dispatch/mod.rs:42 carries 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 core
  • ServerExecutionHost: queue/store/server-owned lifecycle behavior
  • DirectHost / DirectExecutionHost: inline local execution without queueing or registry discovery
  • ServerBackend / LocalServerBackend, route-facing server control-plane seam over persisted jobs, orchestration, event subscription, traces, and runtime shutdown
  • prepare_workers*() vs prepare_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 through DirectHost.
  • Explicit server mode (--server URL) submits a shared-filesystem paths_mode job. 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:

  • ExecutionEngine remains the canonical command execution core.
  • DirectHost remains the BA2-style local/default execution path.
  • ServerBackend becomes 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:

  • ExecutionEngine owns canonical command execution only.
  • DirectHost remains the direct/local execution path.
  • ServerBackend owns app-facing job submission, inspection, cancellation, traces, event subscription, and runtime shutdown.
  • LocalServerBackend owns 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:

  1. add or extend crates/batchalign/src/commands/<name>.rs
  2. choose the existing runner family it should reuse
  3. keep the CLI argument plumbing thin
  4. 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 FooArgsCommandOptions::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:

  1. command_supported() in crates/batchalign/src/capability.rs: the worker’s admitted report (admitted once, in WorkerPool::record_capabilities()) advertises the entry’s capabilities.primary_infer_task. This alone decides what /health advertises 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, beside WorkerCapabilitySnapshot.
  2. At dispatch (runner/routing.rs), WorkerPool::ensure_command_capabilities() loads the command’s task on the selected worker (ensure_task) and returns LoadedCapabilities: 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 with FaCacheNamespace::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() in crates/batchalign/src/capability.rs prefers 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 through WorkerPool::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 with infer_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:

  • external daemons are preserved on routine shutdown
  • server_owned daemons are tagged with server_instance_id and server_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 from BATCHALIGN_BUILD_IDENTITY, set by the server’s daemon spawner and by batchalign3 worker start. The logged refusal names the remedy: batchalign3 worker stop, then batchalign3 worker start with this build. Each sweep’s refusals are listed in /health under refused_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 alignment
  • transcribe_pipeline.rs: dispatch_transcribe_infer() for audio-to-CHAT generation
  • benchmark_pipeline.rs: dispatch_benchmark_infer() for transcribe + compare composition
  • media_analysis_v2.rs: dispatch_media_analysis_v2() for opensmile/avqi/diarize
  • speaker_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:

  • TextBatchFileInput keeps one file name and one owned CHAT payload together.
  • TextBatchFileResults keeps the per-file outcome shape explicit.
  • TextWorkflowFileError keeps file-scoped failure details separate from file identity instead of returning String error 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-wheelmake 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:

FilesApproximate 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.

FileRole
crates/batchalign/src/routes/mod.rsOuter RequestBodyLimitLayer
crates/batchalign/src/routes/jobs/mod.rsInner limit disabled via DefaultBodyLimit::disable()
crates/batchalign/src/types/config/server.rsmax_body_bytes_mb field and 512 MB default
crates/batchalign/src/types/request.rsJobSubmission 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

  1. Single consumer: batchalign-hk-plugin was the only plugin. The discovery machinery existed for one package.
  2. Entry-point fragility: importlib.metadata.entry_points() failed silently on broken packages, loaded wrong versions across environments, and was difficult to debug.
  3. Enum dispatch is safer: AsrEngine and FaEngine enums provide compile-time exhaustiveness checking and clear error messages for missing engines.
  4. 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:

  1. Create a (load_*, infer_*) function pair in batchalign/inference/
  2. Add an enum variant to AsrEngine, FaEngine, or the relevant engine enum
  3. Wire the loader into worker/_model_loading/
  4. Register the runtime handler during bootstrap in worker/_model_loading/ and keep worker/_infer.py thin
  5. 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:

  1. Move your load_* and infer_* functions into batchalign/inference/
  2. Add an enum variant for your engine in worker/_types.py
  3. Remove pyproject.toml entry points and PluginDescriptor
  4. Add your dependencies to batchalign’s base package if the engine is a supported built-in provider
  5. Update tests to use monkeypatch instead 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:

  1. 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.
  2. 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"]
  1. 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, UdSentence in Rust), never on serialized CHAT text.
  2. Ops-visible logging. Every rewrite emits a tracing::warn naming the defect, the upstream library version, the affected input, and the rewrite applied. An operator monitoring the fleet can see that the workaround fired.
  3. Final validation gate. chatter validate checks 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 the ml-golden cargo 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/:

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.

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:

  1. Open the registry for that library.
  2. For each active defect entry, run the standalone reproducer.
  3. 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.
  4. Retire the workaround: delete the detect+rewrite code, remove the tracing::warn call, delete the pure unit tests and the integration test, update the registry entry from “ACTIVE” to “RETIRED (upstream fixed in version X.Y.Z)”.
  5. 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 under crates/batchalign-transform/src/morphosyntax/invariants/.
  • Stanza Defect 4 (Finnish <SOS> leak) was retired in Stanza 1.12.0; the historical Python-side workaround batchalign/inference/_control_token_filter.py is 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:

  1. Feed the workaround’s trigger input through Stanza directly (no workaround)
  2. Compare output with the workaround applied
  3. If they differ, the workaround is still needed
  4. If they agree, the workaround can be retired

English (engen)

E1. Irregular Verb Conjugation Database

Filecrates/batchalign-transform/src/morphosyntax/lang_en.rs
SizeIrregular-form entries (see the file for the active list)
WhatStatic lookup of irregular past tense / participle forms (be→was/been, go→went/gone, etc.). Used by verb_features() to emit -PAST or -PASTP suffixes.
WhyStanza’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.
OriginPorted 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.
Testslang_en.rs: test_irregular_past, test_irregular_participle, test_regular_verb, test_case_insensitive

E2. English Contraction MWT Handling

Filecrates/batchalign-transform/src/tokenizer_realign.rs
WhatTokens 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).
WhyStanza’s neural tokenizer sometimes fails to split contractions. Explicit MWT hints ensure consistent expansion.
Originbatchalign2/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.
Teststests embedded in tokenizer_realign.rs

E3. English GUM MWT Package

Filebatchalign/worker/_stanza_loading.py
WhatEnglish uses Stanza’s “gum” MWT package instead of default.
WhyThe GUM corpus MWT model provides better English contraction handling.
Originbatchalign2 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.
Teststest_stanza_config_parity.py

French (frafr)

F1. Pronoun Case Lookup

Filecrates/batchalign-transform/src/morphosyntax/lang_fr.rs
SizePronoun-case lookup (Nominative + Accusative entries; see the file)
WhatHardcoded 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”).
WhyStanza’s French model often omits or misassigns the Case feature on pronouns. The lookup provides correct case for CHAT %mor output.
Originbatchalign2/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.
Testslang_fr.rs: 4 tests covering Nom, Acc, unknown, apostrophe

F2. Auditory Plural Marking (APM) Noun Detection

Filecrates/batchalign-transform/src/morphosyntax/lang_fr.rs
SizeNoun form list (see the file for the active set)
WhatList of French nouns that undergo auditory plural marking (e.g., “cheval”/“chevaux”). Used by noun_features() to correctly emit plural suffixes in %mor.
WhyStanza may not distinguish between regular and APM plurals. CHILDES/CHAT convention requires explicit plural marking for these nouns.
Originbatchalign2/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.
Testslang_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)

Filecrates/batchalign-transform/src/tokenizer_realign.rs
WhatThree 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
WhyStanza’s French MWT model has known quirks with these forms.
Originbatchalign2/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.
TestsFrench-specific tests embedded in tokenizer_realign.rs

Japanese (jpnja)

J1. Verb Form Overrides

Filecrates/batchalign-transform/src/morphosyntax/lang_ja.rs
SizeOrder-dependent override chain (see the file)
WhatIf/elif chain matching substrings in Japanese word text. Can override both POS and lemma. Examples:
“ちゃ” → sconj/“ば”, “なきゃ” → sconj/“なきゃ”, “れる” → aux/“られる”, “はい” → intj/“はい”
WhyStanza’s Japanese models systematically mislabel auxiliary particles and verbs. The surface form is a reliable signal for the true grammatical function.
Originbatchalign2/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 mattersThe if/elif chain is order-dependent, matches Python exactly.
Testslang_ja.rs: 4 tests covering sconj, intj, de, and no-override cases

J2. Combined Processor Package

Filebatchalign/worker/_stanza_loading.py
WhatJapanese uses Stanza’s “combined” processor package for all processors instead of default.
WhyJapanese doesn’t use MWT. Combined models provide better accuracy.
Originbatchalign2/ud.py:1048-1052
Still needed?Likely yes. Japanese tokenization is fundamentally different from European languages.
Teststest_stanza_config_parity.py

J3. Comma POS Normalization

Filecrates/batchalign-transform/src/morphosyntax/mor_word.rs
WhatJapanese PUNCT tokens are remapped to cm POS. Japanese commas (“、”, “,”) specifically get lemma “cm”.
WhyCHAT uses “cm|cm” for comma punctuation, but Stanza tags these as regular PUNCT.
OriginPython master Japanese handling
Still needed?Yes, permanent. This is a CHAT convention, not a Stanza bug.
TestsCovered by morphosyntax round-trip tests

Italian (itait)

I1. “l’” MWT Suppression

Filecrates/batchalign-transform/src/tokenizer_realign.rs
WhatWhen Stanza tags “l’” as MWT (l', true), suppress the expansion hint.
WhyStanza aggressively expands “l’” which should not always be split.
Originbatchalign2/ud.py:662-668
Still needed?Testable. Run Stanza on Italian text with “l’”, if it still over-expands, keep.
TestsItalian tests embedded in tokenizer_realign.rs

I2. “lei” Merge (le + i → lei)

Filecrates/batchalign-transform/src/tokenizer_realign.rs
WhatIf Stanza splits “lei” into “le” + “i”, merge them back.
WhyKnown Stanza bug splitting the pronoun “lei” (she/her).
Originbatchalign2/ud.py:668
Still needed?Testable. If Stanza no longer splits “lei”, can remove.
TestsItalian tests embedded in tokenizer_realign.rs

Portuguese (porpt)

P1. “d’água” MWT Forcing

Filecrates/batchalign-transform/src/tokenizer_realign.rs
WhatForce MWT expansion on “d’água” (de + água).
WhyStanza may not recognize this as a contraction.
Originbatchalign2/ud.py:669-670
Still needed?Testable. Run Stanza on “d’água”, if it splits correctly, can remove.
TestsPortuguese test embedded in tokenizer_realign.rs

Dutch (nldnl)

D1. Possessive “’s” MWT Suppression

Filecrates/batchalign-transform/src/tokenizer_realign.rs
WhatTokens ending with “’s” (e.g., “vader’s”) get (text, false) hint to prevent MWT expansion.
WhyDutch possessive ’s is not a contraction and should not be split.
Originbatchalign2/ud.py:694-695
Still needed?Likely yes. Dutch possessive ’s is an orthographic convention that MWT models may mishandle.
TestsDutch tests embedded in tokenizer_realign.rs

Cantonese (yue): Engines

C1. Text Normalization Pipeline

Filecrates/batchalign-transform/src/asr_postprocess/cantonese.rs
Sizeferrous-opencc s2hk conversion + a domain replacement table (see the file for the active entries)
WhatTwo-stage normalization: Simplified→Traditional via ferrous-opencc, then a domain replacement table (multi-char first to prevent partial matches).
WhyCantonese ASR output uses simplified or colloquial forms that need normalization to standard written Cantonese.
OriginCantonese-specific (new in batchalign3)
Still needed?Yes, permanent. Regional dialect normalization, not a model bug.
Teststest_common.py

C2. Jyutping Romanization for FA

Filebatchalign/inference/languages/cantonese/_cantonese_fa.py
WhatConverts hanzi to jyutping (tone-stripped, apostrophe-joined) before Wave2Vec FA.
WhyWave2Vec MMS was trained on romanized text, so hanzi must be romanized for alignment.
OriginCantonese-specific (new in batchalign3)
Still needed?Yes, permanent. Architectural requirement of the FA model.
Teststest_cantonese_fa.py

Cross-Language

X1. MWT Language Dispatch Table

Filebatchalign/worker/_stanza_loading.py
Size39 languages currently enable MWT
WhatDetermines which languages use Stanza’s MWT processor. CJK, some Slavic languages excluded.
WhyMWT is not applicable to all languages. CJK languages don’t have multi-word tokens.
Originbatchalign2/ud.py:1034-1036
Still needed?Yes, permanent. Fundamental to pipeline architecture.
Teststest_stanza_config_parity.py

X2. ISO 639-3 → ISO 639-1 Mapping

Filebatchalign/worker/_stanza_loading.py
Size55 explicit mappings
WhatConverts 3-letter codes (batchalign internal) to 2-letter codes (Stanza). Special: yue→zh, cmn→zh.
WhyStanza uses 2-letter codes.
OriginEssential mapping maintained from batchalign2
Still needed?Yes, permanent. Different code systems.
TestsImplicit in all morphosyntax tests

X3. Number Expansion

Filecrates/batchalign-transform/src/asr_postprocess/num2text.rs, crates/batchalign-transform/src/asr_postprocess/num2chinese.rs
SizeLanguage-specific lookup tables (the authoritative list lives at crates/batchalign-transform/data/num2lang.json) plus a Chinese-script converter
WhatConverts digit strings to word forms (5→“five”, 5→“五”) during ASR post-processing.
WhyASR output digit strings need language-appropriate word forms for CHAT transcription.
Originbatchalign2/pipelines/asr/utils.py
Still needed?Yes, permanent. Language-specific numeral systems.
TestsParameterized 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:

IDWorkaroundTest Method
E3English GUM MWT packageCompare default vs GUM package on contractions
F3French “au” MWT forcingCheck if Stanza recognizes “au” as contraction
I1Italian “l’” suppressionCheck if Stanza still over-expands “l’”
I2Italian “lei” mergeCheck if Stanza still splits “lei” → “le” + “i”
P1Portuguese “d’água”Check if Stanza recognizes as contraction
J1Japanese verb form overridesTest 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:

IDWorkaroundReason
E1Irregular verb databaseCHAT %mor -PAST/-PASTP convention
F1French pronoun caseCHAT %mor case feature convention
F2French APM nounsCHILDES French plural convention
J3Japanese comma → cmCHAT punctuation convention
C1Cantonese normalizationRegional dialect convention
C2Jyutping for FAModel architecture requirement
X1MWT dispatch tablePipeline architecture
X2ISO code mappingCode system interop
X3Number expansionLanguage-specific numeral systems

Mixed (partly convention, partly bug workaround)

IDWorkaroundAnalysis
E2English contraction MWTConvention (contractions should expand) + bug (Stanza misses some)
F3French elision/multi-cliticConvention (elision rules) + patches (Stanza-specific)
D1Dutch possessive ’sConvention (not a contraction) + bug (MWT over-expands)

To systematically determine which workarounds are still needed, create a test fixture that:

  1. Loads a Stanza pipeline for each language
  2. Runs a curated input through Stanza without workarounds
  3. Runs the same input with workarounds
  4. 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 %gra relations are validated before they are returned from map_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 %mor and %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 (with InvalidRoot, CircularDependency, InvalidHeadReference variants) is the error type, defined in the parent talkbank_transform::morphosyntax module.

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:

  1. there is exactly one non-terminator root
  2. the generated graph is acyclic
  3. every head reference points to an existing relation or root

map_ud_sentence() also checks:

  1. %mor chunk count matches %gra relation 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 %gra tier

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_valid
  • test_validate_generated_gra_accepts_head_zero
  • test_validate_generated_gra_rejects_no_root
  • test_validate_generated_gra_rejects_multiple_roots
  • test_validate_generated_gra_rejects_cycle
  • test_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 %gra tiers 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:

ScenarioPeak RSSFiles/hour
GIL enabled, four processes13.5 GB10,069
GIL disabled, four threads3.0 GB10,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_GIL settings.

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:

  1. Every required dependency resolves as a wheel on the platforms we support.
  2. All released command paths, including local speaker diarization, pass on that interpreter.
  3. Long-running end-to-end ML soaks show stable memory, shutdown, exception, signal, and idle behavior on isolated hosts.
  4. The normal CI and release workflows intentionally build, test, and smoke that ABI.
  5. 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

LayerSource of truthFiles
Rust typesCanonical definitionscrates/batchalign-types/src/worker_v2/, re-exported by crates/batchalign/src/types/worker_v2.rs, plus crates/batchalign/src/morphosyntax/mod.rs
JSON SchemaGenerated from Rustipc-schema/worker_v2/*.json, ipc-schema/batch_items/*.json
Hand-written PythonConformance-testedbatchalign/worker/_types_v2.py, batchalign/inference/*.py
Conformance testsValidates hand-written against schemabatchalign/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 str and float. The hand-written models carry LanguageCode, Terminator, WorkerRequestIdV2 and FiniteNonNegativeFloat, 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_s on WhisperChunkSpanV2 and the parallel-array lengths on FaInferItem.
  • extra="allow" on UdWord, 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:

  1. Derive schemars::JsonSchema on the Rust struct/enum:

    #[derive(Serialize, Deserialize, schemars::JsonSchema)]
    pub struct MyNewPayloadV2 { ... }
  2. Register it in crates/batchalign/src/cli/ipc_schema.rs (the ipc-schema CLI subcommand is wired through cli/mod.rs and cli/args/commands.rs::IpcSchemaArgs):

    register!(v2, MyNewPayloadV2);
    
  3. Add the Python model in the appropriate file (or use generated):

    class MyNewPayloadV2(BaseModel):
        # Fields matching Rust struct
        ...
    
  4. 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)
    
  5. 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:

  1. Define request/result types in Rust with JsonSchema derive
  2. Register them in ipc_schema.rs
  3. Generate schemas → see the exact field shapes Python must implement
  4. Write the Python Pydantic model matching the schema
  5. 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.

LevelRust (tracing)Python (logging)When to use
0 (default)WARNWARNINGNormal operation
1 (-v)INFOINFOServer start/stop, job lifecycle
2 (-vv)DEBUGDEBUGPer-file progress, engine boundary data
3 (-vvv)TRACEDEBUGFull 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 INFO level on completion
  • Sentence count mismatch warnings at WARNING level
  • Stanza batch failure warnings at WARNING level

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

  1. Always try DirectParser::parse_word() first: if the text is valid CHAT syntax, the parser returns a properly validated Word.
  2. Only fall back to new_unchecked when the input is genuinely unparseable (e.g., ASR returned non-CHAT characters). Log a warn! when this happens.
  3. Never fall back to new_unchecked in 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

  1. Run with -vv to see per-utterance word counts and Stanza I/O:

    batchalign3 -vv morphotag input/ output/
    
  2. If a specific utterance fails, the warn! from inject.rs will report the exact word count mismatch and utterance text.

  3. 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:

  1. A warn! is logged: "Token is not valid CHAT syntax; keeping original word".
  2. The original word is preserved in the AST.
  3. 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:

  1. A warn! is logged: "ASR word is not valid CHAT syntax; using unchecked fallback".
  2. The unchecked word enters the AST, this is expected for non-CHAT characters.
  3. 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.

VariableWritesOn failure
BA3_DUMP_ASR_PIPELINE=/path/file.jsonAsrPipelineTrace: 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 reportedSerialization and write failures are ignored
BA3_DUMP_UTTERANCES=/path/file.jsonThe post-processed utterances handed to CHAT constructionThe 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:

FailureWhat is loggedWhere
Utseg pre-validation fails (parse error in CHAT)Full CHAT text + error detailsutseg.rs
Whisper returns inverted timestampsWarning with start/end valuesinference/asr.py
MOR item count mismatchWord count + MOR count + utterance textinject.rs
Stanza sentence count mismatchExpected vs actual sentence countsmorphosyntax.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-op
  • disabled(): test helper, always no-op
  • ensure_dir(): lazily creates the directory on first write
  • stem(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:

  1. Job options carry debug_dir: Option<String> in CommonOptions
  2. Per-file dispatch extracts it: job.dispatch.options.common().debug_dir
  3. Creates DebugDumper::new(debug_dir.as_deref().map(Path::new))
  4. Passes the dumper into the pipeline context struct
  5. 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:

AnomalyDetection
Bogus lemmaLemma is pure punctuation for a word with letters (e.g. 哎呀 → 》)
Sentence count mismatchStanza returned a different number of sentences than input utterances
Batch failureStanza 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:

ViewUse 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 nameFileCarries
dispatch_execute_v2_with_progressworker/pool/dispatch.rsrequest_id
dispatch_gpu_execute_v2worker/pool/dispatch.rstarget, lang, request_id
get_or_create_gpu_workerworker/pool/mod.rstarget, lang
execute_v2worker/pool/shared_gpu/stdio.rspid, request_id
shared_gpu_reader_loopworker/pool/shared_gpu/stdio.rspid
write_request / read_responseworker/handle/ipc.rspid

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_unstable cfg 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-appender server-log surface for production.
  • Memory or UB bugs. Wrong category. Use memray / dhat-rs / miri instead.
  • Subprocess IPC bugs in isolation. tokio-console sees the Rust side only. A bug that crosses into the Python worker also needs py-spy dump on 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

SymptomCommand
Worker hung at 0% CPU; daemon log is silentpy-spy dump: get the Python stack instantly
Worker is using 100% CPU but you don’t know whypy-spy top: watch per-function CPU live
Sustained slow performance; want to optimizepy-spy record --native: flame graph the workload
Multi-worker job; want to see which child is busypy-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) or dhat-rs (Rust) , separate “How to profile memory” doc when those land.
  • Allocation hotpath in PyTorch / Whisper / Stanza native code. Use py-spy record --native to 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_ENABLE env-var hook in batchalign3; 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 fileTriggerContents
failed_ipc_{timestamp}.jsonAny 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.

ArtifactPipelineContentsDebugDumper method
{stem}_pre_morphosyntax.chaMorphotagCHAT before morphosyntax injectiondump_pre_morphosyntax_chat
{stem}_utr_input.chaAlignCHAT before UTR timing injectiondump_utr_input
{stem}_utr_tokens.jsonAlignASR timing tokensdump_utr_tokens
{stem}_fa_input.chaAlignCHAT before FAdump_fa_grouping (writes _fa_input.cha)
{stem}_fa_grouping.jsonAlignFA group structuredump_fa_grouping
{stem}_fa_group_{n}.jsonAlignPer-group FA timingsdump_fa_group_result
{stem}_fa_evidence.jsonAlignVersioned group source/cache identity, pre-injection timing score/provenance, and typed post-inference decisions; fail-closed when requesteddump_fa_evidence
{identity}_rev_evidence.jsonRev transcribe / align UTRVersioned provider-media/request/cache/projection identity; collision-resistant and fail-closeddump_rev_evidence
{stem}_asr_response.jsonTranscribeRaw ASR outputdump_asr_response
{stem}_post_asr.chaTranscribeCHAT after ASR assemblydump_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": null when 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: id as 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:

  1. Read the dump
  2. Extract the request
  3. Pipe it to python -m batchalign.worker --task morphosyntax --lang eng
  4. 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 fra language items with word au
  • “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:

IssueAuto-FixWhen
"lemma": nullDefault to surface textAlways
"lemma": ""Default to surface textAlways
"lemma" absentDefault to surface textNon-Range tokens
"upos": nullDefault to "X" (unknown)Always
"deprel": nullDefault to "dep"Always
"deprel": "<pad>"Replace with "dep"Always
Bogus lemma (punct for word)Replace with surface textWhen text has letters
"id": [n] (single tuple)Unwrap to nAlways

What Could Be Auto-Fixed Next

Based on the MWT range token issue discovered on a worker machine:

IssueProposed Auto-FixRisk
Range token missing annotation fieldsInsert defaults (upos="X", deprel="dep", lemma="")Low, range tokens are display-only in CHAT
Non-Range token missing ALL annotation fieldsDefault all fields from surface textMedium, 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

FileWhat
crates/batchalign-transform/src/morphosyntax/stanza_raw.rsnormalize_word_dict(), diagnose_parse_failure(), StanzaWordDiagnostic
crates/batchalign/src/morphosyntax/worker.rsEnriched error path with diagnostics (imports diagnose_parse_failure from talkbank_transform::morphosyntax)
crates/batchalign/src/morphosyntax/batch.rsError-path batch handling
crates/batchalign/src/runner/debug_dumper.rsDebugDumper and per-artifact dump methods (see Layer 3 table)
crates/batchalign/src/worker/handle/protocol.rs:155dump_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 a CHATValidationException during 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), typically ValidityLevel::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 through PostValidated, and possession of one is the proof; the writer seams take the proof rather than a String.
  • What it checks: whichever judgement the command’s INPUT entitles it to. A command that admitted its input at a ValidityLevel is held to validate_to_level at that level plus validate_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 %mor had 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:

  1. ASR inference produces raw tokens
  2. Rust post-processing converts tokens to utterances
  3. build_chat() assembles a CHAT AST and serializes it
  4. 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

JobFileErrorRoot CauseOutput?
696870c7maria18.wav[L0] File has 1 parse error(s)Unknown, CHAT discardedNo
696870c7sastre02.wav[L0] File has 1 parse error(s)Unknown, CHAT discardedNo
696870c7maria16.wavend_s must be >= start_sWhisper hallucinated inverted timestampsNo
696870c7maria27.wavend_s must be >= start_sWhisper hallucinated inverted timestampsNo

Validity Levels

The validation system uses three levels, each including all checks from lower levels:

LevelNameChecks
L0ParseableNo parse errors (clean tree-sitter CST)
L1StructurallyCompleteL0 + participants, languages, speaker codes, terminators
L2MainTierValidL1 + 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:

  1. Always write the output: even with validation warnings, the CHAT is the most valuable diagnostic artifact
  2. Log the validation errors: so they can be investigated
  3. 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:

  1. 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.

  2. Debug artifact dumps: with --debug-dir, the transcribe pipeline writes intermediate CHAT at every stage boundary. The sample_post_asr.cha artifact 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:

  1. 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. See crates/batchalign/src/revai/preflight.rs::skip_postprocessing_hint.

  2. Fallible ChatWordText construction, with two distinct outcomes. transcript_from_asr_utterances calls ChatWordText::try_from_lang per 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 xxx would 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_invalid carries 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.json into the run’s debug artifacts. Before 2026-09-03 this fact went only to a tracing::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.

  3. 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

FileRole
../chatter/crates/talkbank-model/src/pipeline.rsValidityLevel enum
../chatter/crates/talkbank-transform/src/validate.rsvalidate_to_level(), validate_output()
crates/batchalign/src/pipeline/text_infer.rsSingle-file cached text pipeline with pre/post gates
crates/batchalign-transform/src/utseg.rsBatch utseg path with pre-validation + CHAT dump
crates/batchalign/src/pipeline/transcribe.rsTranscribe pipeline stages (ASR → utseg → morphosyntax)
crates/batchalign/src/runner/debug_dumper.rsDebugDumper: zero-cost artifact writer
batchalign/inference/asr.pyWhisper 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_likeat::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:

ProjectMPS status
OpenAI WhisperPR #382 to enable MPS was never merged, users reported crashes, MPS slower than CPU
faster-whisperMPS not supported at all (ValueError: unsupported device mps)
pyannote-audioMPS produces wrong timestamps (#1337, closed wontfix); kernel crashes on M4 (#1886)
whisper.cppUses Metal API directly, bypassing PyTorch MPS entirely
PyTorch LightningMPS marked “experimental”

Representative open PyTorch MPS issues:

  • #178497: sum, mean, count_nonzero give wrong results, confirmed by PyTorch team as originating in Apple’s MPS framework. Closed 2026-05-14 as completed; fix landed on main after the v2.12.0 branch cut, so it ships in v2.13, not v2.11 or v2.12.
  • #179352: scaled_dot_product_attention incorrect 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:

  1. pytorch/pytorch#144634, torch.mps.synchronize() hangs on shader fault. Filed by PyTorch maintainer @malfet: “few attempt[s] to reproduce the same resulted in system hang.” Apple engineer acknowledged, still open.

  2. pytorch/pytorch#162872, Event.synchronize() deadlocks before elapsed_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 kIOGPUCommandBufferCallbackErrorImpactingInteractivity fires when a Metal command buffer blocks WindowServer compositing for too long. An undocumented env var AGX_RELAX_CDM_CTXSTORE_TIMEOUT=1 exists 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_attention returns 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.multinomial crashes with SIGSEGV on MPS for larger tensors (#178579, closed 2026-04-16 as completed; fix landed on main after the v2.12.0 branch cut, so it ships in v2.13, not v2.11 or v2.12)
  • uint16/uint32/uint64 binary ops produce garbage values
  • ComplexFloat dtype 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 @jhavukainen is 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-metal plugin had GPU hangup issues; v0.5.1 fixed “multiple memory leak issues leading to GPU hangups.” Users reported IOGPUDevice::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

ModuleDevice orderDtype
fa.py (Whisper FA)CUDA → CPUCUDA: float16; CPU: float32
fa.py (Wave2Vec FA)CUDA → CPUfloat32
asr.py (Whisper ASR)CUDA → CPUCUDA: float16; CPU: float32
speaker.pyCUDA → 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:

TypeStatusPyTorch behavior
bfloat16Not in Metal specCrashes, wrong results, or TypeError depending on operation
float64Not in Metal specTypeError: Cannot convert Double to MPS
int64Not in Metal specCrashes on some ops (e.g. abs_out_mps)
complex128Not in Metal specConversion 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 a MemAvailable equivalent like Linux.

Mitigations:

  • torch.mps.empty_cache(): call periodically during long-running inference
  • PYTORCH_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, 0 to 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

IssueStatusWhat to do if fixed
pytorch/pytorch#141864Closed (won’t fix)N/A, Metal lacks native bfloat16. Would require Apple hardware/firmware change.
pytorch/pytorch#136624ClosedSpecific to torch.arange; the broader bfloat16 gap remains.
pytorch/pytorch#104191ClosedSpecific to torch.embedding.

Verdict: bfloat16 on MPS will not be fixed. Our float32 workarounds are permanent.

Memory

IssueStatusWhat to do if fixed
pytorch/pytorch#105839OpenMPS OOM with memory available. If fixed, we could remove empty_cache() calls.
pytorch/pytorch#154329OpenMPS memory leak during inference. Critical for long-running server.
pytorch/pytorch#145374OpenMPS memory leak in LSTM iterations.
pytorch/pytorch#114096OpenLeak when converting device+type simultaneously via .to().

Whisper

IssueStatusWhat to do if fixed
huggingface/transformers#31408ClosedSDPA broke MPS in v4.40.0. Our attn_implementation="eager" workaround is for this. Check if later versions fixed SDPA on MPS.
pytorch/pytorch#141774Closed 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#162092OpenVoxtral (Whisper variant) produces gibberish on MPS.

Speaker Diarization

IssueStatusWhat to do if fixed
pyannote/pyannote-audio#1337Closed (wontfix)Wrong timestamps on MPS. If reversed, we could enable MPS for diarization.
pyannote/pyannote-audio#1886OpenKernel crash on M4 with MPS.

MPS Correctness

IssueStatusWhat to do if fixed
pytorch/pytorch#134534OpenModel 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:

  1. Never use MPS. Our standard device selection is CUDA > CPU. MPS is permanently excluded due to kernel-level deadlocks.
  2. Use force_cpu_preferred() as the first check: respect the operator’s CPU override.
  3. 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.
  4. Use float16 on CUDA, float32 on 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:

TestFileWhat it verifies
test_load_whisper_fa_selects_device_and_dtypetests/pipelines/fa/test_fa_inference.pyWhisper FA: CPU when MPS-only, float16 on CUDA, float32 on CPU
test_load_wave2vec_fa_selects_expected_devicetests/pipelines/fa/test_fa_inference.pyWave2Vec FA: CPU when MPS-only
test_load_wave2vec_fa_forces_float32_on_mpstests/pipelines/fa/test_fa_inference.pyWave2Vec FA: no MPS-specific .float() needed (MPS excluded)
test_load_whisper_asr_ignores_mps_and_applies_cantonese_overridestests/pipelines/asr/test_asr_inference.pyASR: CPU when MPS available, Cantonese config still applied
TestGpuHasCudaDevice (4 tests)tests/test_worker_serving_mode.pyCUDA detection helper: force_cpu interaction
TestServingModeSelection (6 tests)tests/test_worker_serving_mode.pyGPU profile: sequential on CPU, concurrent on CUDA only
Speaker device selection testtests/pipelines/speaker/test_speaker_inference.pySpeaker: 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 bumpalo arenas 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

  1. Lifetime rigidity: Arena references can’t escape the owning function, but our processing pipelines return intermediate results across function boundaries.
  2. Marginal gains: The targeted patterns below already eliminate hot spots. Adding bumpalo on top yielded < 2% additional improvement in benchmarks.
  3. API friction: bumpalo::collections::Vec and String are different types from std, 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

  1. Start with the cheapest fix. Reuse a buffer, use a flat table, avoid the allocation entirely.
  2. Don’t add an arena for < 10 allocations per call.
  3. 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

CrateUsed by pyo3 forCould be removed?
batchalign-typesDomain newtypes, worker IPC types (ExecuteRequestV2, etc.)No, core shared types
talkbank-transformCantonese 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 / numpyPyO3 bridge, NumPy array handling for audioNo, fundamental
serde / serde_jsonJSON serialization for IPCNo, fundamental
tracing / tracing-subscriberWorker-process logging (env-filtered)No, required for diagnostics

What was removed

Removed depWhy
batchalign (+ transitive batchalign)CLI binary shipped as package data instead of compiled into .so
batchalign-revaiDead code, server uses Rev.AI directly
talkbank-modelOnly used by deleted ParsedChat class
talkbank-parserOnly used by deleted parse helpers
indexmap, thiserrorOnly 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.py falls back to target/debug/batchalign3 or cargo 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 package
  • BATCHALIGN_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 batchalign or batchalign directly, not through pyo3.

  • Do not vendor types. Use path dependencies. batchalign-types is 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:

  1. frontend/ as the canonical React UI.
  2. 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 progress
  • http://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

CommandSignaturePurpose
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() -> ServerStatusInfoSpawn batchalign3 serve start --foreground --port 18000 as managed child and return the resulting { running, port, binary_path, pid } snapshot.
stop_server() -> ServerStatusInfoKill managed server process and return the resulting { running, port, binary_path, pid } snapshot.
server_status() -> ServerStatusInfoReturn { running, port, binary_path, pid }.
get_batchalign_path() -> Option<String>Find batchalign3 on PATH.
is_first_launch() -> boolTrue if ~/.batchalign.ini doesn’t exist (triggers setup wizard).
read_config() -> UserConfigRead ~/.batchalign.ini (engine + Rev.AI key).
write_config(config: UserConfig) -> DesktopCommandAckWrite ~/.batchalign.ini.

Tauri Events

EventPayloadPurpose
desktop://server-status-changedServerStatusChangedEventShell-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

PluginPermissionsPurpose
dialogdialog:allow-open, dialog:allow-saveNative file/folder picker dialogs
shellshell:allow-openOpen 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.ts inventories raw command/event identifiers and pairs them with request/response payload types
  • DesktopProvider wraps the app in frontend/src/main.tsx
  • frontend/src/desktop/DesktopContext.tsx exposes focused hooks: useDesktopEnvironment(), useDesktopFiles(), useDesktopConfig(), and useDesktopServer()
  • lib/tauri.ts keeps the dynamic imports and browser fallbacks for dialogs, config, server lifecycle, event subscription, and shell-open helpers
  • runtime.ts still 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/:

ComponentRole
CommandPicker2×3 grid of command cards (transcribe, morphotag, align, translate, utseg, benchmark)
FolderPickerNative folder picker wrapping Tauri dialog plugin
OutputModeSelectorSeparate folder vs in-place output toggle
ProcessFormMain orchestrator: command → configure → processing
ProcessingProgressSSE-driven live file progress with completion actions
RecentJobsCompact recent jobs list for home screen

Supporting hooks:

HookRole
useServerLifecycleAuto-start server, subscribe to desktop://server-status-changed, track status (starting/running/stopped/not-found), expose start/stop
useServerHealthPoll GET /health every 5s, expose reachability + capabilities
useSubmitJobReact Query mutation for POST /jobs with paths_mode: true
useJobStreamSSE EventSource wrapper for /jobs/{id}/stream

Supporting components:

ComponentRole
ServerStatusBarGreen/yellow/red dot + label + manual start/stop button
ErrorRecoveryStructured error messages grouped by category with suggested actions
OnboardingOverlayFirst-time 3-step guide (pick task → select files → watch progress)
HelpPanelSlide-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/:

ComponentRole
SetupWizardMulti-step flow: welcome → engine selection → API key → done
EngineCardRev.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 serialization
  • src-tauri/src/main.rs: discover_files_in_dir() recursion/filter/sort
  • src-tauri/src/config.rs: config roundtrip and Windows home-dir fallbacks
  • src-tauri/src/server.rs: ServerProcess empty/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

RouteComponentPurpose
/dashboardDashboardPageTwo-column: job list + system panels
/dashboard/jobs/:idJobPageJobDetailPageViewFull job detail with file table
/dashboard/visualizationsVisualizationsIndexAlgorithm trace landing
/dashboard/visualizations/:typeDPAlignmentPage, etc.Individual visualizations
/processProcessPageDesktop processing flow

Job Display Components

ComponentFileRole
JobListJobList.tsxRenders sorted list of JobCards
JobCardJobCard.tsxSingle job summary: command badge, status, progress bar, metadata
JobDetailPageViewJobDetailPageView.tsxFull detail: metadata grid, progress, file table, errors
FileTableFileTable.tsxDirectory-grouped file rows with status, stage, progress
PaginatedFileListPaginatedFileList.tsxWraps FileTable with pagination controls
FilterTabsFilterTabs.tsxStatus filter tabs (All/Processing/Done/Error/Queued) + search
ProgressBarProgressBar.tsxAnimated fill bar with striped/indeterminate modes
PipelineStageBarPipelineStageBar.tsx5-segment phase indicator (Read/Transcribe/Align/Analyze/Finalize)
StatusBadgeStatusBadge.tsxColored status pill
ErrorPanelErrorPanel.tsxError groups by code with expandable file lists
ErrorCodeGroupErrorCodeGroup.tsxSingle error code bucket
StatusSummaryStripStatusSummaryStrip.tsxInline done/error/active counts
ActionButtonsActionButtons.tsxCancel/restart/delete controls

System Health Components (Right Column)

These panels live in the dashboard right column and consume HealthResponse data from the Zustand healthMap:

ComponentFileData Fields Used
WorkerProfilePanelWorkerProfilePanel.tsxlive_worker_keys, live_workers
MemoryPanelMemoryPanel.tsxsystem_memory_total_mb, system_memory_available_mb, system_memory_used_mb, memory_gate_threshold_mb, memory_gate_aborts
VitalsRowVitalsRow.tsxworker_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:

PhaseColorStages
Readzincreading, resolving_audio, checking_cache
Transcribeemeraldtranscribing, recovering_utterance_timing, recovering_timing_fallback
Alignindigoaligning, applying_results
Analyzevioletanalyzing_morphosyntax, segmenting_utterances, translating, resolving_coreference, segmenting, analyzing, comparing, benchmarking
Finalizeamberpost_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 English
  • profile:stanza:eng (2 total, 1 idle): two Stanza workers, one currently checked out
  • profile: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_stage is the stable machine-readable stage code from Rust.
  • progress_label is 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

ModeEntry PointUse CaseRequirements
Mock servernpm run test:e2e (default)Fast local iteration, no depsNone (mock server built-in)
Real servermake batchalign-dashboard-e2e-realFull integration, real Batchalign binaryRust binary, Python installation
CI canonicaldashboard-e2e job in batchalign-python.ymlPre-merge gateSame as real-server
Desktop appnpm run test:e2e from apps/dashboard-desktop/Tauri webview integrationTauri + 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:

  1. Builds the wheel from the Batchalign Rust/Python stack
  2. Sets BATCHALIGN_REAL_SERVER_E2E=1 and BATCHALIGN_PLAYWRIGHT_WITH_DEPS=1
  3. Runs bash scripts/run_react_dashboard_smoke.sh which orchestrates:
    • API type generation
    • Frontend build
    • E2E environment setup (Playwright browsers + dependencies)
    • Playwright tests against a real batchalign3 server 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_E2E env 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.json and frontend/src/generated/api.ts are in sync
  • Fails if generated artifacts are stale
  • Part of both Makefile and CI gates

Data Flow

Dashboard (fleet monitoring)

  1. Init: Resolve the server URL from runtime config
  2. WebSocket: Connect to the server and receive snapshot + real-time updates
  3. REST: React Query fetches job lists and details
  4. State: Zustand store tracks dashboard and connection state
  5. Updates: WebSocket patches store + query cache in real time

Detail pages follow the same split:

  • pages/JobPage.tsx is the route shell
  • hooks/useJobPageController.ts owns job lookup, server resolution, and store sync
  • components/JobDetailPageView.tsx owns detail presentation and file filters

Process flow (desktop)

  1. Setup: App checks useDesktopConfig().isFirstLaunch() and shows SetupWizard before routes load
  2. Server: useServerLifecycle reads useDesktopServer().serverStatus(), subscribes to desktop://server-status-changed, and auto-starts via useDesktopServer().startServer()
  3. Health: useServerHealth polls GET /health on a fixed interval
  4. Command: User picks from the command card grid
  5. Files: useDesktopFiles().pickFolder() → native dialog → useDesktopFiles().discoverFiles() → file list
  6. Submit: useSubmitJob sends POST /jobs with paths_mode: true
  7. Progress: useJobStream opens SSE to /jobs/{id}/stream for live updates
  8. 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.
GateWarm predicateSource
CPU loadavggetloadavg(3).one < available_parallelism()cpu_gate.rs
Memory floor + projectionavailable_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:

TierTotal RAMGPU StartupStanza StartupIO StartupHeadroom
Small< 24 GB6 GB3 GB2 GB2 GB
Medium24-48 GB3 GB (LazyProfile)6 GB3 GB4 GB
Large48-128 GB16 GB12 GB4 GB8 GB
Fleet≥ 128 GB16 GB12 GB4 GB8 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:

  1. computes a requested worker count from file count, CPU, and category caps,
  2. asks the host coordinator for a job execution plan,
  3. receives a granted worker count plus a lease held for the job lifetime,
  4. 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_golden layout.

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

VariableDefaultWhat it does
BATCHALIGN_SPAWN_MIN_MEMORY_MB4096Minimum free RAM (MB) to allow a worker spawn
BATCHALIGN_MAX_CONCURRENT_SPAWNS1Max concurrent worker spawns (semaphore size)
BATCHALIGN_HOST_MEMORY_LEDGERtemp-dir pathOverride the shared host-memory ledger path
RUST_TEST_THREADSRust harness defaultOptional cap on parallel test functions; pass --test-threads=1 for isolated worker probes

Key config knobs

SettingDefaultWhat it does
memory_gate_mb2048 MBHost reserve/headroom preserved after reservations. Same constant the worker-pool admission gate enforces; operator override accepted but rarely needed.
max_concurrent_worker_startups1Host-wide limit for simultaneous worker/model startups
gpu_thread_pool_size4In-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

FileWhat
crates/batchalign/src/worker/pool/cpu_gate.rsLayer 0 admission: getloadavg(3) vs available_parallelism()
crates/batchalign/src/worker/pool/memory_gate.rsLayer 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.rsPer-process RSS sampling for the Mode B admission estimate
crates/batchalign/src/worker/pool/idle_eviction.rsPressure-driven idle-worker eviction (largest-RSS first when available <= 4096 MB)
crates/batchalign/src/host_memory.rsHost-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.rsLocal spawn semaphore plus host-memory startup reservation
crates/batchalign/src/worker/handle/mod.rs and crates/batchalign/src/worker/handle/spawn.rsWorkerHandle::spawn() (mod.rs:73) and spawn_tcp_daemon() (spawn.rs:135) both call acquire_spawn_permit()
crates/batchalign/src/runner/mod.rsCoordinator-backed job execution planning and requeue
crates/batchalign/tests/common/mod.rsMachine-wide ML fixture lock
crates/batchalign/tests/worker_integration.rsrequire_python! macro with memory check
crates/batchalign/tests/gpu_concurrent_dispatch.rsSame
crates/batchalign/tests/worker_protocol_matrix.rsSame
.cargo/config.tomlRUST_TEST_THREADS = "1"
MakefileTiered 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, and crates/batchalign/Cargo.toml match the target.
  • batchalign/version names the same version. The Python regression test compares it with the installed wheel metadata.
  • batchalign3 version reports 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-full passes locally. It includes make gate and 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, and make lint-actionlint pass.
  • 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 Release manually for prospective vX.Y.Z with dry_run=true against 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 --help and version smoke 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.sum but 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

  1. Complete the gates above on the reviewed main commit.
  2. Run and verify the prospective-tag dry run.
  3. Create an annotated tag: git tag -a vX.Y.Z -m "batchalign3 vX.Y.Z".
  4. Push only that tag. The tag-triggered workflow builds, smokes, checksums, and creates the GitHub Release.
  5. Verify the release contains five wheels, one source distribution, both installers, and sha256.sum.
  6. Download from the published release and perform a clean installer or wheel smoke outside the source checkout.
  7. Deploy the exact released identity where an operational deployment is in scope, then verify /health and 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:

  1. Mark the GitHub Release as a prerelease or remove it from public discovery, depending on severity, and record which artifacts are affected.
  2. Fix and review the defect on main.
  3. Choose a new patch version. Never move or reuse a published tag.
  4. 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 %wor timings.
  • 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:

PlatformArtifact / test promise
Linux x86_64Wheel build, clean CLI smoke, server-health smoke; full Linux CI elsewhere
Linux ARM64Native wheel build; no release-workflow execution smoke
macOS ARM64Wheel build, clean CLI smoke, server-health smoke
macOS x86_64Wheel build; no release-workflow execution smoke
Windows x86_64Wheel 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 morphotag against 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

TriggerScopeWhere
After every release (mandatory)Full corpusFleet/Large-tier host (≥ 256 GB RAM)
Weekly on main (future scheduled CI)Full corpusFleet/Large-tier host
On demand for significant changesTargeted subsetFleet/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. (batchalign3 is the CLI name; the package directory is batchalign/.)
  • 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

  1. Run the regression suite and capture output.
  2. Diff against the committed baseline.
  3. If differences exist and are intentional, update the baseline in a dedicated commit with a clear explanation of what changed and why.
  4. 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

CategoryLabelExamples
ASR accuracyfailure/asrWrong words, missing segments, language detection failures
Alignmentfailure/alignmentTiming drift, bullet placement errors, silence misattribution
Morphosyntaxfailure/morphosyntaxWrong POS tag, missing lemma, MWT expansion failure
Infrastructurefailure/infraCrashes, hangs, OOM, worker spawn failures, deploy errors
CHAT formatfailure/chat-formatParse failures, serialization bugs, roundtrip drift

Process

  1. Each reported failure gets a GitHub Issue with the appropriate failure/* label.
  2. The issue body includes: reproduction steps, input file (or trimmed fixture), observed vs. expected output, and the failure category.
  3. Root cause is documented in the issue before closing.
  4. Every fix includes a regression test that reproduces the original failure (red/green TDD: write the failing test first, then fix).
  5. 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

TestWhat it verifies
Concurrent spawn/kill cyclesWorkers 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 resultsTimeout fires, partial results are discarded, job is marked failed
Memory pressure during model loadingOOM is caught and reported, server remains operational
Multiple simultaneous jobs contending for workersJob queue drains correctly, no deadlock or starvation

Recovery tests

TestWhat it verifies
Server restart with in-flight jobsJobs are marked as failed or retried; no silent data loss
Worker crash recoveryServer spawns replacement worker, next job succeeds
Cache corruption recoveryCorrupted cache entries are detected and evicted, not served
Network interruption during remote executionClient receives a clear error; server-side state is consistent

Implementation

  • Invoke cargo test -p batchalign --test <stress-test> directly. (This said “use cargo nextest with 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

DimensionMetricTargetNotes
CI green rate% of main branch builds passing100%Any red build blocks release
Clippy cleanWarnings on cargo clippy --all-targets0Includes all workspace members
Wheel smokeClean pip install + batchalign3 --help passesPassCovers sdist and wheel paths
Cross-platformSmoke tests on macOS (ARM) + Linux (x86_64)2/2CI matrix or manual verification
Version consistencyCargo.toml, pyproject.toml, CLI --version agreeMatchChecked by release script
License consistencyAll surfaces show BSD-3-ClauseMatchCargo.toml, pyproject.toml, LICENSE
Audit cleanpip-audit + cargo-deny report no critical vulns0 criticalAdvisory-only items documented
Test coverageIntegration + unit + golden tests pass>80% line coverageMeasured via cargo llvm-cov
Regression baselineCorpus regression run shows no unintentional driftNo driftPer Section 1
Open blockersGitHub Issues labeled release-blocker0All must be closed or deferred with justification

Process

  1. Before tagging: run the scorecard checks (automated where possible, manual where not yet automated). Record results.
  2. Blockers: any dimension marked as failing blocks the release. Resolve or explicitly defer with a documented justification and a follow-up issue.
  3. 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.
  4. 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:

  1. Rust owns argument parsing, UX, and process orchestration.
  2. Python owns training/inference library integration for model training.
  3. Interpreter resolution must remain uv-friendly: BATCHALIGN_PYTHON -> VIRTUAL_ENV -> python3.

Rationale

  1. Training-specific dependencies are Python-native and already production validated.
  2. Rewriting training loops in Rust now would be high-risk, low-ROI versus finishing CLI/server/runtime migration.
  3. The bridge keeps migration momentum while avoiding duplicate training stacks.

Consequences

  1. Shipping still requires a compatible Python runtime for models.
  2. CLI/server/runtime operations remain Rust-first.
  3. Migration accounting treats models as an intentional Python-core island rather than accidental legacy code.

Exit Criteria For Future Rust Port

Revisit only when all are true:

  1. A Rust training stack is selected and benchmarked with parity targets.
  2. Feature parity test corpus exists for training outputs.
  3. 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:

  1. tree-sitter grammar catch-alls for unknown headers and unsupported lines
  2. Rust-side recovery/reporting for localized parse failures that escape the grammar
  3. 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 @UTF8 or @Begin are 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:

  1. Algorithm strategies: different implementations of the same operation (e.g., global vs. per-speaker UTR alignment).
  2. Engine selection: choosing which ML backend to use (Whisper vs. Rev.AI for ASR, Wave2Vec vs. Whisper for FA, Pyannote vs. NeMo for diarization).
  3. Language-specific processing: per-language rules for morphosyntax, number expansion, text normalization.
  4. 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:

StrategyDescriptionStatus
Global UTRCurrent single-stream monotonic DPImplemented
Backbone UTRStrip &* segments before DPProposed
Per-speaker UTRDiarize, then per-speaker DPProposed

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:

  1. Implement it as a sibling file under crates/batchalign/src/chat_ops/fa/utr/ next to the existing strategy files.
  2. Reuse the UtrStrategy trait from chat_ops/fa/utr.rs; do not widen the trait surface unless absolutely required.
  3. Wire selection in crates/batchalign/src/runner/dispatch/utr.rs, following the existing pattern for Global and TwoPass.

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:

  1. 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.
  2. 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.
  3. 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:

  1. Hidden flag, default = current behavior. Used only in development and corpus experiments.
  2. Visible flag, default = current behavior. Documented in --help with a note that the alternate strategies are experimental.
  3. Change the default if the new strategy proves better on real data.
  4. 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 dimensionMechanismWhy
Algorithm strategies (UTR, future FA grouping)TraitMultiple Rust impls, need controlled comparison, independently testable
Engine selection (ASR, FA, Speaker)Enum matchVariation is in Python worker, not Rust logic; match arms are trivial
Language processing (morphosyntax, ASR post)Conditional + module + table1-3 languages with special handling; below threshold for trait overhead
Command pipelinesExplicit functionsPipelines 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).