# `ALLM.Providers.FakeTranscription`
[🔗](https://github.com/cykod/ALLM/blob/v0.6.1/lib/allm/providers/fake_transcription.ex#L1)

Deterministic, scripted adapter for speech-to-text testing. Implements
`ALLM.TranscriptionAdapter` and `ALLM.TranscriptionStreamAdapter`.

Layer B — runtime. FakeTranscription is the canonical testing
transcription adapter; it ships in `lib/` (not `test/support/`) because
users need it for their own application tests, mirroring
`ALLM.Providers.FakeSpeech` and `ALLM.Providers.FakeModeration`.

## What FakeTranscription is (and isn't)

FakeTranscription never listens to the audio. It measures `:audio` to run
the resolvable and size gates every `ALLM.TranscriptionAdapter` must run,
and reads `:model` and `:metadata` to round-trip them onto the response.
It has no MIME gate: accepted MIME types are a real provider's concern.

## Size cap

`max_audio_bytes/0` returns `1024`, deliberately small so a test crosses
the boundary cheaply. The gate measures against
`adapter_opts[:max_audio_bytes] || max_audio_bytes()`. A real adapter that
hands a scripted call off to this Fake passes its own cap under that key,
so a scripted run with a real multi-kilobyte clip is not rejected by the
Fake's small default.

## Default transcript (no script)

With **no script** — `adapter_opts[:transcription_script]` absent or `[]`
— the response carries `text: ""` and `usage: %ALLM.Usage{}`.

## Spent script

A **non-empty** script whose cursor has run off the end does NOT fall back
to the default — it returns

    {:error, %ALLM.Error.TranscriptionAdapterError{
       reason: :unknown,
       metadata: %{cause: :transcription_script_exhausted}}}

## Script shapes

`opts[:adapter_opts][:transcription_script]` accepts a list of script
entries. See `script/1` for the full grammar.

    adapter_opts: [
      transcription_script: [
        {:ok, "The quick brown fox."},
        {:ok, %ALLM.TranscriptionResponse{...}},
        {:error, %ALLM.Error.TranscriptionAdapterError{reason: :rate_limited}},
        {:retry_until_call, 3}
      ]
    ]

`{:retry_until_call, n}` returns a synthetic
`%ALLM.Error.TranscriptionAdapterError{reason: :rate_limited, retry_after_ms: 0}`
for the first `n - 1` calls against this entry, then advances to the next
entry on call `n`. Consecutive entries of this shape chain. `n` must be a
positive integer: any other budget raises `ArgumentError` naming the entry
when a call reaches it, whether or not the script went through `script/1`.

| Entry | `transcribe/2` | `stream_transcribe/3` |
|-------|----------------|-----------------------|
| `{:ok, text}` | `text` as the transcript; with a span flag set, one `:word` span per word | after the input is consumed: one partial per cumulative word prefix, one committed segment, `:transcription_completed`; with a span flag set, both carry the word spans |
| `{:ok, %TranscriptionResponse{}}` | the struct, verbatim (span flags not applied) | the struct's fields as events; a non-`nil` `:spans` rides the committed and completed events |
| `{:error, err}` | `{:error, err}` | `:transcription_started`, then `{:error, err}` |
| `{:events, events}` | `:unknown` with `metadata.cause: :stream_only_script_entry` | `events`, verbatim |
| `{:retry_until_call, n}` | `:rate_limited` error | a stream whose only event is a `:rate_limited` error |
| absent or `[]` script | `text: ""` (`spans: []` with a span flag set) | `:transcription_completed` with `text: ""` (and `spans: []` with a span flag set) |

## Span flags

With `timestamps: true` or `logprobs: true` on the request, a text entry
(and the no-script default) is split on whitespace into one `:word`
`ALLM.TranscriptSpan` per word: word *i* spans `i * 0.5` to
`(i + 1) * 0.5` seconds with logprob `-0.1`. Times are kept only under
`timestamps: true` and the logprob only under `logprobs: true`; a flag
counts only when it is exactly `true`. Blank text gives `spans: []`; with
both flags off `spans` is `nil` and no stream event carries a `:spans`
key.

    iex> req = ALLM.TranscriptionRequest.new(
    ...>   audio: ALLM.Audio.from_binary(<<0>>, "audio/mpeg"), logprobs: true)
    iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi there"}]]]
    iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
    iex> for s <- resp.spans, do: {s.text, s.start_seconds, s.logprob}
    [{"hi", nil, -0.1}, {"there", nil, -0.1}]

On the stream path the one committed segment carries the same spans and
`:transcription_completed` carries them under `:spans` (`[]` when nothing
was committed). A scripted `%TranscriptionResponse{}` is the escape
hatch: its own `:spans` is returned (or streamed) as scripted, whatever
the flags say.

`adapter_opts[:span_flags]`, when present, is the list of flags the
caller's adapter supports. A set flag outside it is refused with
`{:error, %ALLM.Error.TranscriptionAdapterError{reason: :unsupported_feature,
metadata: %{field: flag}}}` after the audio (or sample-rate) gate and
before the script is consulted, so the refusal does not advance the
cursor; `:timestamps` is checked before `:logprobs`. The error's
`:provider` is `nil`. Without the key both flags are supported.

    iex> req = ALLM.TranscriptionRequest.new(
    ...>   audio: ALLM.Audio.from_binary(<<0>>, "audio/mpeg"), timestamps: true)
    iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi"}], span_flags: [:logprobs]]]
    iex> {:error, err} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
    iex> {err.reason, err.metadata.field}
    {:unsupported_feature, :timestamps}

## Streaming

`stream_transcribe/3` resolves its script entry and advances the cursor
**at call time**, before it returns `{:ok, stream}`, so two calls consume
two entries even if neither stream is reduced. Streams are never retried,
so on the stream path `{:retry_until_call, n}` returns a stream holding
only the rate-limit error while the entry's visit counter (shared with
`transcribe/2`) is below `n`. The input is reduced through
`ALLM.Providers.Support.InputPump`, in another process, as a real adapter
reduces it. `stream_sample_rates/0` is `[8_000, 16_000, 24_000]`.

