     1→from __future__ import annotations
     2→
     3→import gc
     4→from collections import OrderedDict
     5→from dataclasses import dataclass
     6→from pathlib import Path
     7→from typing import Any
     8→
     9→from maya_transcribe.language import to_whisper_lang
    10→
    11→
    12→@dataclass(frozen=True)
    13→class ASRSegment:
    14→    start: float
    15→    end: float
    16→    text: str
    17→    avg_logprob: float | None = None
    18→    no_speech_prob: float | None = None
    19→    compression_ratio: float | None = None
    20→
    21→
    22→@dataclass(frozen=True)
    23→class ASRResult:
    24→    model: str
    25→    text: str
    26→    language: str | None
    27→    language_probability: float | None
    28→    segments: list[ASRSegment]
    29→    meta: dict[str, Any]
    30→
    31→
    32→class FasterWhisperASR:
    33→    def __init__(
    34→        self,
    35→        model_size_or_path: str = "large-v3",
    36→        device: str | None = None,
    37→        compute_type: str | None = None,
    38→    ) -> None:
    39→        try:
    40→            from faster_whisper import WhisperModel
    41→        except Exception as e:  # pragma: no cover
    42→            raise RuntimeError(
    43→                "faster-whisper is not installed. Install with: pip install -e '.[asr]'"
    44→            ) from e
    45→
    46→        if device is None:
    47→            try:
    48→                import torch
    49→
    50→                device = "cuda" if torch.cuda.is_available() else "cpu"
    51→            except Exception:
    52→                device = "cpu"
    53→
    54→        if compute_type is None:
    55→            compute_type = "float16" if device == "cuda" else "float32"
    56→
    57→        self.model_size_or_path = model_size_or_path
    58→        self.device = device
    59→        self.compute_type = compute_type
    60→        self._model = WhisperModel(model_size_or_path, device=device, compute_type=compute_type)
    61→
    62→    @property
    63→    def name(self) -> str:
    64→        return f"faster-whisper:{self.model_size_or_path}"
    65→
    66→    def transcribe(
    67→        self,
    68→        audio_path: Path,
    69→        language: str | None = None,
    70→        beam_size: int = 5,
    71→        best_of: int = 5,
    72→        temperature: float = 0.0,
    73→        vad_filter: bool = False,
    74→    ) -> ASRResult:
    75→        lang = to_whisper_lang(language)
    76→        # faster-whisper's Assamese token ("as") tends to produce Devanagari output,
    77→        # while the Bengali token ("bn") yields Bengali/Assamese-script output on FLEURS.
    78→        if lang == "as":
    79→            lang = "bn"
    80→        if lang is not None:
    81→            # faster-whisper supports a subset of Whisper's language tokens.
    82→            # Avoid hard failures (e.g., Odia "or") by falling back to auto-detect.
    83→            try:
    84→                from faster_whisper.tokenizer import _LANGUAGE_CODES  # type: ignore
    85→
    86→                if lang not in _LANGUAGE_CODES:
    87→                    lang = None
    88→            except Exception:
    89→                pass
    90→
    91→        segments_iter, info = self._model.transcribe(
    92→            str(audio_path),
    93→            language=lang,
    94→            beam_size=beam_size,
    95→            best_of=best_of,
    96→            temperature=temperature,
    97→            vad_filter=vad_filter,
    98→        )
    99→
   100→        segments: list[ASRSegment] = []
   101→        texts: list[str] = []
   102→        for s in segments_iter:
   103→            texts.append(s.text)
   104→            segments.append(
   105→                ASRSegment(
   106→                    start=float(s.start),
   107→                    end=float(s.end),
   108→                    text=str(s.text),
   109→                    avg_logprob=getattr(s, "avg_logprob", None),
   110→                    no_speech_prob=getattr(s, "no_speech_prob", None),
   111→                    compression_ratio=getattr(s, "compression_ratio", None),
   112→                )
   113→            )
   114→
   115→        return ASRResult(
   116→            model=self.name,
   117→            text="".join(texts).strip(),
   118→            language=getattr(info, "language", None),
   119→            language_probability=getattr(info, "language_probability", None),
   120→            segments=segments,
   121→            meta={
   122→                "device": self.device,
   123→                "compute_type": self.compute_type,
   124→                "beam_size": beam_size,
   125→                "best_of": best_of,
   126→                "temperature": temperature,
   127→                "vad_filter": vad_filter,
   128→            },
   129→        )
   130→
   131→
   132→class TransformersWhisperASR:
   133→    """
   134→    Whisper-family ASR using HuggingFace Transformers (useful for fine-tuned checkpoints like IndicWhisper).
   135→    """
   136→
   137→    def __init__(
   138→        self,
   139→        model_id: str,
   140→        device: str | None = None,
   141→        dtype: str | None = None,
   142→    ) -> None:
   143→        try:
   144→            import torch
   145→            from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
   146→        except Exception as e:  # pragma: no cover
   147→            raise RuntimeError(
   148→                "TransformersWhisperASR requires the optional dependencies: torch + transformers. "
   149→                "Install with: pip install -e '.[torch]'"
   150→            ) from e
   151→
   152→        if device is None:
   153→            device = "cuda" if torch.cuda.is_available() else "cpu"
   154→        if dtype is None:
   155→            dtype = "float16" if (device == "cuda") else "float32"
   156→
   157→        torch_dtype = torch.float16 if dtype == "float16" else torch.float32
   158→
   159→        self.model_id = model_id
   160→        self.device = device
   161→        self.dtype = dtype
   162→        self._torch = torch
   163→        self._processor = AutoProcessor.from_pretrained(model_id)
   164→        self._model = AutoModelForSpeechSeq2Seq.from_pretrained(model_id, torch_dtype=torch_dtype).to(device)
   165→        self._model.eval()
   166→
   167→    @property
   168→    def name(self) -> str:
   169→        return f"transformers:{self.model_id}"
   170→
   171→    def close(self) -> None:
   172→        """
   173→        Best-effort release of GPU memory for large checkpoints.
   174→        """
   175→        model = getattr(self, "_model", None)
   176→        if model is not None:
   177→            try:
   178→                del self._model
   179→            except Exception:
   180→                pass
   181→        proc = getattr(self, "_processor", None)
   182→        if proc is not None:
   183→            try:
   184→                del self._processor
   185→            except Exception:
   186→                pass
   187→
   188→        try:
   189→            gc.collect()
   190→        except Exception:
   191→            pass
   192→
   193→        try:
   194→            if self.device == "cuda" and hasattr(self, "_torch"):
   195→                self._torch.cuda.empty_cache()
   196→        except Exception:
   197→            pass
   198→
   199→    def transcribe(
   200→        self,
   201→        audio_path: Path,
   202→        language: str | None = None,
   203→        max_new_tokens: int = 256,
   204→        num_beams: int = 5,
   205→    ) -> ASRResult:
   206→        from maya_transcribe.audio import decode_pcm16
   207→
   208→        wav, sr = decode_pcm16(audio_path, sample_rate=16000, mono=True)
   209→        if wav.size == 0:
   210→            return ASRResult(
   211→                model=self.name,
   212→                text="",
   213→                language=language,
   214→                language_probability=None,
   215→                segments=[],
   216→                meta={"max_new_tokens": max_new_tokens, "num_beams": num_beams},
   217→            )
   218→
   219→        inputs = self._processor(wav, sampling_rate=sr, return_tensors="pt")
   220→        input_features = inputs.get("input_features")
   221→        if input_features is None:
   222→            raise RuntimeError("Processor did not return input_features")
   223→        input_features = input_features.to(self.device)
   224→        model_dtype = getattr(self._model, "dtype", None)
   225→        if model_dtype is not None:
   226→            input_features = input_features.to(dtype=model_dtype)
   227→
   228→        forced_decoder_ids = None
   229→        whisper_lang = to_whisper_lang(language)
   230→        if whisper_lang:
   231→            get_ids = getattr(self._processor, "get_decoder_prompt_ids", None)
   232→            if callable(get_ids):
   233→                try:
   234→                    forced_decoder_ids = get_ids(language=whisper_lang, task="transcribe")
   235→                except (ValueError, KeyError):
   236→                    forced_decoder_ids = None
   237→
   238→        with self._torch.no_grad():
   239→            gen_kwargs = {
   240→                "max_new_tokens": max_new_tokens,
   241→                "num_beams": num_beams,
   242→            }
   243→            if forced_decoder_ids is not None:
   244→                gen_kwargs["forced_decoder_ids"] = forced_decoder_ids
   245→            try:
   246→                generated_ids = self._model.generate(input_features, **gen_kwargs)
   247→            except ValueError as e:
   248→                # Some fine-tuned checkpoints / transformer versions reject `forced_decoder_ids`.
   249→                msg = str(e)
   250→                if "forced_decoder_ids" in msg and "not used by the model" in msg:
   251→                    # Whisper's newer generate() prefers forced ids via generation_config.
   252→                    gen_kwargs.pop("forced_decoder_ids", None)
   253→                    old_forced = None
   254→                    restore_forced = False
   255→                    if forced_decoder_ids is not None:
   256→                        gen_cfg = getattr(self._model, "generation_config", None)
   257→                        if gen_cfg is not None:
   258→                            old_forced = getattr(gen_cfg, "forced_decoder_ids", None)
   259→                            gen_cfg.forced_decoder_ids = forced_decoder_ids
   260→                            restore_forced = True
   261→                    try:
   262→                        generated_ids = self._model.generate(input_features, **gen_kwargs)
   263→                    finally:
   264→                        if restore_forced:
   265→                            gen_cfg = getattr(self._model, "generation_config", None)
   266→                            if gen_cfg is not None:
   267→                                gen_cfg.forced_decoder_ids = old_forced
   268→                else:
   269→                    raise
   270→
   271→        text = self._processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
   272→        return ASRResult(
   273→            model=self.name,
   274→            text=text.strip(),
   275→            language=whisper_lang or language,
   276→            language_probability=None,
   277→            segments=[],
   278→            meta={
   279→                "device": self.device,
   280→                "dtype": self.dtype,
   281→                "max_new_tokens": max_new_tokens,
   282→                "num_beams": num_beams,
   283→            },
   284→        )
   285→
   286→
   287→class TransformersWhisperASRRouter:
   288→    """
   289→    Lazily load and cache Whisper-family HF checkpoints by language.
   290→    Useful for per-language fine-tuned models without running 10+ models per segment.
   291→    """
   292→
   293→    def __init__(
   294→        self,
   295→        *,
   296→        model_map: dict[str, str],
   297→        default_model: str | None = None,
   298→        device: str | None = None,
   299→        dtype: str | None = None,
   300→        max_loaded_models: int = 1,
   301→    ) -> None:
   302→        self.model_map = {str(k).strip().lower(): str(v).strip() for k, v in (model_map or {}).items() if str(v).strip()}
   303→        self.default_model = default_model.strip() if isinstance(default_model, str) and default_model.strip() else None
   304→        self.device = device
   305→        self.dtype = dtype
   306→        self.max_loaded_models = max(1, int(max_loaded_models))
   307→        self._cache: OrderedDict[str, TransformersWhisperASR] = OrderedDict()
   308→
   309→    def _get_model_id(self, language: str | None) -> str | None:
   310→        if language:
   311→            key = language.strip().lower()
   312→            if key in self.model_map:
   313→                return self.model_map[key]
   314→        return self.default_model
   315→
   316→    def transcribe(
   317→        self,
   318→        audio_path: Path,
   319→        *,
   320→        language: str | None = None,
   321→        max_new_tokens: int = 256,
   322→        num_beams: int = 5,
   323→    ) -> ASRResult | None:
   324→        model_id = self._get_model_id(language)
   325→        if not model_id:
   326→            return None
   327→
   328→        backend = self._cache.get(model_id)
   329→        if backend is None:
   330→            backend = TransformersWhisperASR(model_id=model_id, device=self.device, dtype=self.dtype)
   331→            self._cache[model_id] = backend
   332→        else:
   333→            # Refresh LRU ordering.
   334→            self._cache.move_to_end(model_id, last=True)
   335→
   336→        while len(self._cache) > self.max_loaded_models:
   337→            _, evicted = self._cache.popitem(last=False)
   338→            try:
   339→                evicted.close()
   340→            except Exception:
   341→                pass
   342→
   343→        return backend.transcribe(
   344→            audio_path=audio_path,
   345→            language=language,
   346→            max_new_tokens=max_new_tokens,
   347→            num_beams=num_beams,
   348→        )
   349→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

from __future__ import annotations

import gc
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from maya_transcribe.language import to_whisper_lang

@dataclass(frozen=True)
class ASRSegment:
    start: float
    end: float
    text: str
    avg_logprob: float | None = None
    no_speech_prob: float | None = None
    compression_ratio: float | None = None

@dataclass(frozen=True)
class ASRResult:
    model: str
    text: str
    language: str | None
    language_probability: float | None
    segments: list[ASRSegment]
    meta: dict[str, Any]

class FasterWhisperASR:
    def __init__(
        self,
        model_size_or_path: str = "large-v3",
        device: str | None = None,
        compute_type: str | None = None,
    ) -> None:
        try:
            from faster_whisper import WhisperModel
        except Exception as e:  # pragma: no cover
            raise RuntimeError(
                "faster-whisper is not installed. Install with: pip install -e '.[asr]'"
            ) from e

        if device is None:
            try:
                import torch

                device = "cuda" if torch.cuda.is_available() else "cpu"
            except Exception:
                device = "cpu"

        if compute_type is None:
            compute_type = "float16" if device == "cuda" else "float32"

        self.model_size_or_path = model_size_or_path
        self.device = device
        self.compute_type = compute_type
        self._model = WhisperModel(model_size_or_path, device=device, compute_type=compute_type)

    @property
    def name(self) -> str:
        return f"faster-whisper:{self.model_size_or_path}"

    def transcribe(
        self,
        audio_path: Path,
        language: str | None = None,
        beam_size: int = 5,
        best_of: int = 5,
        temperature: float = 0.0,
        vad_filter: bool = False,
    ) -> ASRResult:
        lang = to_whisper_lang(language)
        # faster-whisper's Assamese token ("as") tends to produce Devanagari output,
        # while the Bengali token ("bn") yields Bengali/Assamese-script output on FLEURS.
        if lang == "as":
            lang = "bn"
        if lang is not None:
            # faster-whisper supports a subset of Whisper's language tokens.
            # Avoid hard failures (e.g., Odia "or") by falling back to auto-detect.
            try:
                from faster_whisper.tokenizer import _LANGUAGE_CODES  # type: ignore

                if lang not in _LANGUAGE_CODES:
                    lang = None
            except Exception:
                pass

        segments_iter, info = self._model.transcribe(
            str(audio_path),
            language=lang,
            beam_size=beam_size,
            best_of=best_of,
            temperature=temperature,
            vad_filter=vad_filter,
        )

        segments: list[ASRSegment] = []
        texts: list[str] = []
        for s in segments_iter:
            texts.append(s.text)
            segments.append(
                ASRSegment(
                    start=float(s.start),
                    end=float(s.end),
                    text=str(s.text),
                    avg_logprob=getattr(s, "avg_logprob", None),
                    no_speech_prob=getattr(s, "no_speech_prob", None),
                    compression_ratio=getattr(s, "compression_ratio", None),
                )
            )

        return ASRResult(
            model=self.name,
            text="".join(texts).strip(),
            language=getattr(info, "language", None),
            language_probability=getattr(info, "language_probability", None),
            segments=segments,
            meta={
                "device": self.device,
                "compute_type": self.compute_type,
                "beam_size": beam_size,
                "best_of": best_of,
                "temperature": temperature,
                "vad_filter": vad_filter,
            },
        )

class TransformersWhisperASR:
    """
    Whisper-family ASR using HuggingFace Transformers (useful for fine-tuned checkpoints like IndicWhisper).
    """

    def __init__(
        self,
        model_id: str,
        device: str | None = None,
        dtype: str | None = None,
    ) -> None:
        try:
            import torch
            from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
        except Exception as e:  # pragma: no cover
            raise RuntimeError(
                "TransformersWhisperASR requires the optional dependencies: torch + transformers. "
                "Install with: pip install -e '.[torch]'"
            ) from e

        if device is None:
            device = "cuda" if torch.cuda.is_available() else "cpu"
        if dtype is None:
            dtype = "float16" if (device == "cuda") else "float32"

        torch_dtype = torch.float16 if dtype == "float16" else torch.float32

        self.model_id = model_id
        self.device = device
        self.dtype = dtype
        self._torch = torch
        self._processor = AutoProcessor.from_pretrained(model_id)
        self._model = AutoModelForSpeechSeq2Seq.from_pretrained(model_id, torch_dtype=torch_dtype).to(device)
        self._model.eval()

    @property
    def name(self) -> str:
        return f"transformers:{self.model_id}"

    def close(self) -> None:
        """
        Best-effort release of GPU memory for large checkpoints.
        """
        model = getattr(self, "_model", None)
        if model is not None:
            try:
                del self._model
            except Exception:
                pass
        proc = getattr(self, "_processor", None)
        if proc is not None:
            try:
                del self._processor
            except Exception:
                pass

        try:
            gc.collect()
        except Exception:
            pass

        try:
            if self.device == "cuda" and hasattr(self, "_torch"):
                self._torch.cuda.empty_cache()
        except Exception:
            pass

    def transcribe(
        self,
        audio_path: Path,
        language: str | None = None,
        max_new_tokens: int = 256,
        num_beams: int = 5,
    ) -> ASRResult:
        from maya_transcribe.audio import decode_pcm16

        wav, sr = decode_pcm16(audio_path, sample_rate=16000, mono=True)
        if wav.size == 0:
            return ASRResult(
                model=self.name,
                text="",
                language=language,
                language_probability=None,
                segments=[],
                meta={"max_new_tokens": max_new_tokens, "num_beams": num_beams},
            )

        inputs = self._processor(wav, sampling_rate=sr, return_tensors="pt")
        input_features = inputs.get("input_features")
        if input_features is None:
            raise RuntimeError("Processor did not return input_features")
        input_features = input_features.to(self.device)
        model_dtype = getattr(self._model, "dtype", None)
        if model_dtype is not None:
            input_features = input_features.to(dtype=model_dtype)

        forced_decoder_ids = None
        whisper_lang = to_whisper_lang(language)
        if whisper_lang:
            get_ids = getattr(self._processor, "get_decoder_prompt_ids", None)
            if callable(get_ids):
                try:
                    forced_decoder_ids = get_ids(language=whisper_lang, task="transcribe")
                except (ValueError, KeyError):
                    forced_decoder_ids = None

        with self._torch.no_grad():
            gen_kwargs = {
                "max_new_tokens": max_new_tokens,
                "num_beams": num_beams,
            }
            if forced_decoder_ids is not None:
                gen_kwargs["forced_decoder_ids"] = forced_decoder_ids
            try:
                generated_ids = self._model.generate(input_features, **gen_kwargs)
            except ValueError as e:
                # Some fine-tuned checkpoints / transformer versions reject `forced_decoder_ids`.
                msg = str(e)
                if "forced_decoder_ids" in msg and "not used by the model" in msg:
                    # Whisper's newer generate() prefers forced ids via generation_config.
                    gen_kwargs.pop("forced_decoder_ids", None)
                    old_forced = None
                    restore_forced = False
                    if forced_decoder_ids is not None:
                        gen_cfg = getattr(self._model, "generation_config", None)
                        if gen_cfg is not None:
                            old_forced = getattr(gen_cfg, "forced_decoder_ids", None)
                            gen_cfg.forced_decoder_ids = forced_decoder_ids
                            restore_forced = True
                    try:
                        generated_ids = self._model.generate(input_features, **gen_kwargs)
                    finally:
                        if restore_forced:
                            gen_cfg = getattr(self._model, "generation_config", None)
                            if gen_cfg is not None:
                                gen_cfg.forced_decoder_ids = old_forced
                else:
                    raise

        text = self._processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
        return ASRResult(
            model=self.name,
            text=text.strip(),
            language=whisper_lang or language,
            language_probability=None,
            segments=[],
            meta={
                "device": self.device,
                "dtype": self.dtype,
                "max_new_tokens": max_new_tokens,
                "num_beams": num_beams,
            },
        )

class TransformersWhisperASRRouter:
    """
    Lazily load and cache Whisper-family HF checkpoints by language.
    Useful for per-language fine-tuned models without running 10+ models per segment.
    """

    def __init__(
        self,
        *,
        model_map: dict[str, str],
        default_model: str | None = None,
        device: str | None = None,
        dtype: str | None = None,
        max_loaded_models: int = 1,
    ) -> None:
        self.model_map = {str(k).strip().lower(): str(v).strip() for k, v in (model_map or {}).items() if str(v).strip()}
        self.default_model = default_model.strip() if isinstance(default_model, str) and default_model.strip() else None
        self.device = device
        self.dtype = dtype
        self.max_loaded_models = max(1, int(max_loaded_models))
        self._cache: OrderedDict[str, TransformersWhisperASR] = OrderedDict()

    def _get_model_id(self, language: str | None) -> str | None:
        if language:
            key = language.strip().lower()
            if key in self.model_map:
                return self.model_map[key]
        return self.default_model

    def transcribe(
        self,
        audio_path: Path,
        *,
        language: str | None = None,
        max_new_tokens: int = 256,
        num_beams: int = 5,
    ) -> ASRResult | None:
        model_id = self._get_model_id(language)
        if not model_id:
            return None

        backend = self._cache.get(model_id)
        if backend is None:
            backend = TransformersWhisperASR(model_id=model_id, device=self.device, dtype=self.dtype)
            self._cache[model_id] = backend
        else:
            # Refresh LRU ordering.
            self._cache.move_to_end(model_id, last=True)

        while len(self._cache) > self.max_loaded_models:
            _, evicted = self._cache.popitem(last=False)
            try:
                evicted.close()
            except Exception:
                pass

        return backend.transcribe(
            audio_path=audio_path,
            language=language,
            max_new_tokens=max_new_tokens,
            num_beams=num_beams,
        )


     1→from __future__ import annotations
     2→
     3→from dataclasses import dataclass
     4→from typing import Any
     5→
     6→from maya_transcribe.align import align_tokens, edit_distance
     7→from maya_transcribe.textnorm import tokenize_words
     8→
     9→
    10→@dataclass(frozen=True)
    11→class RoverDisagreement:
    12→    kind: str  # "token" | "gap"
    13→    index: int
    14→    chosen: Any
    15→    options: Any
    16→
    17→
    18→@dataclass(frozen=True)
    19→class RoverResult:
    20→    text: str
    21→    skeleton_key: str
    22→    disagreement_rate: float
    23→    disagreements: list[RoverDisagreement]
    24→
    25→
    26→def _avg_norm_distance(tokens_a: list[str], tokens_b: list[str]) -> float:
    27→    if not tokens_a and not tokens_b:
    28→        return 0.0
    29→    denom = max(1, (len(tokens_a) + len(tokens_b)) // 2)
    30→    return float(edit_distance(tokens_a, tokens_b)) / float(denom)
    31→
    32→
    33→def choose_centroid(hyps: dict[str, list[str]]) -> str:
    34→    keys = list(hyps.keys())
    35→    if not keys:
    36→        raise ValueError("No hypotheses provided")
    37→    if len(keys) == 1:
    38→        return keys[0]
    39→
    40→    avg: dict[str, float] = {}
    41→    for k in keys:
    42→        dsum = 0.0
    43→        for k2 in keys:
    44→            if k2 == k:
    45→                continue
    46→            dsum += _avg_norm_distance(hyps[k], hyps[k2])
    47→        avg[k] = dsum / float(len(keys) - 1)
    48→    return min(avg.items(), key=lambda kv: kv[1])[0]
    49→
    50→
    51→def rover_combine(
    52→    hypotheses: dict[str, str],
    53→    weights: dict[str, float] | None = None,
    54→    min_gap_support: float = 0.5,
    55→) -> RoverResult:
    56→    """
    57→    Lightweight ROVER-style combination:
    58→    - pick a centroid hypothesis as skeleton
    59→    - align others to skeleton
    60→    - vote per skeleton token position
    61→    - for gap insertions, keep a centroid insertion sequence if it has >= min_gap_support weight
    62→    """
    63→    if not hypotheses:
    64→        return RoverResult(text="", skeleton_key="", disagreement_rate=0.0, disagreements=[])
    65→
    66→    weights = weights or {}
    67→    tokenized: dict[str, list[str]] = {k: tokenize_words(v) for k, v in hypotheses.items()}
    68→    skel_key = choose_centroid(tokenized)
    69→    skel_tokens = tokenized[skel_key]
    70→
    71→    keys = list(tokenized.keys())
    72→    total_weight = sum(float(weights.get(k, 1.0)) for k in keys)
    73→    if total_weight <= 0:
    74→        total_weight = float(len(keys))
    75→
    76→    aligned_tokens: dict[str, list[str | None]] = {k: [None] * len(skel_tokens) for k in keys}
    77→    gap_inserts: dict[str, list[list[str]]] = {k: [[] for _ in range(len(skel_tokens) + 1)] for k in keys}
    78→
    79→    # Skeleton aligns to itself.
    80→    for j, tok in enumerate(skel_tokens):
    81→        aligned_tokens[skel_key][j] = tok
    82→
    83→    # Align all others to skeleton.
    84→    for k in keys:
    85→        if k == skel_key:
    86→            continue
    87→        hyp_tokens = tokenized[k]
    88→        alignment = align_tokens(skel_tokens, hyp_tokens).aligned
    89→
    90→        skel_pos = 0
    91→        for ref_tok, hyp_tok in alignment:
    92→            if ref_tok is None:
    93→                if hyp_tok is not None:
    94→                    gap_inserts[k][skel_pos].append(hyp_tok)
    95→                continue
    96→            # ref_tok is not None
    97→            if skel_pos >= len(skel_tokens):
    98→                break
    99→            aligned_tokens[k][skel_pos] = hyp_tok
   100→            skel_pos += 1
   101→
   102→    disagreements: list[RoverDisagreement] = []
   103→
   104→    # Combine gap insertions.
   105→    chosen_gaps: list[list[str]] = [[] for _ in range(len(skel_tokens) + 1)]
   106→    for gap_idx in range(len(skel_tokens) + 1):
   107→        sequences: list[tuple[str, tuple[str, ...], float]] = []
   108→        present_weight = 0.0
   109→        for k in keys:
   110→            w = float(weights.get(k, 1.0))
   111→            seq = tuple(gap_inserts[k][gap_idx])
   112→            if seq:
   113→                present_weight += w
   114→            sequences.append((k, seq, w))
   115→
   116→        if present_weight < (min_gap_support * total_weight):
   117→            chosen_gaps[gap_idx] = []
   118→            continue
   119→
   120→        # Choose centroid sequence among non-empty candidates (weighted by average distance).
   121→        candidates = [s for s in sequences if s[1]]
   122→        if not candidates:
   123→            chosen_gaps[gap_idx] = []
   124→            continue
   125→
   126→        best_seq: tuple[str, ...] | None = None
   127→        best_score: float | None = None
   128→        for _, seq, _w in candidates:
   129→            score = 0.0
   130→            for _, seq2, w2 in candidates:
   131→                score += _avg_norm_distance(list(seq), list(seq2)) * w2
   132→            score /= max(1e-9, present_weight)
   133→            if best_score is None or score < best_score:
   134→                best_score = score
   135→                best_seq = seq
   136→
   137→        chosen = list(best_seq or ())
   138→        chosen_gaps[gap_idx] = chosen
   139→
   140→        unique = {s[1] for s in candidates}
   141→        if len(unique) > 1:
   142→            disagreements.append(
   143→                RoverDisagreement(
   144→                    kind="gap",
   145→                    index=gap_idx,
   146→                    chosen=chosen,
   147→                    options={k: list(seq) for k, seq, _w in candidates},
   148→                )
   149→            )
   150→
   151→    # Combine skeleton positions.
   152→    chosen_tokens: list[str] = []
   153→    for j in range(len(skel_tokens)):
   154→        votes: dict[str, float] = {}
   155→        for k in keys:
   156→            tok = aligned_tokens[k][j]
   157→            if tok is None:
   158→                continue
   159→            tok = tok.strip()
   160→            if not tok:
   161→                continue
   162→            votes[tok] = votes.get(tok, 0.0) + float(weights.get(k, 1.0))
   163→
   164→        if not votes:
   165→            continue
   166→
   167→        max_vote = max(votes.values())
   168→        top = [t for t, v in votes.items() if v == max_vote]
   169→        if len(top) == 1:
   170→            chosen_tok = top[0]
   171→        else:
   172→            chosen_tok = skel_tokens[j] if skel_tokens[j] in top else sorted(top)[0]
   173→
   174→        if len(votes) > 1:
   175→            disagreements.append(
   176→                RoverDisagreement(kind="token", index=j, chosen=chosen_tok, options=dict(sorted(votes.items())))
   177→            )
   178→
   179→        chosen_tokens.append(chosen_tok)
   180→
   181→    # Assemble final transcript with gap insertions.
   182→    final_tokens: list[str] = []
   183→    for j in range(len(skel_tokens)):
   184→        final_tokens.extend(chosen_gaps[j])
   185→        if j < len(chosen_tokens):
   186→            final_tokens.append(chosen_tokens[j])
   187→    final_tokens.extend(chosen_gaps[len(skel_tokens)])
   188→
   189→    text = " ".join(final_tokens).strip()
   190→    total_positions = len(skel_tokens) + (len(skel_tokens) + 1)
   191→    disagreement_rate = float(len(disagreements)) / float(max(1, total_positions))
   192→
   193→    return RoverResult(
   194→        text=text,
   195→        skeleton_key=skel_key,
   196→        disagreement_rate=disagreement_rate,
   197→        disagreements=disagreements,
   198→    )
   199→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from maya_transcribe.align import align_tokens, edit_distance
from maya_transcribe.textnorm import tokenize_words

@dataclass(frozen=True)
class RoverDisagreement:
    kind: str  # "token" | "gap"
    index: int
    chosen: Any
    options: Any

@dataclass(frozen=True)
class RoverResult:
    text: str
    skeleton_key: str
    disagreement_rate: float
    disagreements: list[RoverDisagreement]

def _avg_norm_distance(tokens_a: list[str], tokens_b: list[str]) -> float:
    if not tokens_a and not tokens_b:
        return 0.0
    denom = max(1, (len(tokens_a) + len(tokens_b)) // 2)
    return float(edit_distance(tokens_a, tokens_b)) / float(denom)

def choose_centroid(hyps: dict[str, list[str]]) -> str:
    keys = list(hyps.keys())
    if not keys:
        raise ValueError("No hypotheses provided")
    if len(keys) == 1:
        return keys[0]

    avg: dict[str, float] = {}
    for k in keys:
        dsum = 0.0
        for k2 in keys:
            if k2 == k:
                continue
            dsum += _avg_norm_distance(hyps[k], hyps[k2])
        avg[k] = dsum / float(len(keys) - 1)
    return min(avg.items(), key=lambda kv: kv[1])[0]

def rover_combine(
    hypotheses: dict[str, str],
    weights: dict[str, float] | None = None,
    min_gap_support: float = 0.5,
) -> RoverResult:
    """
    Lightweight ROVER-style combination:
    - pick a centroid hypothesis as skeleton
    - align others to skeleton
    - vote per skeleton token position
    - for gap insertions, keep a centroid insertion sequence if it has >= min_gap_support weight
    """
    if not hypotheses:
        return RoverResult(text="", skeleton_key="", disagreement_rate=0.0, disagreements=[])

    weights = weights or {}
    tokenized: dict[str, list[str]] = {k: tokenize_words(v) for k, v in hypotheses.items()}
    skel_key = choose_centroid(tokenized)
    skel_tokens = tokenized[skel_key]

    keys = list(tokenized.keys())
    total_weight = sum(float(weights.get(k, 1.0)) for k in keys)
    if total_weight <= 0:
        total_weight = float(len(keys))

    aligned_tokens: dict[str, list[str | None]] = {k: [None] * len(skel_tokens) for k in keys}
    gap_inserts: dict[str, list[list[str]]] = {k: [[] for _ in range(len(skel_tokens) + 1)] for k in keys}

    # Skeleton aligns to itself.
    for j, tok in enumerate(skel_tokens):
        aligned_tokens[skel_key][j] = tok

    # Align all others to skeleton.
    for k in keys:
        if k == skel_key:
            continue
        hyp_tokens = tokenized[k]
        alignment = align_tokens(skel_tokens, hyp_tokens).aligned

        skel_pos = 0
        for ref_tok, hyp_tok in alignment:
            if ref_tok is None:
                if hyp_tok is not None:
                    gap_inserts[k][skel_pos].append(hyp_tok)
                continue
            # ref_tok is not None
            if skel_pos >= len(skel_tokens):
                break
            aligned_tokens[k][skel_pos] = hyp_tok
            skel_pos += 1

    disagreements: list[RoverDisagreement] = []

    # Combine gap insertions.
    chosen_gaps: list[list[str]] = [[] for _ in range(len(skel_tokens) + 1)]
    for gap_idx in range(len(skel_tokens) + 1):
        sequences: list[tuple[str, tuple[str, ...], float]] = []
        present_weight = 0.0
        for k in keys:
            w = float(weights.get(k, 1.0))
            seq = tuple(gap_inserts[k][gap_idx])
            if seq:
                present_weight += w
            sequences.append((k, seq, w))

        if present_weight < (min_gap_support * total_weight):
            chosen_gaps[gap_idx] = []
            continue

        # Choose centroid sequence among non-empty candidates (weighted by average distance).
        candidates = [s for s in sequences if s[1]]
        if not candidates:
            chosen_gaps[gap_idx] = []
            continue

        best_seq: tuple[str, ...] | None = None
        best_score: float | None = None
        for _, seq, _w in candidates:
            score = 0.0
            for _, seq2, w2 in candidates:
                score += _avg_norm_distance(list(seq), list(seq2)) * w2
            score /= max(1e-9, present_weight)
            if best_score is None or score < best_score:
                best_score = score
                best_seq = seq

        chosen = list(best_seq or ())
        chosen_gaps[gap_idx] = chosen

        unique = {s[1] for s in candidates}
        if len(unique) > 1:
            disagreements.append(
                RoverDisagreement(
                    kind="gap",
                    index=gap_idx,
                    chosen=chosen,
                    options={k: list(seq) for k, seq, _w in candidates},
                )
            )

    # Combine skeleton positions.
    chosen_tokens: list[str] = []
    for j in range(len(skel_tokens)):
        votes: dict[str, float] = {}
        for k in keys:
            tok = aligned_tokens[k][j]
            if tok is None:
                continue
            tok = tok.strip()
            if not tok:
                continue
            votes[tok] = votes.get(tok, 0.0) + float(weights.get(k, 1.0))

        if not votes:
            continue

        max_vote = max(votes.values())
        top = [t for t, v in votes.items() if v == max_vote]
        if len(top) == 1:
            chosen_tok = top[0]
        else:
            chosen_tok = skel_tokens[j] if skel_tokens[j] in top else sorted(top)[0]

        if len(votes) > 1:
            disagreements.append(
                RoverDisagreement(kind="token", index=j, chosen=chosen_tok, options=dict(sorted(votes.items())))
            )

        chosen_tokens.append(chosen_tok)

    # Assemble final transcript with gap insertions.
    final_tokens: list[str] = []
    for j in range(len(skel_tokens)):
        final_tokens.extend(chosen_gaps[j])
        if j < len(chosen_tokens):
            final_tokens.append(chosen_tokens[j])
    final_tokens.extend(chosen_gaps[len(skel_tokens)])

    text = " ".join(final_tokens).strip()
    total_positions = len(skel_tokens) + (len(skel_tokens) + 1)
    disagreement_rate = float(len(disagreements)) / float(max(1, total_positions))

    return RoverResult(
        text=text,
        skeleton_key=skel_key,
        disagreement_rate=disagreement_rate,
        disagreements=disagreements,
    )


     1→from __future__ import annotations
     2→
     3→from dataclasses import dataclass
     4→from typing import Any
     5→
     6→from maya_transcribe.lid import LIDResult
     7→
     8→
     9→@dataclass(frozen=True)
    10→class Verification:
    11→    decision: str  # "accept" | "human" | "reject"
    12→    reasons: list[str]
    13→    scores: dict[str, Any]
    14→
    15→
    16→def lid_confidence(lid: LIDResult | None) -> dict[str, Any]:
    17→    if not lid or not lid.predictions:
    18→        return {"available": False, "top1": None, "top1_prob": None, "margin": None, "confident": False}
    19→    top1 = lid.predictions[0]
    20→    top2_prob = lid.predictions[1].prob if len(lid.predictions) > 1 else 0.0
    21→    margin = float(top1.prob) - float(top2_prob)
    22→    return {
    23→        "available": True,
    24→        "top1": top1.code,
    25→        "top1_prob": float(top1.prob),
    26→        "margin": margin,
    27→        "confident": False,
    28→    }
    29→
    30→
    31→def verify_segment(
    32→    *,
    33→    qc: dict[str, Any],
    34→    lid: LIDResult | None,
    35→    final_text: str,
    36→    rover: dict[str, Any],
    37→    asr_summaries: dict[str, Any],
    38→    primary_asr_key: str | None = None,
    39→    ctc: dict[str, Any] | None = None,
    40→    thresholds: dict[str, Any] | None = None,
    41→) -> Verification:
    42→    """
    43→    Conservative verification gate (precision-first).
    44→    """
    45→    thresholds = thresholds or {}
    46→    min_duration = float(thresholds.get("min_duration_sec", 0.8))
    47→    max_clipping = float(thresholds.get("max_clipping_fraction", 0.01))
    48→    max_disagreement = float(thresholds.get("max_rover_disagreement_rate", 0.03))
    49→    lid_min_prob = float(thresholds.get("lid_min_prob", 0.90))
    50→    lid_min_margin = float(thresholds.get("lid_min_margin", 0.20))
    51→    require_lid = bool(thresholds.get("require_lid", True))
    52→
    53→    # Whisper-style failure heuristics (see OpenAI whisper fallback logic).
    54→    min_avg_logprob = float(thresholds.get("min_avg_logprob_mean", -1.0))
    55→    max_compression_ratio = float(thresholds.get("max_compression_ratio_mean", 2.4))
    56→
    57→    # CTC fit (audio-grounded) gating.
    58→    require_ctc = bool(thresholds.get("require_ctc", False))
    59→    min_ctc_margin = float(thresholds.get("min_ctc_margin", 0.0))
    60→    min_ctc_token_coverage = float(thresholds.get("min_ctc_token_coverage", 0.85))
    61→    min_ctc_token_score_mean = float(thresholds.get("min_ctc_token_score_mean", -3.0))
    62→    fail_on_ctc_span_mismatch = bool(thresholds.get("fail_on_ctc_span_mismatch", True))
    63→
    64→    reasons: list[str] = []
    65→    decision = "accept"
    66→
    67→    duration = float(qc.get("duration_sec") or 0.0)
    68→    clipping = float(qc.get("clipping_fraction") or 0.0)
    69→    if duration < min_duration:
    70→        decision = "reject"
    71→        reasons.append("too_short")
    72→    if clipping > max_clipping:
    73→        decision = "human" if decision != "reject" else decision
    74→        reasons.append("clipping")
    75→
    76→    lidc = lid_confidence(lid)
    77→    if lidc["available"]:
    78→        lidc["confident"] = bool(lidc["top1_prob"] >= lid_min_prob and lidc["margin"] >= lid_min_margin)
    79→        if not lidc["confident"]:
    80→            decision = "human" if decision != "reject" else decision
    81→            reasons.append("lid_uncertain")
    82→    else:
    83→        reasons.append("lid_missing")
    84→        if require_lid:
    85→            decision = "human" if decision != "reject" else decision
    86→
    87→    disagreement_rate = float(rover.get("disagreement_rate") or 0.0)
    88→    if disagreement_rate > max_disagreement:
    89→        decision = "human" if decision != "reject" else decision
    90→        reasons.append("asr_disagreement")
    91→
    92→    final_text = (final_text or "").strip()
    93→    if "<unk>" in final_text or "<inaudible>" in final_text:
    94→        decision = "human" if decision != "reject" else decision
    95→        reasons.append("contains_unk")
    96→
    97→    # Whisper-style safety: flag if any decoder claims "no speech" with high probability.
    98→    no_speech_probs = []
    99→    for summary in asr_summaries.values():
   100→        p = summary.get("no_speech_prob_mean")
   101→        if p is None:
   102→            continue
   103→        no_speech_probs.append(float(p))
   104→    if no_speech_probs and max(no_speech_probs) > 0.8:
   105→        decision = "reject"
   106→        reasons.append("no_speech")
   107→
   108→    # Whisper-style hallucination heuristics: apply to the primary ASR (or rover skeleton fallback).
   109→    asr_key_for_heuristics = primary_asr_key or str(rover.get("skeleton_key") or "")
   110→    heur = asr_summaries.get(asr_key_for_heuristics, {}) if asr_key_for_heuristics else {}
   111→    avg_lp = heur.get("avg_logprob_mean")
   112→    comp = heur.get("compression_ratio_mean")
   113→    if avg_lp is not None and float(avg_lp) < min_avg_logprob:
   114→        decision = "human" if decision != "reject" else decision
   115→        reasons.append("low_logprob")
   116→    if comp is not None and float(comp) > max_compression_ratio:
   117→        decision = "human" if decision != "reject" else decision
   118→        reasons.append("high_compression_ratio")
   119→
   120→    # Audio-grounded fit check (when available).
   121→    if require_ctc and (ctc is None or ctc.get("best_loss") is None):
   122→        decision = "human" if decision != "reject" else decision
   123→        reasons.append("ctc_missing")
   124→    if ctc is not None:
   125→        margin = ctc.get("margin")
   126→        if margin is not None and float(margin) < min_ctc_margin:
   127→            decision = "human" if decision != "reject" else decision
   128→            reasons.append("ctc_ambiguous")
   129→
   130→        best_key = ctc.get("best_key")
   131→        scores = ctc.get("scores")
   132→        if isinstance(best_key, str) and isinstance(scores, dict):
   133→            best = scores.get(best_key) or {}
   134→            if isinstance(best, dict):
   135→                cov = best.get("token_coverage")
   136→                if cov is not None and float(cov) < min_ctc_token_coverage:
   137→                    decision = "human" if decision != "reject" else decision
   138→                    reasons.append("ctc_low_coverage")
   139→                mean_tok = best.get("token_score_mean")
   140→                if mean_tok is not None and float(mean_tok) < min_ctc_token_score_mean:
   141→                    decision = "human" if decision != "reject" else decision
   142→                    reasons.append("ctc_low_token_score")
   143→                if fail_on_ctc_span_mismatch and bool(best.get("span_mismatch")):
   144→                    decision = "human" if decision != "reject" else decision
   145→                    reasons.append("ctc_span_mismatch")
   146→
   147→    scores = {
   148→        "duration_sec": duration,
   149→        "clipping_fraction": clipping,
   150→        "rover_disagreement_rate": disagreement_rate,
   151→        "lid": lidc,
   152→        "no_speech_prob_max": max(no_speech_probs) if no_speech_probs else None,
   153→        "heuristics_asr_key": asr_key_for_heuristics or None,
   154→        "avg_logprob_mean": avg_lp,
   155→        "compression_ratio_mean": comp,
   156→        "ctc": ctc,
   157→    }
   158→
   159→    return Verification(decision=decision, reasons=reasons, scores=scores)
   160→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from maya_transcribe.lid import LIDResult

@dataclass(frozen=True)
class Verification:
    decision: str  # "accept" | "human" | "reject"
    reasons: list[str]
    scores: dict[str, Any]

def lid_confidence(lid: LIDResult | None) -> dict[str, Any]:
    if not lid or not lid.predictions:
        return {"available": False, "top1": None, "top1_prob": None, "margin": None, "confident": False}
    top1 = lid.predictions[0]
    top2_prob = lid.predictions[1].prob if len(lid.predictions) > 1 else 0.0
    margin = float(top1.prob) - float(top2_prob)
    return {
        "available": True,
        "top1": top1.code,
        "top1_prob": float(top1.prob),
        "margin": margin,
        "confident": False,
    }

def verify_segment(
    *,
    qc: dict[str, Any],
    lid: LIDResult | None,
    final_text: str,
    rover: dict[str, Any],
    asr_summaries: dict[str, Any],
    primary_asr_key: str | None = None,
    ctc: dict[str, Any] | None = None,
    thresholds: dict[str, Any] | None = None,
) -> Verification:
    """
    Conservative verification gate (precision-first).
    """
    thresholds = thresholds or {}
    min_duration = float(thresholds.get("min_duration_sec", 0.8))
    max_clipping = float(thresholds.get("max_clipping_fraction", 0.01))
    max_disagreement = float(thresholds.get("max_rover_disagreement_rate", 0.03))
    lid_min_prob = float(thresholds.get("lid_min_prob", 0.90))
    lid_min_margin = float(thresholds.get("lid_min_margin", 0.20))
    require_lid = bool(thresholds.get("require_lid", True))

    # Whisper-style failure heuristics (see OpenAI whisper fallback logic).
    min_avg_logprob = float(thresholds.get("min_avg_logprob_mean", -1.0))
    max_compression_ratio = float(thresholds.get("max_compression_ratio_mean", 2.4))

    # CTC fit (audio-grounded) gating.
    require_ctc = bool(thresholds.get("require_ctc", False))
    min_ctc_margin = float(thresholds.get("min_ctc_margin", 0.0))
    min_ctc_token_coverage = float(thresholds.get("min_ctc_token_coverage", 0.85))
    min_ctc_token_score_mean = float(thresholds.get("min_ctc_token_score_mean", -3.0))
    fail_on_ctc_span_mismatch = bool(thresholds.get("fail_on_ctc_span_mismatch", True))

    reasons: list[str] = []
    decision = "accept"

    duration = float(qc.get("duration_sec") or 0.0)
    clipping = float(qc.get("clipping_fraction") or 0.0)
    if duration < min_duration:
        decision = "reject"
        reasons.append("too_short")
    if clipping > max_clipping:
        decision = "human" if decision != "reject" else decision
        reasons.append("clipping")

    lidc = lid_confidence(lid)
    if lidc["available"]:
        lidc["confident"] = bool(lidc["top1_prob"] >= lid_min_prob and lidc["margin"] >= lid_min_margin)
        if not lidc["confident"]:
            decision = "human" if decision != "reject" else decision
            reasons.append("lid_uncertain")
    else:
        reasons.append("lid_missing")
        if require_lid:
            decision = "human" if decision != "reject" else decision

    disagreement_rate = float(rover.get("disagreement_rate") or 0.0)
    if disagreement_rate > max_disagreement:
        decision = "human" if decision != "reject" else decision
        reasons.append("asr_disagreement")

    final_text = (final_text or "").strip()
    if "<unk>" in final_text or "<inaudible>" in final_text:
        decision = "human" if decision != "reject" else decision
        reasons.append("contains_unk")

    # Whisper-style safety: flag if any decoder claims "no speech" with high probability.
    no_speech_probs = []
    for summary in asr_summaries.values():
        p = summary.get("no_speech_prob_mean")
        if p is None:
            continue
        no_speech_probs.append(float(p))
    if no_speech_probs and max(no_speech_probs) > 0.8:
        decision = "reject"
        reasons.append("no_speech")

    # Whisper-style hallucination heuristics: apply to the primary ASR (or rover skeleton fallback).
    asr_key_for_heuristics = primary_asr_key or str(rover.get("skeleton_key") or "")
    heur = asr_summaries.get(asr_key_for_heuristics, {}) if asr_key_for_heuristics else {}
    avg_lp = heur.get("avg_logprob_mean")
    comp = heur.get("compression_ratio_mean")
    if avg_lp is not None and float(avg_lp) < min_avg_logprob:
        decision = "human" if decision != "reject" else decision
        reasons.append("low_logprob")
    if comp is not None and float(comp) > max_compression_ratio:
        decision = "human" if decision != "reject" else decision
        reasons.append("high_compression_ratio")

    # Audio-grounded fit check (when available).
    if require_ctc and (ctc is None or ctc.get("best_loss") is None):
        decision = "human" if decision != "reject" else decision
        reasons.append("ctc_missing")
    if ctc is not None:
        margin = ctc.get("margin")
        if margin is not None and float(margin) < min_ctc_margin:
            decision = "human" if decision != "reject" else decision
            reasons.append("ctc_ambiguous")

        best_key = ctc.get("best_key")
        scores = ctc.get("scores")
        if isinstance(best_key, str) and isinstance(scores, dict):
            best = scores.get(best_key) or {}
            if isinstance(best, dict):
                cov = best.get("token_coverage")
                if cov is not None and float(cov) < min_ctc_token_coverage:
                    decision = "human" if decision != "reject" else decision
                    reasons.append("ctc_low_coverage")
                mean_tok = best.get("token_score_mean")
                if mean_tok is not None and float(mean_tok) < min_ctc_token_score_mean:
                    decision = "human" if decision != "reject" else decision
                    reasons.append("ctc_low_token_score")
                if fail_on_ctc_span_mismatch and bool(best.get("span_mismatch")):
                    decision = "human" if decision != "reject" else decision
                    reasons.append("ctc_span_mismatch")

    scores = {
        "duration_sec": duration,
        "clipping_fraction": clipping,
        "rover_disagreement_rate": disagreement_rate,
        "lid": lidc,
        "no_speech_prob_max": max(no_speech_probs) if no_speech_probs else None,
        "heuristics_asr_key": asr_key_for_heuristics or None,
        "avg_logprob_mean": avg_lp,
        "compression_ratio_mean": comp,
        "ctc": ctc,
    }

    return Verification(decision=decision, reasons=reasons, scores=scores)


Now let me examine the remaining critical files:


     1→from __future__ import annotations
     2→
     3→from dataclasses import dataclass
     4→from pathlib import Path
     5→from typing import Any
     6→
     7→from maya_transcribe.audio import decode_pcm16
     8→from maya_transcribe.language import ISO3_TO_WHISPER_LANG
     9→from maya_transcribe.textnorm import normalize_text
    10→
    11→
    12→@dataclass(frozen=True)
    13→class CTCScore:
    14→    model_id: str
    15→    loss: float
    16→    text: str
    17→    unk_ratio: float | None
    18→    target_len: int
    19→    token_score_mean: float | None
    20→    token_score_min: float | None
    21→    token_coverage: float | None
    22→    token_logp_threshold: float | None
    23→    span_count: int | None
    24→    span_mismatch: bool | None
    25→
    26→
    27→_WHISPER_TO_ISO3: dict[str, str] = {v: k for k, v in ISO3_TO_WHISPER_LANG.items()}
    28→
    29→
    30→def _to_iso3(lang: str | None) -> str | None:
    31→    if not lang:
    32→        return None
    33→    code = lang.strip().lower()
    34→    if not code:
    35→        return None
    36→    # Handle common BCP-47 forms (e.g., hi_IN / hi-IN).
    37→    code = code.replace("-", "_")
    38→    if "_" in code:
    39→        code = code.split("_", 1)[0]
    40→    if len(code) == 3:
    41→        return code
    42→    if len(code) == 2:
    43→        return _WHISPER_TO_ISO3.get(code, None)
    44→    return code
    45→
    46→
    47→class _CTCModel:
    48→    def __init__(self, model_id: str, device: str | None = None) -> None:
    49→        try:
    50→            import torch
    51→            from transformers import AutoModelForCTC, AutoProcessor
    52→        except Exception as e:  # pragma: no cover
    53→            raise RuntimeError(
    54→                "CTC scoring requires torch + transformers. Install with: pip install -e '.[torch]'"
    55→            ) from e
    56→
    57→        if device is None:
    58→            device = "cuda" if torch.cuda.is_available() else "cpu"
    59→
    60→        self.model_id = model_id
    61→        self.device = device
    62→        self._torch = torch
    63→        self._processor = AutoProcessor.from_pretrained(model_id)
    64→        self._model = AutoModelForCTC.from_pretrained(model_id).to(device)
    65→        self._model.eval()
    66→
    67→        tokenizer = getattr(self._processor, "tokenizer", None)
    68→        self._force_uppercase = False
    69→        if tokenizer is not None and hasattr(tokenizer, "get_vocab"):
    70→            vocab = tokenizer.get_vocab()
    71→            # Common for wav2vec2 CTC: vocab contains A-Z but not a-z; transcripts should be uppercased.
    72→            self._force_uppercase = ("A" in vocab) and ("a" not in vocab)
    73→
    74→        blank = getattr(self._model.config, "pad_token_id", None)
    75→        if blank is None and getattr(self._processor, "tokenizer", None) is not None:
    76→            blank = getattr(self._processor.tokenizer, "pad_token_id", None)
    77→        self.blank_id = int(blank) if blank is not None else 0
    78→
    79→        self._target_lang: str | None = None
    80→
    81→    def _maybe_set_target_lang(self, lang: str | None) -> None:
    82→        tokenizer = getattr(self._processor, "tokenizer", None)
    83→        set_lang = getattr(tokenizer, "set_target_lang", None) if tokenizer is not None else None
    84→        if not callable(set_lang):
    85→            return
    86→
    87→        iso3 = _to_iso3(lang)
    88→        if not iso3:
    89→            return
    90→        if self._target_lang == iso3:
    91→            return
    92→        try:
    93→            set_lang(iso3)
    94→        except Exception as e:
    95→            raise RuntimeError(f"set_target_lang_failed:{iso3}:{e}") from e
    96→        self._target_lang = iso3
    97→
    98→    def _compute_log_probs(self, audio_path: Path):
    99→        wav, sr = decode_pcm16(audio_path, sample_rate=16000, mono=True)
   100→        if wav.size == 0:
   101→            raise ValueError("empty_audio")
   102→
   103→        inputs = self._processor(wav, sampling_rate=sr, return_tensors="pt")
   104→
   105→        # Common key names: input_values (wav2vec2) or input_features (some processors).
   106→        if "input_values" in inputs:
   107→            model_inputs = {"input_values": inputs["input_values"].to(self.device)}
   108→        elif "input_features" in inputs:
   109→            model_inputs = {"input_features": inputs["input_features"].to(self.device)}
   110→        else:
   111→            # Best-effort: move all tensors to device.
   112→            model_inputs = {k: v.to(self.device) for k, v in inputs.items() if hasattr(v, "to")}
   113→
   114→        with self._torch.no_grad():
   115→            logits = self._model(**model_inputs).logits  # (B,T,C)
   116→            log_probs = logits.log_softmax(dim=-1)  # (B,T,C)
   117→
   118→        input_lengths = self._torch.tensor([log_probs.size(1)], dtype=self._torch.long, device=log_probs.device)
   119→        return log_probs, input_lengths
   120→
   121→    def _score_from_log_probs(
   122→        self,
   123→        *,
   124→        log_probs,
   125→        input_lengths,
   126→        text: str,
   127→        lang: str | None,
   128→        max_unk_ratio: float,
   129→        token_logp_threshold: float,
   130→    ) -> CTCScore:
   131→        import torch.nn.functional as F
   132→        import warnings
   133→
   134→        if not text:
   135→            raise ValueError("empty_text")
   136→        if "<unk>" in text or "<inaudible>" in text:
   137→            raise ValueError("contains_unk")
   138→
   139→        # Conservative normalization helps tokenizer compatibility.
   140→        text_norm = normalize_text(text, lang=lang)
   141→        if not text_norm:
   142→            raise ValueError("empty_text")
   143→        if self._force_uppercase:
   144→            text_norm = text_norm.upper()
   145→
   146→        # Tokenize targets (MMS multilingual tokenizers require set_target_lang()).
   147→        tokenizer = getattr(self._processor, "tokenizer", None)
   148→        if tokenizer is None:
   149→            raise ValueError("missing_tokenizer")
   150→        self._maybe_set_target_lang(lang)
   151→        tokenized = tokenizer(text_norm, add_special_tokens=False)
   152→        target_ids = tokenized.get("input_ids")
   153→        if not target_ids:
   154→            raise ValueError("empty_targets")
   155→
   156→        unk_id = getattr(tokenizer, "unk_token_id", None)
   157→        unk_ratio: float | None = None
   158→        if unk_id is not None:
   159→            unk_count = sum(1 for tid in target_ids if int(tid) == int(unk_id))
   160→            unk_ratio = float(unk_count) / float(len(target_ids))
   161→            if unk_ratio > max_unk_ratio:
   162→                raise ValueError(f"too_many_unk_tokens:{unk_ratio:.3f}")
   163→
   164→        # torch ctc_loss expects (T,N,C)
   165→        log_probs_tnc = log_probs.transpose(0, 1)
   166→        device = log_probs_tnc.device
   167→        targets = self._torch.tensor(target_ids, dtype=self._torch.long, device=device)
   168→        target_lengths = self._torch.tensor([targets.numel()], dtype=self._torch.long, device=device)
   169→
   170→        # Reduction=mean normalizes by target length; good for comparability.
   171→        loss = F.ctc_loss(
   172→            log_probs_tnc,
   173→            targets,
   174→            input_lengths,
   175→            target_lengths,
   176→            blank=self.blank_id,
   177→            reduction="mean",
   178→            zero_infinity=True,
   179→        )
   180→        loss_value = float(loss.detach().cpu().item())
   181→
   182→        # Forced alignment token scores (coverage/fit). Best-effort: alignment may fail for short audio vs long text.
   183→        token_score_mean = None
   184→        token_score_min = None
   185→        token_coverage = None
   186→        span_count = None
   187→        span_mismatch = None
   188→
   189→        try:
   190→            import torchaudio
   191→
   192→            with warnings.catch_warnings():
   193→                warnings.filterwarnings("ignore", message=".*forced_align.*deprecated.*", category=UserWarning)
   194→                aligned_tokens, aligned_scores = torchaudio.functional.forced_align(
   195→                    log_probs,
   196→                    targets.unsqueeze(0),
   197→                    input_lengths=input_lengths,
   198→                    target_lengths=target_lengths,
   199→                    blank=self.blank_id,
   200→                )
   201→
   202→            spans = torchaudio.functional.merge_tokens(
   203→                aligned_tokens[0].detach().cpu(), aligned_scores[0].detach().cpu(), blank=self.blank_id
   204→            )
   205→            span_scores = [float(s.score) for s in spans]
   206→            span_count = len(span_scores)
   207→            span_mismatch = (span_count != int(target_lengths.item()))
   208→            if span_scores:
   209→                token_score_mean = sum(span_scores) / float(len(span_scores))
   210→                token_score_min = min(span_scores)
   211→                token_coverage = sum(1 for s in span_scores if s >= token_logp_threshold) / float(len(span_scores))
   212→        except Exception:
   213→            pass
   214→
   215→        return CTCScore(
   216→            model_id=self.model_id,
   217→            loss=loss_value,
   218→            text=text,
   219→            unk_ratio=unk_ratio,
   220→            target_len=int(target_lengths.item()),
   221→            token_score_mean=token_score_mean,
   222→            token_score_min=token_score_min,
   223→            token_coverage=token_coverage,
   224→            token_logp_threshold=token_logp_threshold,
   225→            span_count=span_count,
   226→            span_mismatch=span_mismatch,
   227→        )
   228→
   229→    def score(
   230→        self,
   231→        audio_path: Path,
   232→        text: str,
   233→        lang: str | None = None,
   234→        *,
   235→        max_unk_ratio: float = 0.2,
   236→        token_logp_threshold: float = -3.0,
   237→    ) -> CTCScore:
   238→        log_probs, input_lengths = self._compute_log_probs(audio_path)
   239→        return self._score_from_log_probs(
   240→            log_probs=log_probs,
   241→            input_lengths=input_lengths,
   242→            text=text,
   243→            lang=lang,
   244→            max_unk_ratio=max_unk_ratio,
   245→            token_logp_threshold=token_logp_threshold,
   246→        )
   247→
   248→    def score_candidates(
   249→        self,
   250→        *,
   251→        audio_path: Path,
   252→        candidates: dict[str, str],
   253→        lang: str | None,
   254→        max_unk_ratio: float,
   255→        token_logp_threshold: float,
   256→    ) -> tuple[list[tuple[str, CTCScore]], dict[str, str]]:
   257→        log_probs, input_lengths = self._compute_log_probs(audio_path)
   258→
   259→        scored: list[tuple[str, CTCScore]] = []
   260→        errors: dict[str, str] = {}
   261→
   262→        for key, text in candidates.items():
   263→            text = (text or "").strip()
   264→            if not text:
   265→                continue
   266→            try:
   267→                score = self._score_from_log_probs(
   268→                    log_probs=log_probs,
   269→                    input_lengths=input_lengths,
   270→                    text=text,
   271→                    lang=lang,
   272→                    max_unk_ratio=max_unk_ratio,
   273→                    token_logp_threshold=token_logp_threshold,
   274→                )
   275→                scored.append((key, score))
   276→            except Exception as e:
   277→                errors[key] = f"{type(e).__name__}:{e}"
   278→
   279→        return scored, errors
   280→
   281→
   282→class CTCFitScorer:
   283→    """
   284→    Cache-loaded CTC scoring models for audio-grounded transcript fit.
   285→    """
   286→
   287→    def __init__(
   288→        self,
   289→        model_map: dict[str, str] | None = None,
   290→        default_model: str | None = None,
   291→        *,
   292→        max_unk_ratio: float = 0.2,
   293→        token_logp_threshold: float = -3.0,
   294→    ) -> None:
   295→        self.model_map = model_map or {}
   296→        self.default_model = default_model
   297→        self.max_unk_ratio = float(max_unk_ratio)
   298→        self.token_logp_threshold = float(token_logp_threshold)
   299→        self._cache: dict[str, _CTCModel] = {}
   300→
   301→    def _get_model_id(self, lang: str | None) -> str | None:
   302→        if lang and lang in self.model_map:
   303→            return self.model_map[lang]
   304→        return self.default_model
   305→
   306→    def score(self, audio_path: Path, text: str, lang: str | None) -> CTCScore | None:
   307→        model_id = self._get_model_id(lang)
   308→        if not model_id:
   309→            return None
   310→
   311→        model = self._cache.get(model_id)
   312→        if model is None:
   313→            model = _CTCModel(model_id=model_id)
   314→            self._cache[model_id] = model
   315→
   316→        return model.score(
   317→            audio_path=audio_path,
   318→            text=text,
   319→            lang=lang,
   320→            max_unk_ratio=self.max_unk_ratio,
   321→            token_logp_threshold=self.token_logp_threshold,
   322→        )
   323→
   324→    def score_candidates(
   325→        self, audio_path: Path, candidates: dict[str, str], lang: str | None
   326→    ) -> tuple[list[tuple[str, CTCScore]], dict[str, str]] | None:
   327→        model_id = self._get_model_id(lang)
   328→        if not model_id:
   329→            return None
   330→
   331→        model = self._cache.get(model_id)
   332→        if model is None:
   333→            model = _CTCModel(model_id=model_id)
   334→            self._cache[model_id] = model
   335→
   336→        return model.score_candidates(
   337→            audio_path=audio_path,
   338→            candidates=candidates,
   339→            lang=lang,
   340→            max_unk_ratio=self.max_unk_ratio,
   341→            token_logp_threshold=self.token_logp_threshold,
   342→        )
   343→
   344→
   345→def rank_candidates_by_ctc(
   346→    scorer: CTCFitScorer, audio_path: Path, candidates: dict[str, str], lang: str | None
   347→) -> dict[str, Any]:
   348→    scored: list[tuple[str, CTCScore]] = []
   349→    errors: dict[str, str] = {}
   350→
   351→    batch = scorer.score_candidates(audio_path=audio_path, candidates=candidates, lang=lang)
   352→    if batch is not None:
   353→        scored, errors = batch
   354→
   355→    scored.sort(key=lambda kv: kv[1].loss)
   356→    best = scored[0] if scored else None
   357→
   358→    # Find the best *different-text* alternative, so duplicates don't create a fake "zero margin".
   359→    second = None
   360→    if best:
   361→        best_text = best[1].text
   362→        for item in scored[1:]:
   363→            if item[1].text != best_text:
   364→                second = item
   365→                break
   366→
   367→    margin = (second[1].loss - best[1].loss) if (best and second) else None
   368→
   369→    return {
   370→        "model_id": best[1].model_id if best else None,
   371→        "best_key": best[0] if best else None,
   372→        "best_loss": best[1].loss if best else None,
   373→        "second_best_key": second[0] if second else None,
   374→        "second_best_loss": second[1].loss if second else None,
   375→        "margin": float(margin) if margin is not None else None,
   376→        "scores": {
   377→            k: {
   378→                "loss": s.loss,
   379→                "model_id": s.model_id,
   380→                "unk_ratio": s.unk_ratio,
   381→                "target_len": s.target_len,
   382→                "token_score_mean": s.token_score_mean,
   383→                "token_score_min": s.token_score_min,
   384→                "token_coverage": s.token_coverage,
   385→                "token_logp_threshold": s.token_logp_threshold,
   386→                "span_count": s.span_count,
   387→                "span_mismatch": s.span_mismatch,
   388→            }
   389→            for k, s in scored
   390→        },
   391→        "errors": errors,
   392→    }
   393→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

from maya_transcribe.audio import decode_pcm16
from maya_transcribe.language import ISO3_TO_WHISPER_LANG
from maya_transcribe.textnorm import normalize_text

@dataclass(frozen=True)
class CTCScore:
    model_id: str
    loss: float
    text: str
    unk_ratio: float | None
    target_len: int
    token_score_mean: float | None
    token_score_min: float | None
    token_coverage: float | None
    token_logp_threshold: float | None
    span_count: int | None
    span_mismatch: bool | None

_WHISPER_TO_ISO3: dict[str, str] = {v: k for k, v in ISO3_TO_WHISPER_LANG.items()}

def _to_iso3(lang: str | None) -> str | None:
    if not lang:
        return None
    code = lang.strip().lower()
    if not code:
        return None
    # Handle common BCP-47 forms (e.g., hi_IN / hi-IN).
    code = code.replace("-", "_")
    if "_" in code:
        code = code.split("_", 1)[0]
    if len(code) == 3:
        return code
    if len(code) == 2:
        return _WHISPER_TO_ISO3.get(code, None)
    return code

class _CTCModel:
    def __init__(self, model_id: str, device: str | None = None) -> None:
        try:
            import torch
            from transformers import AutoModelForCTC, AutoProcessor
        except Exception as e:  # pragma: no cover
            raise RuntimeError(
                "CTC scoring requires torch + transformers. Install with: pip install -e '.[torch]'"
            ) from e

        if device is None:
            device = "cuda" if torch.cuda.is_available() else "cpu"

        self.model_id = model_id
        self.device = device
        self._torch = torch
        self._processor = AutoProcessor.from_pretrained(model_id)
        self._model = AutoModelForCTC.from_pretrained(model_id).to(device)
        self._model.eval()

        tokenizer = getattr(self._processor, "tokenizer", None)
        self._force_uppercase = False
        if tokenizer is not None and hasattr(tokenizer, "get_vocab"):
            vocab = tokenizer.get_vocab()
            # Common for wav2vec2 CTC: vocab contains A-Z but not a-z; transcripts should be uppercased.
            self._force_uppercase = ("A" in vocab) and ("a" not in vocab)

        blank = getattr(self._model.config, "pad_token_id", None)
        if blank is None and getattr(self._processor, "tokenizer", None) is not None:
            blank = getattr(self._processor.tokenizer, "pad_token_id", None)
        self.blank_id = int(blank) if blank is not None else 0

        self._target_lang: str | None = None

    def _maybe_set_target_lang(self, lang: str | None) -> None:
        tokenizer = getattr(self._processor, "tokenizer", None)
        set_lang = getattr(tokenizer, "set_target_lang", None) if tokenizer is not None else None
        if not callable(set_lang):
            return

        iso3 = _to_iso3(lang)
        if not iso3:
            return
        if self._target_lang == iso3:
            return
        try:
            set_lang(iso3)
        except Exception as e:
            raise RuntimeError(f"set_target_lang_failed:{iso3}:{e}") from e
        self._target_lang = iso3

    def _compute_log_probs(self, audio_path: Path):
        wav, sr = decode_pcm16(audio_path, sample_rate=16000, mono=True)
        if wav.size == 0:
            raise ValueError("empty_audio")

        inputs = self._processor(wav, sampling_rate=sr, return_tensors="pt")

        # Common key names: input_values (wav2vec2) or input_features (some processors).
        if "input_values" in inputs:
            model_inputs = {"input_values": inputs["input_values"].to(self.device)}
        elif "input_features" in inputs:
            model_inputs = {"input_features": inputs["input_features"].to(self.device)}
        else:
            # Best-effort: move all tensors to device.
            model_inputs = {k: v.to(self.device) for k, v in inputs.items() if hasattr(v, "to")}

        with self._torch.no_grad():
            logits = self._model(**model_inputs).logits  # (B,T,C)
            log_probs = logits.log_softmax(dim=-1)  # (B,T,C)

        input_lengths = self._torch.tensor([log_probs.size(1)], dtype=self._torch.long, device=log_probs.device)
        return log_probs, input_lengths

    def _score_from_log_probs(
        self,
        *,
        log_probs,
        input_lengths,
        text: str,
        lang: str | None,
        max_unk_ratio: float,
        token_logp_threshold: float,
    ) -> CTCScore:
        import torch.nn.functional as F
        import warnings

        if not text:
            raise ValueError("empty_text")
        if "<unk>" in text or "<inaudible>" in text:
            raise ValueError("contains_unk")

        # Conservative normalization helps tokenizer compatibility.
        text_norm = normalize_text(text, lang=lang)
        if not text_norm:
            raise ValueError("empty_text")
        if self._force_uppercase:
            text_norm = text_norm.upper()

        # Tokenize targets (MMS multilingual tokenizers require set_target_lang()).
        tokenizer = getattr(self._processor, "tokenizer", None)
        if tokenizer is None:
            raise ValueError("missing_tokenizer")
        self._maybe_set_target_lang(lang)
        tokenized = tokenizer(text_norm, add_special_tokens=False)
        target_ids = tokenized.get("input_ids")
        if not target_ids:
            raise ValueError("empty_targets")

        unk_id = getattr(tokenizer, "unk_token_id", None)
        unk_ratio: float | None = None
        if unk_id is not None:
            unk_count = sum(1 for tid in target_ids if int(tid) == int(unk_id))
            unk_ratio = float(unk_count) / float(len(target_ids))
            if unk_ratio > max_unk_ratio:
                raise ValueError(f"too_many_unk_tokens:{unk_ratio:.3f}")

        # torch ctc_loss expects (T,N,C)
        log_probs_tnc = log_probs.transpose(0, 1)
        device = log_probs_tnc.device
        targets = self._torch.tensor(target_ids, dtype=self._torch.long, device=device)
        target_lengths = self._torch.tensor([targets.numel()], dtype=self._torch.long, device=device)

        # Reduction=mean normalizes by target length; good for comparability.
        loss = F.ctc_loss(
            log_probs_tnc,
            targets,
            input_lengths,
            target_lengths,
            blank=self.blank_id,
            reduction="mean",
            zero_infinity=True,
        )
        loss_value = float(loss.detach().cpu().item())

        # Forced alignment token scores (coverage/fit). Best-effort: alignment may fail for short audio vs long text.
        token_score_mean = None
        token_score_min = None
        token_coverage = None
        span_count = None
        span_mismatch = None

        try:
            import torchaudio

            with warnings.catch_warnings():
                warnings.filterwarnings("ignore", message=".*forced_align.*deprecated.*", category=UserWarning)
                aligned_tokens, aligned_scores = torchaudio.functional.forced_align(
                    log_probs,
                    targets.unsqueeze(0),
                    input_lengths=input_lengths,
                    target_lengths=target_lengths,
                    blank=self.blank_id,
                )

            spans = torchaudio.functional.merge_tokens(
                aligned_tokens[0].detach().cpu(), aligned_scores[0].detach().cpu(), blank=self.blank_id
            )
            span_scores = [float(s.score) for s in spans]
            span_count = len(span_scores)
            span_mismatch = (span_count != int(target_lengths.item()))
            if span_scores:
                token_score_mean = sum(span_scores) / float(len(span_scores))
                token_score_min = min(span_scores)
                token_coverage = sum(1 for s in span_scores if s >= token_logp_threshold) / float(len(span_scores))
        except Exception:
            pass

        return CTCScore(
            model_id=self.model_id,
            loss=loss_value,
            text=text,
            unk_ratio=unk_ratio,
            target_len=int(target_lengths.item()),
            token_score_mean=token_score_mean,
            token_score_min=token_score_min,
            token_coverage=token_coverage,
            token_logp_threshold=token_logp_threshold,
            span_count=span_count,
            span_mismatch=span_mismatch,
        )

    def score(
        self,
        audio_path: Path,
        text: str,
        lang: str | None = None,
        *,
        max_unk_ratio: float = 0.2,
        token_logp_threshold: float = -3.0,
    ) -> CTCScore:
        log_probs, input_lengths = self._compute_log_probs(audio_path)
        return self._score_from_log_probs(
            log_probs=log_probs,
            input_lengths=input_lengths,
            text=text,
            lang=lang,
            max_unk_ratio=max_unk_ratio,
            token_logp_threshold=token_logp_threshold,
        )

    def score_candidates(
        self,
        *,
        audio_path: Path,
        candidates: dict[str, str],
        lang: str | None,
        max_unk_ratio: float,
        token_logp_threshold: float,
    ) -> tuple[list[tuple[str, CTCScore]], dict[str, str]]:
        log_probs, input_lengths = self._compute_log_probs(audio_path)

        scored: list[tuple[str, CTCScore]] = []
        errors: dict[str, str] = {}

        for key, text in candidates.items():
            text = (text or "").strip()
            if not text:
                continue
            try:
                score = self._score_from_log_probs(
                    log_probs=log_probs,
                    input_lengths=input_lengths,
                    text=text,
                    lang=lang,
                    max_unk_ratio=max_unk_ratio,
                    token_logp_threshold=token_logp_threshold,
                )
                scored.append((key, score))
            except Exception as e:
                errors[key] = f"{type(e).__name__}:{e}"

        return scored, errors

class CTCFitScorer:
    """
    Cache-loaded CTC scoring models for audio-grounded transcript fit.
    """

    def __init__(
        self,
        model_map: dict[str, str] | None = None,
        default_model: str | None = None,
        *,
        max_unk_ratio: float = 0.2,
        token_logp_threshold: float = -3.0,
    ) -> None:
        self.model_map = model_map or {}
        self.default_model = default_model
        self.max_unk_ratio = float(max_unk_ratio)
        self.token_logp_threshold = float(token_logp_threshold)
        self._cache: dict[str, _CTCModel] = {}

    def _get_model_id(self, lang: str | None) -> str | None:
        if lang and lang in self.model_map:
            return self.model_map[lang]
        return self.default_model

    def score(self, audio_path: Path, text: str, lang: str | None) -> CTCScore | None:
        model_id = self._get_model_id(lang)
        if not model_id:
            return None

        model = self._cache.get(model_id)
        if model is None:
            model = _CTCModel(model_id=model_id)
            self._cache[model_id] = model

        return model.score(
            audio_path=audio_path,
            text=text,
            lang=lang,
            max_unk_ratio=self.max_unk_ratio,
            token_logp_threshold=self.token_logp_threshold,
        )

    def score_candidates(
        self, audio_path: Path, candidates: dict[str, str], lang: str | None
    ) -> tuple[list[tuple[str, CTCScore]], dict[str, str]] | None:
        model_id = self._get_model_id(lang)
        if not model_id:
            return None

        model = self._cache.get(model_id)
        if model is None:
            model = _CTCModel(model_id=model_id)
            self._cache[model_id] = model

        return model.score_candidates(
            audio_path=audio_path,
            candidates=candidates,
            lang=lang,
            max_unk_ratio=self.max_unk_ratio,
            token_logp_threshold=self.token_logp_threshold,
        )

def rank_candidates_by_ctc(
    scorer: CTCFitScorer, audio_path: Path, candidates: dict[str, str], lang: str | None
) -> dict[str, Any]:
    scored: list[tuple[str, CTCScore]] = []
    errors: dict[str, str] = {}

    batch = scorer.score_candidates(audio_path=audio_path, candidates=candidates, lang=lang)
    if batch is not None:
        scored, errors = batch

    scored.sort(key=lambda kv: kv[1].loss)
    best = scored[0] if scored else None

    # Find the best *different-text* alternative, so duplicates don't create a fake "zero margin".
    second = None
    if best:
        best_text = best[1].text
        for item in scored[1:]:
            if item[1].text != best_text:
                second = item
                break

    margin = (second[1].loss - best[1].loss) if (best and second) else None

    return {
        "model_id": best[1].model_id if best else None,
        "best_key": best[0] if best else None,
        "best_loss": best[1].loss if best else None,
        "second_best_key": second[0] if second else None,
        "second_best_loss": second[1].loss if second else None,
        "margin": float(margin) if margin is not None else None,
        "scores": {
            k: {
                "loss": s.loss,
                "model_id": s.model_id,
                "unk_ratio": s.unk_ratio,
                "target_len": s.target_len,
                "token_score_mean": s.token_score_mean,
                "token_score_min": s.token_score_min,
                "token_coverage": s.token_coverage,
                "token_logp_threshold": s.token_logp_threshold,
                "span_count": s.span_count,
                "span_mismatch": s.span_mismatch,
            }
            for k, s in scored
        },
        "errors": errors,
    }


     1→from __future__ import annotations
     2→
     3→import unicodedata
     4→
     5→
     6→_ZERO_WIDTH = {
     7→    "\u200b",  # zero width space
     8→    "\u200c",  # zero width non-joiner
     9→    "\u200d",  # zero width joiner
    10→    "\ufeff",  # byte order mark
    11→}
    12→
    13→
    14→def normalize_text(text: str, lang: str | None = None) -> str:
    15→    """
    16→    Conservative normalization for hypothesis alignment/comparison (not for publishing).
    17→    - Unicode NFKC
    18→    - Drop punctuation
    19→    - Normalize all Unicode digits to ASCII
    20→    - Collapse whitespace
    21→    - Lowercase for English only
    22→    """
    23→    if not text:
    24→        return ""
    25→
    26→    text = unicodedata.normalize("NFKC", text)
    27→
    28→    out_chars: list[str] = []
    29→    for ch in text:
    30→        if ch in _ZERO_WIDTH:
    31→            continue
    32→
    33→        cat = unicodedata.category(ch)
    34→        if cat.startswith("P"):
    35→            continue
    36→
    37→        if cat == "Zs" or ch in ("\t", "\n", "\r"):
    38→            out_chars.append(" ")
    39→            continue
    40→
    41→        if cat == "Nd":
    42→            try:
    43→                out_chars.append(str(unicodedata.digit(ch)))
    44→                continue
    45→            except (TypeError, ValueError):
    46→                pass
    47→
    48→        out_chars.append(ch)
    49→
    50→    norm = "".join(out_chars)
    51→    norm = " ".join(norm.split())
    52→
    53→    if (lang or "").lower() in {"en", "eng"}:
    54→        norm = norm.lower()
    55→
    56→    return norm
    57→
    58→
    59→def tokenize_words(text: str) -> list[str]:
    60→    text = " ".join((text or "").split())
    61→    if not text:
    62→        return []
    63→    return text.split(" ")
    64→
    65→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

from __future__ import annotations

import unicodedata

_ZERO_WIDTH = {
    "\u200b",  # zero width space
    "\u200c",  # zero width non-joiner
    "\u200d",  # zero width joiner
    "\ufeff",  # byte order mark
}

def normalize_text(text: str, lang: str | None = None) -> str:
    """
    Conservative normalization for hypothesis alignment/comparison (not for publishing).
    - Unicode NFKC
    - Drop punctuation
    - Normalize all Unicode digits to ASCII
    - Collapse whitespace
    - Lowercase for English only
    """
    if not text:
        return ""

    text = unicodedata.normalize("NFKC", text)

    out_chars: list[str] = []
    for ch in text:
        if ch in _ZERO_WIDTH:
            continue

        cat = unicodedata.category(ch)
        if cat.startswith("P"):
            continue

        if cat == "Zs" or ch in ("\t", "\n", "\r"):
            out_chars.append(" ")
            continue

        if cat == "Nd":
            try:
                out_chars.append(str(unicodedata.digit(ch)))
                continue
            except (TypeError, ValueError):
                pass

        out_chars.append(ch)

    norm = "".join(out_chars)
    norm = " ".join(norm.split())

    if (lang or "").lower() in {"en", "eng"}:
        norm = norm.lower()

    return norm

def tokenize_words(text: str) -> list[str]:
    text = " ".join((text or "").split())
    if not text:
        return []
    return text.split(" ")