Skip to content

refactor: reshape speech recognizer into transcriber engine with injected components#110

Merged
gabewillen merged 5 commits into
mainfrom
cleanup/speech-transcriber
Jul 11, 2026
Merged

refactor: reshape speech recognizer into transcriber engine with injected components#110
gabewillen merged 5 commits into
mainfrom
cleanup/speech-transcriber

Conversation

@gabewillen

@gabewillen gabewillen commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Renames speech/recognizer to speech/transcriber (Whisper is STT — it transcribes; "detector" stays reserved for a future VAD-class component) and replaces the recognizer_routes template-policy pattern with the text/generator shape: a non-templated engine that runs whatever components it is given.

Rename

  • src/emel/speech/recognizer{,_routes}src/emel/speech/transcriber (routes dissolved, see below), tests/speech/recognizertests/speech/transcriber
  • All identifiers, aliases (Transcriber, SpeechTranscriber), bench backend id (emel.speech.transcriber.whisper), runtime-surface strings, CMake, and domain-boundary checks updated

Shape change (components as injected contracts)

  • speech/transcriber is now a plain sm taking a dependencies struct — pure data: component kinds, encoder/decoder execution contracts, ASR decode policy, pinned tokenizer sha. It owns speech/encoder / speech/decoder / speech/tokenizer component facades and drives them via events. The engine contains no model-family names, contracts, or constants; the domain-boundary script enforces this (no whisper token allowed in transcriber source).
  • Component facades (any.hpp with a kind enum over emel::sm_any, mirroring text/tokenizer/preprocessor) added for speech encoder/decoder/tokenizer; execution contracts and asr_decode_policy promoted to component level, whisper variants alias them and keep binders (bind_execution_contract) at the variant boundary.
  • New speech/tokenizer/whisper detokenize machine — detokenization was a route effect calling detail functions; it is now a component actor dispatch with explicit validation and callback-decision states (truncate-to-caller-capacity contract preserved).
  • Route pre-flight sizing guards deleted: the component machines already validate their own workspace/capacity in their transition graphs, and the transcriber routes their failures through explicit decision states (including a new detokenize-failure path).
  • Transcriber lifecycle tests rewritten against the dependencies API (tests use only component-level kind selectors and contract structs — never variant headers).

Ride-along fixes (needed for gates to pass on main)

  • tools/paritychecker/parity_engines.cpp: restore missing parallel_matmul_lanes member in generation_load_state — main does not compile the paritychecker without it (same fix already exists on feature/moshi-personaplex-e2e)
  • tests/diarization/sortformer/pipeline/lifecycle_tests.cpp: stale model::sortformer::detail:: namespace reference
  • docsgen: -fbracket-depth=1024 (introspection tool only) for deep transition-table instantiation
  • clang-format for files that had drifted; snapshots/lint/clang_format.txt strictly shrank (grandfathered entries removed, none added)
  • .planning/architecture machine docs regenerated (recognizer docs renamed; new transcriber + whisper tokenizer docs)

Testing

  • EMEL_QUALITY_GATES_CHANGED_FILES=<diff> EMEL_QUALITY_GATES_BENCH_SUITE=whisper_compare scripts/quality_gates.sh — exit 0 on this branch: 100% tests (incl. real-model whisper transcriber integration test), whisper.cpp transcript parity exact-match, paritychecker full run, coverage, determinism, lint, docs, domain boundaries
  • Known pre-existing and unrelated: the whisper_single_thread performance contract fails at origin/main-era baselines (EMEL ~17% slower than whisper.cpp; reproduced at clean checkout with no local changes) — tracked separately for bisecting

Notes for review

  • The same work exists rebased on feature/moshi-personaplex-e2e (branch cleanup/recognizer-route-smell) for later reconciliation with the Moshi/PersonaPlex work.

Note

Medium Risk
Large architectural refactor of the speech STT path and initialization/recognize state machines; behavior should be equivalent but wiring and validation timing changed (tokenizer validate on init, detokenize failure path).

Overview
Renames speech/recognizer and recognizer_routes to speech/transcriber, drops the route template layer, and wires STT as a plain state machine that owns injected speech::encoder / decoder / tokenizer facades (any.hpp + kind enums, mirroring text generator dependencies).

Component layer: shared execution_contract, asr_decode_policy, and encode/decode/detokenize/validate events live at component scope; Whisper variants alias them and keep binders at the variant edge. speech/tokenizer/whisper gains a full detokenize/validate SM (replacing route effects that called detail directly). emel::sm_any now preserves requested kind for unsupported sentinels and exposes is_supported_kind.