## Cursor behaviour

Multi-call scripts advance a per-process cursor on every call. The cursor
lives in the process dictionary at
`{:allm_fake_transcription_cursor, key_id}`, isolated per ExUnit test
process (`async: true`), GC'd on pid-down, zero-setup for the common case.
The `key_id` is chosen by this precedence:

  1. `adapter_opts[:script_cursor]` — an explicit Agent pid (handled
     separately; see `start_script_cursor/0`).
  2. `adapter_opts[:cursor_key]` — the engine's stable `:id`, injected by
     the façade dispatch chokepoint via `ALLM.Engine.put_cursor_key/2`.
  3. `:erlang.phash2(script)` — the content-hash fallback for direct
     adapter calls with no engine.

At the façade the cursor keys on engine identity, so two engines built with
content-equal `:transcription_script` values each read index 0 on their
first call, even in the same process. **The content-hash footgun remains
only for DIRECT adapter calls** —
`ALLM.Providers.FakeTranscription.transcribe(req, opts)` invoked without an
engine receives no `:cursor_key`. Workaround for that path: pass distinct
`adapter_opts[:script_cursor]` Agent pids from `start_script_cursor/0`. The
`{:retry_until_call, n}` visit counter keys on the same identity, so
content-equal engines never share a retry budget.

## Test-only capture seam

Pass `adapter_opts[:capture_pid]` with a pid to receive a side-channel
message every time `transcribe/2` or `stream_transcribe/3` is invoked,
BEFORE any gate runs and before the script is consulted:

    {ALLM.Providers.FakeTranscription, :call, %{request: request, opts: opts}}

## Examples

    iex> audio = ALLM.Audio.from_binary(<<0, 1, 2>>, "audio/mpeg")
    iex> req = ALLM.TranscriptionRequest.new(audio: audio)
    iex> opts = [adapter_opts: [transcription_script: [{:ok, "Hello."}]]]
    iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
    iex> resp.text
    "Hello."

# `script_entry`

