Skip to content

feat: simultaneous-MT variant for mlx-llm-mt (AlignAtt commit policy) (depends on #422) - #423

Open
clkao wants to merge 15 commits into
QuentinFuxa:mainfrom
clkao:spacedock-ensign/mlx-llm-mt-simultaneous
Open

feat: simultaneous-MT variant for mlx-llm-mt (AlignAtt commit policy) (depends on #422)#423
clkao wants to merge 15 commits into
QuentinFuxa:mainfrom
clkao:spacedock-ensign/mlx-llm-mt-simultaneous

Conversation

@clkao

@clkao clkao commented Aug 27, 2026

Copy link
Copy Markdown

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 generic
MlxLlmTranslation base 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.Attention with a manual
softmax(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 --simultaneous flag selects the variant. wants_hypothesis_tail
is set so the audio processor forwards the unstable ASR tail.

User impact

New --simultaneous flag for --translation-backend mlx-llm-mt. When
set, 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_processor change (6 lines) forwards the provisional
translation 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 MlxLlmTranslation is 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 the
localagreement policy. MT: mlx-community/Hy-MT2-1.8B-8bit (Hunyuan-MT
1.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?

Variant first EN reaches user how MT calls MT-RTF
base (no --simultaneous) 16.52s at utterance close (final) 12 0.19x
simul (--simultaneous) 2.44s during speech (provisional) 24 0.42x

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

  • I searched for an existing issue or discussion and linked it when relevant.
  • I added or updated tests for behavior changes.
  • I updated documentation for user-facing changes.
  • I ran ruff check . (on changed files).
  • I ran the relevant pytest suite.
  • I did not commit credentials, model weights, generated caches, or private data.

ks

@clkao
clkao requested a review from QuentinFuxa as a code owner August 27, 2026 07:30
@clkao
clkao force-pushed the spacedock-ensign/mlx-llm-mt-simultaneous branch from 3135887 to ed4d19c Compare August 28, 2026 21:20
clkao added a commit to clkao/WhisperLiveKit that referenced this pull request Aug 28, 2026
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.
@clkao
clkao force-pushed the spacedock-ensign/mlx-llm-mt-simultaneous branch from ed4d19c to 95b9bf0 Compare August 28, 2026 22:08
@QuentinFuxa

Copy link
Copy Markdown
Owner

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 QuentinFuxa left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

clkao added 11 commits August 29, 2026 09:38
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).
clkao added a commit to clkao/WhisperLiveKit that referenced this pull request Aug 29, 2026
@clkao
clkao force-pushed the spacedock-ensign/mlx-llm-mt-simultaneous branch from 95b9bf0 to 57f18c4 Compare August 29, 2026 17:43
@clkao

clkao commented Aug 29, 2026

Copy link
Copy Markdown
Author

Both blockers are addressed, and the branch is rebased on the corrected #422 head (9209316):

  1. Lazy MLX importsimul_mt_capture.py no longer imports mlx.core/mlx.nn at module level. The imports moved inside CapturedAttention.__call__ (the only method that uses them), and CapturedAttention no longer inherits nn.Module. The module imports and pytest --collect-only succeed without MLX installed — added tests/test_simul_mt_capture_no_mlx.py as a collection regression test (35 tests collected with sys.modules['mlx'] = None).

  2. Ruff — Removed the unused released assignment (F841) in tests/test_mlx_llm_mt_simul.py; sorted the import block (I001) in translation_mlx_llm_mt_simul.py. ruff check clean on all changed files.

Post-rebase fix: MlxLlmTranslationSimul now overrides new_session() (inherited from #422's per-session isolation) to return a MlxLlmTranslationSimul instance rather than a plain MlxLlmTranslation, so per-session simul clients keep the simultaneous-MT state. Updated the factory test from identity (is) to type (isinstance).

Validation: 56/56 tests pass (35 simul + 21 base), uv lock --check passes, ruff check clean, pytest --collect-only works without MLX.

@clkao
clkao requested a review from QuentinFuxa August 29, 2026 17:55
@clkao

clkao commented Aug 29, 2026

Copy link
Copy Markdown
Author

btw @QuentinFuxa consider enabling stacked PR for the repo :)

clkao added 4 commits August 30, 2026 16:36
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.
@clkao
clkao force-pushed the spacedock-ensign/mlx-llm-mt-simultaneous branch from eef74fa to 3cb8c85 Compare August 30, 2026 23:37
@clkao
clkao marked this pull request as ready for review August 30, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

2 participants