Preprocessing and Postprocessing for Model Inference
Status: Current Last updated: 2026-05-19 20:22 EDT
All domain logic, text normalization, alignment, result injection, and error recovery, lives in Rust. Python workers are stateless ML inference endpoints. This chapter documents the preprocessing that prepares data for inference and the postprocessing that incorporates results back into the CHAT AST.
The Boundary Principle
Python receives structured payloads (lists of words, audio paths, language codes) and returns structured results (POS tags, timestamps, parse trees). It never sees CHAT text, never parses tiers, and never makes alignment decisions.
Rust: CHAT AST → extract words → clean text → build payload
│
Python: load model → run inference → return structured output
│
Rust: validate response → align with AST → inject results → serialize CHAT
Preprocessing by Task
Morphosyntax
Extract (talkbank-transform/morphosyntax/payload.rs::collect_payloads):
- Walk content with
walk_words(domain=Mor) - Collect
cleaned_text()for each alignable word - Replace special forms (
@c→"xbxxx",@s→ language marker), Stanza can’t handle CHAT-specific markers - Build payload:
Vec<String>of words per utterance
Payload → Python:
{"words": ["I", "want", "cookie"], "lang": "eng"}
Python returns: Raw Stanza to_dict() output, POS tags, lemmas, dependency parse, features.
Postprocess (talkbank-transform/morphosyntax/injection.rs,
talkbank-transform/retokenize/, talkbank-transform/morphosyntax/sentence_mapping.rs):
Two injection paths diverge based on TokenizationMode:
- Preserve (default):
map_ud_sentence()merges MWT Range tokens into clitic MOR items (1 MOR per CHAT word).inject_morphosyntax()adds %mor/%gra tiers without modifying the main tier. - StanzaRetokenize (
--retokenize):map_ud_sentence_expanded()produces per-component MOR items. Range parent tokens are filtered from the token vector.retokenize_utterance()rewrites the main tier with Stanza’s expanded tokens and injects per-component %mor/%gra.
Both paths share GRA generation via build_gra_and_validate().
Steps:
- Range token filtering (Retokenize only): exclude
UdId::Rangeparent entries from the token vector, only component words appear. - Grammatical-invariant rewrites (
apply_grammatical_invariantsattalkbank-transform/morphosyntax/invariants.rs:14,talkbank-transform/morphosyntax/invariants/for the per-rule modules): operate on the typedUdSentenceBEFOREmap_ud_sentenceruns. English primary only today, dispatched vialang2(&ctx.lang)intalkbank-transform/morphosyntax/sentence_mapping.rs. The only rule shipped so far isfinite_verb_main_clause::rescue_english_copula_progressive(atinvariants/finite_verb_main_clause.rs:9), detects<noun>'s <-ing>patterns that Stanza mis-parses as possessive-gerund and rewrites them into a coherent copula-progressive tree (PART → AUXbe, root NOUN → VERB VerbForm=Part, governor deprel → nsubj). See Stanza Limitations, Defect 1 for the defect description and re-evaluation procedure. - UD → CHAT mapping: Convert Universal Dependencies POS/features to TalkBank %mor format (category mappings, stem extraction, feature translation).
- MWT handling: In Preserve mode, multi-word tokens produce one clitic MOR (
pron|it~aux|be). In Retokenize mode, each component gets its own MOR. - %gra construction: Build dependency graph with chunk-based indexing (GRA indices are %mor chunk positions, not surface word positions).
- L2 splice (default; opt out with
--no-l2-morphotag): after primary injection, @s words withL2|xxxare routed to secondary Stanza models and spliced back with real morphology. L2 extracts itsl2_deferredpositions from the ORIGINALud_responsescaptured beforeapply_grammatical_invariantsran (crates/batchalign/src/pipeline/morphosyntax.rs:352-356, plus the L2 dispatch incrates/batchalign/src/morphosyntax/batch.rs), so the English rewrite cannot corrupt L2 position mapping. - Validation: Check word count alignment, GRA cycle detection, chunk count consistency.
- Injection: Replace or add %mor and %gra dependent tiers on the utterance.
Forced Alignment
FA preprocessing has two stages, UTR (Utterance Timing Recovery) and FA proper. See Forced Alignment for the complete pipeline.
UTR: Injects utterance-level timing from ASR tokens before FA runs. Supports global single-pass and two-pass overlap-aware strategies. See Overlapping Speech for the two-pass algorithm and CA marker-aware windowing.
FA: Groups utterances into time-windowed clusters, sends each group’s words + audio window to Python for word-level timestamp inference, then injects timing back into the AST.
Python receives: Audio window (start_ms, end_ms) + word list.
Python returns: Per-word timestamps.
Rust postprocessing: Word end-time chaining, conditional word timing clamping
(only on re-alignment runs where %wor already exists, see
Word timing clamping policy),
monotonicity enforcement, pause assignment, %wor tier generation.
ASR (Automatic Speech Recognition)
ASR preprocessing is the most complex because raw ASR output needs extensive normalization before it becomes CHAT:
ASR postprocessing pipeline (crates/batchalign-transform/src/asr_postprocess/):
| Stage | Module | What it does |
|---|---|---|
| 1. Compound merging | compounds.rs | Join split compounds: ice + cream → ice+cream (3,584 pairs, O(1) HashSet) |
| 2. Timed word extraction | mod.rs | Convert seconds → milliseconds, extract ASR tokens, strip MOR_PUNCT, lowercase |
| 2d. Cantonese normalization | cantonese.rs | Simplified → traditional + domain replacements (31 entries, Aho-Corasick), applied once to the whole monologue through AlignedNormalization, which proves the character count did not change |
| 3. Multi-word splitting | mod.rs | Split space-separated tokens with timestamp interpolation |
| 4. Number expansion | num2text.rs + ordinal_year_eng.rs | Single Rust per-word pass: cardinals via per-language NUM2LANG (47 langs), CJK via num2chinese, currency via try_expand_currency, English ordinals/years/decades via ordinal_year_eng. No Python num2words IPC. See Number Expansion. |
| 5. Long turn splitting | mod.rs | Break turns > 300 words into separate utterances |
| 5b. Pause-based splitting | mod.rs | Long pauses in unpunctuated runs create utterance boundaries |
| 6. Retokenization | mod.rs | Split into utterances by punctuation boundaries |
All module filenames in the table above are under
crates/batchalign-transform/src/asr_postprocess/.
Retokenization (step 6) is particularly important: ASR produces one long stream of text, but CHAT needs it segmented into utterances. The retokenizer uses punctuation (., ?, !) as utterance boundaries and assigns timing from the ASR tokens.
English transcribe corrections
Three English orthographic corrections are woven into the pipeline at
specific points, gated on lang == "eng". Each hook sits exactly where
the surrounding stage either produces or preserves the surface the rule
must see. See
English Transcribe Corrections
for the full rule set and probe-verdict citations.
flowchart TD
Raw["AsrOutput\n(raw provider tokens)"]
TPS["strip_english_title_periods_on_elements\n(talkbank-transform/asr_postprocess/cleanup.rs)\n⚠ BEFORE stage 3 split"]
S3["stage 3: split_multiword_tokens\n(. treated as separator)"]
S4["stages 4-5b:\nnumber expansion,\nlong-turn/pause splits"]
ICap["apply_english_transcribe_rules_pre_retokenize\n(I-cap on words)"]
S6["stage 6: retokenize by punctuation\n→ Vec<Utterance>"]
UCap["apply_english_transcribe_rules_post_retokenize\n(utterance-initial cap, skips retrace/markers)"]
Out["Vec<Utterance>\nready for CHAT assembly"]
Raw --> TPS --> S3 --> S4 --> ICap --> S6 --> UCap --> Out
Why each hook lives where it does:
- Title-period strip runs on raw
AsrElements, before stage 3. Stage 3’snormalized_split_separatortreats.as a word separator, soDr.would fragment intoDr+.before the allowlist could match. Stripping on the raw element keepsDra single token through every subsequent stage. - I-cap runs on per-word chunks, before retokenize. At this point numbers are already expanded and compounds merged, but utterances have not yet been carved out of the stream, the rewrite is a local surface fix.
- Utterance-initial cap runs after retokenize and after retrace detection.
The “real” first word of an utterance is only knowable once
WordKindtags are assigned; the rule walks pastxxx/yyy/www,&-prefixed tokens, andWordKind::Retracecopies to find it.
Translation
Extract: Full utterance text (all words concatenated).
Python: Google Translate or SeamlessM4T → translated text.
Inject: Add %xtra dependent tier with the translated text.
Utterance Segmentation
Extract: Words per utterance (same as morphosyntax). Python: Stanza constituency parser → parse tree with boundary predictions. Postprocess: Assign boundary codes (utterance break, clause break, continuation) based on constituency structure. Apply boundaries to merge/split utterances.
Coreference
Extract: All sentences in the document (document-level, not per-utterance).
Python: Stanza coref → coreference chains.
Inject: Sparse %xcoref tiers on utterances that contain coreferent mentions.
Retokenization: The Character-Level Bridge
When Stanza tokenizes differently than CHAT, the retokenizer (retokenize/) bridges the gap:
CHAT words: ["don't", "wanna"]
Stanza tokens: ["do", "n't", "wan", "na"]
The retokenizer:
- Concatenates both word lists into character strings
- Runs character-level DP alignment
- Builds a deterministic mapping from Stanza token indices back to CHAT word indices
- Uses this mapping to assign Stanza annotations (POS, lemma, depparse) to the correct CHAT words
This handles splits (don't → do + n't), merges, and even reorderings across languages. The mapping uses a length-aware fallback for ambiguous cases.
Cache Keys
Each task computes a cache key from its input payload, so identical inputs skip inference:
| Task | Cache key formula |
|---|---|
| Morphosyntax | `BLAKE3(“{words} |
| Utseg | `BLAKE3(“{words} |
| Translation | `BLAKE3(“{text} |
| FA | `BLAKE3(“{audio_identity} |
| UTR ASR | `BLAKE3(“utr_asr |
| Coref | No caching (document-level context) |
Cache keys are 64-char hex BLAKE3 hashes via the shared
CacheKey::from_content newtype (crates/batchalign/src/chat_ops/cache_key.rs:23).
Cache is tiered: moka in-memory (hot) → SQLite on-disk (cold).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).