```elixir
@type script_entry() ::
  {:ok, String.t()}
  | {:ok, ALLM.TranscriptionResponse.t()}
  | {:error, ALLM.Error.TranscriptionAdapterError.t()}
  | {:events, [ALLM.TranscriptionEvent.t()]}
  | {:retry_until_call, pos_integer()}
```

One scripted transcription result.

# `cursor_index`

```elixir
@spec cursor_index(pid()) :: non_neg_integer()
```

Read the current cursor index for an Agent-backed cursor.

## Examples

    iex> pid = ALLM.Providers.FakeTranscription.start_script_cursor()
    iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_binary("x", "audio/mpeg"))
    iex> opts = [adapter_opts: [transcription_script: [{:ok, "a"}], script_cursor: pid]]
    iex> {:ok, _} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
    iex> ALLM.Providers.FakeTranscription.cursor_index(pid)
    1

# `max_audio_bytes`

```elixir
@spec max_audio_bytes() :: pos_integer()
```

Return the largest audio payload FakeTranscription accepts, in bytes.

Deliberately **not** a provider-shaped number: 1024 bytes lets a test cross
the size boundary without allocating megabytes. Override per call with
`adapter_opts[:max_audio_bytes]`.

## Examples

    iex> ALLM.Providers.FakeTranscription.max_audio_bytes()
    1024

# `script`

```elixir
@spec script([script_entry()]) :: :ok
```

Document and validate the script grammar for
`adapter_opts[:transcription_script]`.

Each entry is one of:

  * `{:ok, text}` — return `text` as the transcript, with
    `usage: %ALLM.Usage{}`.
  * `{:ok, %ALLM.TranscriptionResponse{}}` — return the struct verbatim.
  * `{:error, %ALLM.Error.TranscriptionAdapterError{}}` — return the struct
    verbatim.
  * `{:events, events}` — for `stream_transcribe/3`, emit `events`
    verbatim. `transcribe/2` answers it with `:unknown` and
    `metadata.cause: :stream_only_script_entry`.
  * `{:retry_until_call, n}` — synthetic `:rate_limited` for the first
    `n - 1` calls against this entry. Consecutive entries of this shape
    chain into a layered budget.

Returns `:ok` when the script is well-formed; raises `ArgumentError` on the
first invalid entry. Validation is opt-in: `transcribe/2` does not call it,
though a malformed `{:retry_until_call, n}` still raises when a call
reaches it.

## Examples

    iex> ALLM.Providers.FakeTranscription.script([{:ok, "text"}, {:retry_until_call, 2}])
    :ok

# `start_script_cursor`

```elixir
@spec start_script_cursor() :: pid()
```

Start an Agent-backed script cursor for cross-process multi-call scripting
and for disambiguating content-equal scripts in the same process.

Pass the returned pid as `adapter_opts[:script_cursor]`.

## Examples

    iex> pid = ALLM.Providers.FakeTranscription.start_script_cursor()
    iex> ALLM.Providers.FakeTranscription.cursor_index(pid)
    0

# `stream_sample_rates`

```elixir
@spec stream_sample_rates() :: [pos_integer()]
```

Return the PCM sample rates `stream_transcribe/3` accepts:
`[8000, 16000, 24000]`. Override per call with
`adapter_opts[:stream_sample_rates]`.

## Examples

    iex> ALLM.Providers.FakeTranscription.stream_sample_rates()
    [8000, 16000, 24000]

# `stream_transcribe`

```elixir
@spec stream_transcribe(
  ALLM.TranscriptionStreamRequest.t(),
  Enumerable.t(binary() | :commit),
  keyword()
) ::
  {:ok, Enumerable.t(ALLM.TranscriptionEvent.t())}
  | {:error, ALLM.Error.TranscriptionAdapterError.t()}
```

Stream a scripted realtime transcription as `ALLM.TranscriptionEvent`
values.

Gate, before the script is consulted: `request.sample_rate` must be in
`adapter_opts[:stream_sample_rates] || stream_sample_rates()`, else a
synchronous `{:error, %TranscriptionAdapterError{reason: :invalid_request}}`
with `metadata.sample_rate`. Then, when `adapter_opts[:span_flags]` is
present, the span-flag gate `transcribe/2` runs (`:unsupported_feature`,
also synchronous).

The script entry is resolved and the cursor advanced **at call time**,
before the stream is returned. The input is reduced lazily, when the
stream is, through `ALLM.Providers.Support.InputPump`, and every element
is checked as a real adapter checks it: a binary or `:commit`, else
`metadata.cause: :invalid_input_chunk`. An input whose total length is odd
ends the stream with `:invalid_input_chunk` at end of input; an odd-length
chunk followed by one that completes the sample is not an error. An input
that raises ends it with `:input_raised`, one whose pump dies from an exit
signal with `:input_crashed`, each with a `%{kind, message}` map
(an atom and a string) on the error's `:cause`. `opts[:stream_timeout]` (default 60,000 ms)
bounds the silence between input chunks.

Once the input is exhausted:

  * `{:ok, text}` — one `:partial_transcript` per cumulative word prefix
    of `text`, one `:committed_transcript` of `text`, then
    `:transcription_completed`. A `text` with no words emits no segment.
  * absent script — `:transcription_completed` with `text: ""`.

`:transcription_completed` carries the committed text trimmed (the
normative join), `duration_seconds` computed from the bytes consumed as
`bytes / (sample_rate * 2)`, `opts[:request_id]` and `request.metadata`.

`{:error, err}`, `{:events, events}`, `{:ok, %TranscriptionResponse{}}` and
`{:retry_until_call, n}` behave as for `ALLM.Providers.FakeSpeech`'s
stream callbacks; the input is not reduced.

## Examples

    iex> req = ALLM.TranscriptionStreamRequest.new(sample_rate: 16_000)
    iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi there"}]]]
    iex> {:ok, events} = ALLM.Providers.FakeTranscription.stream_transcribe(req, [<<0::size(256)-unit(8)>>], opts)
    iex> for {:partial_transcript, %{text: t}} <- events, do: t
    ["hi", "hi there"]

# `transcribe`

```elixir
@spec transcribe(
  ALLM.TranscriptionRequest.t(),
  keyword()
) ::
  {:ok, ALLM.TranscriptionResponse.t()}
  | {:error, ALLM.Error.TranscriptionAdapterError.t()}
```

Execute a scripted transcription request.

Gate order, all before the script is consulted:

  1. `adapter_opts[:capture_pid]` side-channel (fires even for rejected
     calls).
  2. **Resolvable** — `ALLM.Audio.size/1` on `request.audio`. A missing
     file, invalid base64, or a value that is not an `%ALLM.Audio{}` →
     `{:error, %TranscriptionAdapterError{reason: :invalid_request}}` with
     `metadata.cause` (`:enoent`, `:eisdir`, `:invalid_base64`,
     `:invalid_source`, …).
  3. **Size** — more than `adapter_opts[:max_audio_bytes] || max_audio_bytes()`
     bytes → `:invalid_request` with `metadata.count` and `metadata.max`.
  4. **Span flags** — only when `adapter_opts[:span_flags]` is present: a
     `timestamps: true` or `logprobs: true` outside that list →
     `:unsupported_feature` with `metadata.field` (see "Span flags" in
     the module docs).

Otherwise reads the script from
`opts[:adapter_opts][:transcription_script]`, advances the process-local
cursor, and interprets the entry. An **absent** script produces the
default empty transcript; a **spent non-empty** script returns
`:transcription_script_exhausted`.

Propagates `opts[:request_id]`, `request.model` and `request.metadata`
onto the response and stamps `provider: :fake`.
`{:ok, %TranscriptionResponse{}}` script entries are returned verbatim.

## Examples

    iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_binary("abc", "audio/mpeg"))
    iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, request_id: "rid-1")
    iex> {resp.text, resp.request_id, resp.usage}
    {"", "rid-1", %ALLM.Usage{}}

    iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_file("/nonexistent.mp3"))
    iex> {:error, err} = ALLM.Providers.FakeTranscription.transcribe(req, [])
    iex> {err.reason, err.metadata.cause}
    {:invalid_request, :enoent}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
