feat: simultaneous-MT variant for mlx-llm-mt (AlignAtt commit policy) (depends on #422) - #423
feat: simultaneous-MT variant for mlx-llm-mt (AlignAtt commit policy) (depends on #422)#423clkao wants to merge 15 commits into
Conversation
3135887 to
ed4d19c
Compare
The model occasionally emits <|hy_place▁holder▁no▁2|> and hallucinates free text after it. The previous fix truncates the string after decode — correct output, but the hallucinated tail is still fully decoded. Now the stream_generate loop stops in-loop the moment the placeholder is emitted. The stop condition is resolved from the tokenizer at call time (_placeholder_stop_check): a single-id placeholder (Hy-MT2-1.8B: id 120020) stops exactly on that id; a fragmented placeholder (Hunyuan-MT-7B: ~13 byte-level BPE ids) uses a rolling id window. Tokenizers that fragment it beyond a 16-id window get no in-loop stop and keep the post-hoc string strip as the only mitigation. _strip_hy_placeholder stays as the defensive fallback for all consumers. The helper is module-level so the simul-MT subclass (PR QuentinFuxa#423 branch) can reuse the same mechanism at its commit/release sites. Tests: 4 new stub-stream tests (single-id stop, fragmented stop, no-stop-id fallback to string strip, clean output unaffected). 16/16 in tests/test_mlx_llm_mt.py.
ed4d19c to
95b9bf0
Compare
|
I approved the CI/CodeQL workflows after checking that this stack does not modify workflow files. This PR is correctly stacked on #422; I will review the incremental simultaneous-MT code once #422's packaging and per-session translation blockers are fixed and this branch is rebased on the corrected base. Keeping the review ordered this way avoids validating the same broken session/dependency contract twice. |
QuentinFuxa
left a comment
There was a problem hiding this comment.
The approved CI exposed an incremental blocker independent of #422: whisperlivekit/simul_mt_capture.py imports mlx.core at module import time, so the default Linux test environment fails during collection with ModuleNotFoundError: No module named 'mlx'. MLX is an optional, macOS-arm64-only extra; importing the package and collecting tests must remain safe without it. Please make the dependency lazy/guarded and keep MLX-specific execution behind a clear runtime error, with a non-MLX import/collection regression test.
The incremental branch also has two additional Ruff failures: an unused released assignment in tests/test_mlx_llm_mt_simul.py and an unsorted import block in translation_mlx_llm_mt_simul.py (on top of the inherited #422 lint failures).
Please fix these together with #422's base blockers, rebase this branch on the corrected #422 head, and rerun the full default suite on Linux without MLX installed as well as the focused MLX suite on Apple Silicon.
The model occasionally emits <|hy_place▁holder▁no▁2|> (U+FF5C pipes) and hallucinates free text after it. Display layers (tui, overlay) stripped the token, but the terminal transcript and file writer still showed the hallucinated tail. Port livecaption's source-level fix: _strip_hy_placeholder truncates at the FIRST placeholder occurrence (cut everything from it onward), applied to _translate_text output.
The model occasionally emits <|hy_place▁holder▁no▁2|> and hallucinates free text after it. The previous fix truncates the string after decode — correct output, but the hallucinated tail is still fully decoded. Now the stream_generate loop stops in-loop the moment the placeholder is emitted. The stop condition is resolved from the tokenizer at call time (_placeholder_stop_check): a single-id placeholder (Hy-MT2-1.8B: id 120020) stops exactly on that id; a fragmented placeholder (Hunyuan-MT-7B: ~13 byte-level BPE ids) uses a rolling id window. Tokenizers that fragment it beyond a 16-id window get no in-loop stop and keep the post-hoc string strip as the only mitigation. _strip_hy_placeholder stays as the defensive fallback for all consumers. The helper is module-level so the simul-MT subclass (PR QuentinFuxa#423 branch) can reuse the same mechanism at its commit/release sites. Tests: 4 new stub-stream tests (single-id stop, fragmented stop, no-stop-id fallback to string strip, clean output unaffected). 16/16 in tests/test_mlx_llm_mt.py.
- MlxLlmTranslation.new_session(target_language) creates a per-session client that shares the model cache but has fresh buffer/pending/metrics state; online_translation_factory and session_translation_factory both route through it so concurrent sessions don't cross-contaminate. - Define [tool.uv].conflicts for mlx-llm-mt vs qwen3-streaming/vllm (transformers>=5 vs ==4.57.6 pin); refresh uv.lock. - BenchmarkReport.has_wer is a @Property (was a method — 'if report.has_wer' was always truthy because the method object is truthy). - Remove mlx-qwen3-asr auto-detection from benchmark/compat.py (no such backend in this repo). - Ruff clean.
Add MlxLlmTranslationSimul subclass that drafts translation over the unstable ASR tail and commits target tokens via attention alignment (calibrated zh→en Hunyuan heads, top head L9/H5). Held tokens release from cached attention without a new MT call when ASR commits the tail. - simul_mt_capture.py: MLX Q/K capture (CapturedAttention) + commit policy - translation_mlx_llm_mt_simul.py: subclass with provisional draft + release - audio_processor.py: forward provisional buffer when no final translation - config.py: mlx_llm_mt_simultaneous flag - core.py: factory routes to Simul when flag set - parse_args.py: --simultaneous flag - cli.py: improved --simultaneous help text for wlk bench - test_mlx_llm_mt_simul.py: 21 tests (subclass, tail, commit, release, wiring) Benchmark (faster-whisper, zh_long, real-time): Base first_final=13.77s Simul first_provisional=10.48s Simul provisional EN arrives ~3.3s before base's first final. 32 tests pass (11 existing + 21 new).
…ivation Add a CALIBRATION_REGISTRY keyed by (model_repo, source_lang, target_lang) in simul_mt_capture.py. MlxLlmTranslationSimul looks up its tuple at init: found → install capture with calibrated heads; not found → silently deactivate (wants_hypothesis_tail=False, delegate to base class, log warning naming the missing tuple). 4bit zh→en is NOT seeded (calibration probe showed 48.9% argmax match vs 8bit; formal promotion gate could not run on MLX-format repo), so 4bit deactivates (translation still works via base). 10 new tests cover the 3-tuple matrix: calibrated (activates), uncalibrated (deactivates), 4bit (deactivates).
…lptext scope leak - CALIBRATION_REGISTRY key changed from fully-qualified repo (mlx-community/Hy-MT2-1.8B-8bit) to normalized model id (hy-mt2-1.8b) by stripping org prefix and quant suffix, so calibration entries are shareable across implementations (MLX, vLLM) and quantizations. - 4bit deactivation moved from missing-key to disabled_quants field on CalibrationEntry (model id matches, but quant is disabled). - Revert --target-language and --reference-translation helptext to PR1 wording; PR2 cli.py diff is now strictly the --simultaneous flag. - Add TODO comment for external-heads-loading refactor (AlignAtt4LLM translation_heads_<model>_<direction>.json pattern). - Add test for _normalize_model_id; update registry tests for new key shape.
The model occasionally emits <|hy_place▁holder▁no▁2|> (U+FF5C pipes) and hallucinates free text after it. Display layers (tui, overlay) stripped the token, but the terminal transcript and file writer still showed the hallucinated tail — and the simul commit policy committed draft tokens that included it. Port livecaption's source-level fix: _strip_hy_placeholder truncates at the FIRST placeholder occurrence (cut everything from it onward), applied to _translate_text, _translate_simul, and _release_held outputs.
Rebase result: the simul branch now sits on PR QuentinFuxa#422 head (f923d78) and inherits the module-level _placeholder_stop_check + the base engine's in-loop stop; the simul-side calibration/registry commits are intact. Wire the same stop predicate into the simul decode paths: - _translate_simul: the commit stream breaks the moment the placeholder is emitted (single-id exact stop / rolling id window, tokenizer-resolved at call time), and the token stream itself is truncated at the first placeholder id sequence so the commit policy and the stashed draft the release path reads never contain placeholder tokens; committed_len is clamped to the truncated stream. - _release_held: reads the clean stash (no new code needed); the post-hoc _strip_hy_placeholder calls at both sites stay as the fallback for tokenizers that fragment beyond the window cap. Tests: 3 new stub-stream tests (single-id stop, fragmented stop, clean stash release). Focused: 35 simul + 16 base = 51 passed. Full suite: only pre-existing qwen3-backend-shim failures (verified identical on the unmodified branch).
- simul_mt_capture.py: move mlx.core/mlx.nn imports from module level to inside CapturedAttention.__call__ and install_capture. CapturedAttention no longer inherits from nn.Module at class definition time — install_capture dynamically creates an nn.Module-backed subclass when MLX is available. Module is now collectable without MLX (Linux CI). - tests/test_simul_mt_capture_no_mlx.py: regression test importing simul_mt_capture with mlx modules removed from sys.modules. - Remove unused 'released' assignment (F841) in test_mlx_llm_mt_simul.py. - Fix import sort (I001) in translation_mlx_llm_mt_simul.py. ruff clean, 37/37 tests pass.
…-session The rebase onto PR QuentinFuxa#422 introduced new_session() per-session isolation in online_translation_factory. MlxLlmTranslationSimul inherited the base new_session which returned MlxLlmTranslation, losing the simul type and state. Override new_session to return MlxLlmTranslationSimul so each session gets fresh simul state (tail, committed tokens, draft) sharing the cached model. Update the factory test to check isinstance instead of identity (per-session creates a new instance, not the same object).
…ewer blockers + new_session fix
95b9bf0 to
57f18c4
Compare
|
Both blockers are addressed, and the branch is rebased on the corrected #422 head (
Post-rebase fix: Validation: 56/56 tests pass (35 simul + 21 base), |
|
btw @QuentinFuxa consider enabling stacked PR for the repo :) |
Mass commit mode: commit target tokens whose accessible attention mass on committed source tokens >= threshold (default 0.5), replacing the brittle argmax-only check. Measured best in livecaption A/B (more provisional content + less final lag). Token hysteresis: the MT-call hysteresis threshold is now in source BPE tokens (the MT's own unit) instead of chars, with a rolling chars-per-token ratio to estimate token growth without re-tokenizing. CJK and Latin converge to the same token budget (15 tokens ≈ one short sentence in both). For Latin source (en→zh), this dramatically reduces MT calls (13 vs 30 on a 30s demo) since the old 15-char threshold fired too often for English's ~5 chars/token ratio. Wired commit_mode + mass_threshold through config and core.
…/H5 top) en→zh: calibrated tencent/Hy-MT2-1.8B on 1138 Mxode en-zh pairs. Top head L9/H5 TS=0.86, 3/3 stability splits stable (max delta 0.0086). ja→zh: calibrated on 219 WikiMatrix ja-zh pairs. Top head L9/H5 TS=0.89, 3/3 stability splits stable (max delta 0.0244). All three directions (zh→en, en→zh, ja→zh) share L9/H5 as top head — strong evidence these are general alignment heads for hunyuan_v1_dense. 5/8 heads shared across all three directions. 4bit disabled for both (same attention-divergence reasoning as zh→en). With all three directions seeded, the simul-MT variant now covers the directions the eval harness needs (IWSLT en→zh anchor + zh→en/ja→zh flagship).
new_session dropped commit_mode/mass_threshold when constructing the per-session client, so server-context sessions defaulted to argmax even when mass was configured. Thread them so the configured policy reaches per-session clients.
eef74fa to
3cb8c85
Compare
feat: simultaneous-MT variant for mlx-llm-mt (AlignAtt commit policy)
Summary
Add a simultaneous-MT variant that overlaps MT with ASR so a provisional
translation appears during speech, before the ASR finalizes the utterance.
The variant (
MlxLlmTranslationSimul) subclasses the genericMlxLlmTranslationbase from the mlx-llm-mt PR; the base is unchanged.The mechanism: the variant drafts a translation over (committed source +
unstable ASR tail) and commits only the target tokens whose attention
(argmax over the source span) lands on a source token the ASR has
committed. Held target tokens release from cached attention when the ASR
commits more source, without a new MT call.
A hysteresis (a 15-char minimum source delta) prevents a new MT call on
every tail token; the release path re-applies the commit policy on the
cached draft instead.
The attention capture hooks
hunyuan_v1_dense.Attentionwith a manualsoftmax(QK^T) so the alignment-head attention is capturable for 8
calibrated zh->en head indices (top head L9/H5). The capture is
bit-identical to the original forward (only attention storage is added).
The
--simultaneousflag selects the variant.wants_hypothesis_tailis set so the audio processor forwards the unstable ASR tail.
User impact
New
--simultaneousflag for--translation-backend mlx-llm-mt. Whenset, a provisional EN translation appears during speech, before the ASR
finalizes the utterance. The final translation still lands at utterance
close (the quality pass).
The
audio_processorchange (6 lines) forwards the provisionaltranslation buffer when no finalized translation is produced. This is a
general fix: a backend that returns a provisional buffer with no
finalized translation now reaches the display (the buffer was dropped
before). The base
MlxLlmTranslationis unchanged.No compatibility risk: the flag defaults to off; the base path is
identical to the mlx-llm-mt PR.
Validation
Hardware: Apple M4. ASR backend:
faster-whisper(base) with thelocalagreement policy. MT:
mlx-community/Hy-MT2-1.8B-8bit(Hunyuan-MT1.8B 8bit). Audio: 31.6s Mandarin, multi-sentence. Speed: 1.0
(real-time).
The metric that matters: when does the first EN translation reach the
user?
The simul variant's first EN translation reaches the user 14.08s earlier
(2.44s vs 16.52s) because it drafts over the unstable ASR tail and
commits the aligned prefix during speech.
Trade-off: the simul variant makes more MT calls (24 vs 12). It makes
the same 12 final calls as the base, plus 12 provisional calls (one
per sentence, the first draft after each reset). The release path
(re-applying the commit policy on cached attention when the ASR commits
more source) does NOT increment the call counter — verified by unit
tests. The hysteresis prevents calls on every tail token; the count is a
superset of the base, not a reduction.
Heads log confirms the 8 calibrated heads load and (9,5) is the top
head:
MlxLlmTranslationSimul: alignment heads=[(9, 5), (13, 1), (9, 6), (12, 11), (14, 2), (14, 0), (4, 12), (1, 10)] top=(9, 5).Tests:
pytest tests/test_mlx_llm_mt.py tests/test_mlx_llm_mt_simul.py -q-> 55 passed (11 mlx-llm-mt + 32 simul + 12 alignatt).
Checklist
ruff check .(on changed files).ks