Transcriber pipeline dispatches encode → decode → detokenize through injected actors; initialize runs tokenizer asset validation before model support checks. Domain-boundary and CMake/tests/bench ids move from recognizer to transcriber.

Docs/tooling: new/updated .planning/architecture for transcriber and whisper tokenizer; text generator initializer diagram guards tweaked; .gitignore for a.out/*.out; clang-format snapshot shrink; assorted formatting and ride-along fixes called out in the PR description.

Reviewed by Cursor Bugbot for commit 26c64a7. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI review requested due to automatic review settings July 10, 2026 23:45
@cursor

cursor Bot commented Jul 10, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_db41f6b2-8114-4737-aab3-c1f474b1ba05)

…cted components

- rename speech/recognizer -> speech/transcriber (STT; 'detector' stays
  reserved for future VAD-class components) across code, tests, tools,
  bench ids, and domain-boundary checks
- dissolve speech/recognizer_routes: the transcriber is now a
  non-templated engine taking a dependencies struct (component kinds,
  encoder/decoder execution contracts, decode policy, pinned tokenizer
  sha) and owning speech/encoder, speech/decoder, speech/tokenizer
  component facades (sm_any + kind enum), mirroring text/generator
- promote execution contracts and ASR decode policy to component level;
  whisper variants alias them and keep binders at the variant boundary
- add speech/tokenizer/whisper detokenize machine (route effect ->
  component actor dispatch, truncate-to-capacity contract preserved)
- rewrite transcriber lifecycle tests against the dependencies API
- ride-along fixes surfaced by gate scoping: stale sortformer test
  namespace, docsgen bracket-depth overflow on deep transition tables,
  clang-format drift (lint baseline strictly shrank, 11 entries removed)
@cursor

cursor Bot commented Jul 10, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_287e4c08-3d49-4901-8cfc-5a829eb362e0)

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

Address PR review findings on the transcriber reshape:

- Reject unknown component kinds before dispatch. The transcriber support
  guards only excluded the `unsupported` sentinel, but sm_any clamps any
  out-of-range enum value to the first variant, so a deserialized/cast kind
  with otherwise valid contracts would silently run the default facade.
  Add a variant-neutral `sm_any::is_supported_kind` predicate, expose it as
  `is_supported` on each speech component facade, and gate the transcriber
  guards on it so the engine stays model-family-neutral while rejecting
  out-of-range kinds.

- Validate detokenize spans before decoding. The whisper tokenizer
  `guard_tokenizer_json_valid` accepted on JSON alone, so a direct
  component dispatch (bypassing the transcriber's recognize validation)
  with a non-empty token or transcript span whose data pointer is null
  would reach UB in the decode action. Extend the guard to require
  well-formed spans so malformed requests take the error path.

- Remove the stray zero-byte a.out build artifact and ignore a.out/*.out.

Add guard-level tests covering out-of-range component kinds and
null-data detokenize spans.
@cursor

cursor Bot commented Jul 11, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_b15ebc03-e547-484a-848b-66d2bd81dc49)

chatgpt-codex-connector[bot]

This comment was marked as resolved.

Address second-round PR review findings:

- Preserve the requested kind in sm_any. Constructing or set_kind-ing a
  component facade with `unsupported` or an out-of-range cast previously
  clamped kind() to the first variant, so the facade reported `whisper`
  and a direct dispatch silently ran the default machine with no way for
  callers to detect the "no component injected" state. sm_any now keeps
  the requested kind (the active machine still clamps for safe
  construction), so is_supported_kind(kind()) stays truthful and owners
  can reject dispatch to an unsupported facade. Facade comments document
  the owner-guard contract.

- Clear transcript_size_out in the whisper detokenize begin action,
  mirroring the encoder/decoder begin actions, so rejected requests
  (invalid tokenizer JSON or malformed spans) never leave a caller
  reading a stale positive transcript size from a previous dispatch.

Add sm_any kind-preservation tests, a tokenizer facade support test, and
a machine-level test proving a rejected detokenize clears the caller's
out-parameter.
@cursor

cursor Bot commented Jul 11, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_f0114db3-798f-4f82-963a-f010161caa03)

Address the P1 Codex review finding: recognize dispatched from
state_uninitialized or state_errored was marked uninitialized but routed
through the shared recognize error chain, whose terminals always land in
state_ready. A failed pre-initialize recognize therefore promoted the
machine to ready, and the next recognize could pass
guard_transcriber_ready and run the encoder/decoder/tokenizer pipeline
without any successful initialize.

Give each pre-initialize origin its own explicit rejection chain
(uninitialized/errored error-out and error-callback decision states)
whose terminals return to the origin state, reusing the existing
error-out/callback guards and effects. The shared recognize error chain
is now reachable only from ready-origin dispatches and correctly returns
to ready. Origin awareness is modeled as distinct states per the actor
rules, not context flags.

Also address two unthreaded Copilot review findings on the same surface:

- guard_model_contracts_supported now rejects injected contracts whose
  encoder and decoder embedding lengths disagree, since the decode phase
  slices the shared encoder-state buffer as frame_count x decoder
  embedding_length.
- guard_encoder_success now requires that slice to fit the caller's
  encoder_state span (and a non-negative frame count), so effect_decode
  can never hand the decoder a span that reads past the caller's buffer;
  oversized outcomes route to the backend-error path.

Add lifecycle tests proving pre-init and post-error recognizes reject
and stay in their origin states with the pipeline unreachable, plus
guard tests for the embedding mismatch and capacity bound.
@cursor

cursor Bot commented Jul 11, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_be4c5190-4620-404c-a09e-3c2389085832)

@gabewillen

Copy link
Copy Markdown
Contributor Author

Addressed three review-body findings that had no inline threads, all fixed in 10d00c8:

Codex P1 — "Keep failed pre-init recognizes from entering ready" (first Codex review, src/emel/speech/transcriber/sm.hpp): recognize from state_uninitialized/state_errored routed through the shared recognize error chain, whose terminals always land in state_ready, so a failed pre-initialize recognize promoted the machine to ready and the next recognize could pass guard_transcriber_ready and run the pipeline with no successful initialize. Each pre-init origin now has its own explicit rejection chain (state_recognize_uninitialized_* / state_recognize_errored_* error-out and callback decisions, reusing the existing guards/effects) whose terminals return to the origin state; the shared chain is reachable only from ready-origin dispatches. Lifecycle tests assert via is() that pre-init and post-error recognizes reject with error::uninitialized and remain in their origin state, that repeated recognizes keep rejecting (pipeline unreachable without a successful initialize), and that ready-origin rejections still return to ready.

Copilot (suppressed) — effect_decode encoder-state span over-claim (src/emel/speech/transcriber/actions.hpp): the decode phase slices the caller's encoder-state buffer as frame_count x decoder embedding_length without checking the span's real size. guard_encoder_success now additionally requires a non-negative frame count and that the slice fits storage.encoder_state, so oversized encode outcomes route to the backend-error path instead of handing the decoder an out-of-bounds span. Covered by a new guard test.

Copilot (suppressed) — missing encoder/decoder embedding-length cross-check (src/emel/speech/transcriber/guards.hpp): guard_model_contracts_supported now rejects injected dependencies where encoder_contract.embedding_length != decoder_contract.embedding_length, since the pipeline assumes a shared width when slicing encoder output for the decoder. Covered by a new guard test; the real whisper-tiny path (384/384) is unaffected and the full whisper integration/parity/bench lanes stay green.

Scoped quality gates (whisper_compare bench suite) exit 0.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

Address three Codex P2 review findings:

- Validate tokenizer JSON and decode policy before initialize succeeds.
  Add a component-level speech::tokenizer::event::validate asset-validation
  event and a validate flow in the whisper tokenizer machine (control-token
  JSON validation plus the same decode-policy support predicate the decoder
  uses). The transcriber's initialize flow now dispatches this event into the
  injected tokenizer actor from a new explicit
  state_tokenizer_validation_decision phase, so malformed tokenizer JSON or a
  default/unsupported decode policy fails initialization with
  tokenizer_invalid instead of running encode/decode work on the first
  recognize and surfacing a generic backend error. The transcriber stays
  model-family-neutral: validation semantics live in the tokenizer variant
  and are reached only through the injected component facade.

- Reject negative token IDs before detokenizing. Direct detokenize requests
  containing negative IDs (including INT32_MIN) are rejected in the request
  guard with a distinct token_ids_invalid error before the decode action
  runs, and write_i32 now computes the digit magnitude in unsigned
  arithmetic so the minimum int32 renders without signed-negation overflow.

Update the transcriber lifecycle fixtures to carry real whisper-tiny
tokenizer JSON and the pinned decode policy as component-level pure data,
and add tests for fail-fast initialize (malformed JSON, unsupported
policy), negative-token-ID rejection, INT32_MIN rendering, and the
validate flow through both the machine and the facade.
@cursor

cursor Bot commented Jul 11, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_f996a73d-99ea-46f7-83eb-465284b02c4f)

@gabewillen gabewillen merged commit de17f92 into main Jul 11, 2026
1 check passed
@gabewillen gabewillen deleted the cleanup/speech-transcriber branch July 11, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants