flexaligner 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ """Public package surface for the clean FlexAligner rebuild."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("flexaligner")
9
+ except PackageNotFoundError:
10
+ __version__ = "0+unknown"
11
+
12
+ from .api import FlexAligner
13
+ from .capabilities import (
14
+ Capability,
15
+ CapabilityId,
16
+ CapabilityReport,
17
+ CapabilityStatus,
18
+ get_capabilities,
19
+ )
20
+ from .contracts import (
21
+ AlignmentOptions,
22
+ AlignmentRequest,
23
+ AlignmentResult,
24
+ AudioPolicy,
25
+ CalibrationMode,
26
+ ChunkResult,
27
+ Device,
28
+ Language,
29
+ LocalModelBundle,
30
+ ModelResolution,
31
+ PhoneInterval,
32
+ PronunciationMode,
33
+ ResourceLimits,
34
+ RunProvenance,
35
+ Score,
36
+ ScoreKind,
37
+ TextGridOutput,
38
+ WordInterval,
39
+ )
40
+ from .errors import (
41
+ AlignmentError,
42
+ ArtifactExistsError,
43
+ AudioFormatError,
44
+ ConfigurationError,
45
+ EngineClosedError,
46
+ ErrorCode,
47
+ FeatureNotAvailableError,
48
+ FlexAlignerError,
49
+ InputValidationError,
50
+ InternalError,
51
+ ModelCompatibilityError,
52
+ ModelValidationError,
53
+ OutputError,
54
+ OutputValidationError,
55
+ ResourceLimitError,
56
+ UnreachableAlignmentError,
57
+ )
58
+
59
+ __all__ = [
60
+ "AlignmentError",
61
+ "AlignmentOptions",
62
+ "AlignmentRequest",
63
+ "AlignmentResult",
64
+ "ArtifactExistsError",
65
+ "AudioFormatError",
66
+ "AudioPolicy",
67
+ "CalibrationMode",
68
+ "Capability",
69
+ "CapabilityId",
70
+ "CapabilityReport",
71
+ "CapabilityStatus",
72
+ "ChunkResult",
73
+ "ConfigurationError",
74
+ "Device",
75
+ "EngineClosedError",
76
+ "ErrorCode",
77
+ "FeatureNotAvailableError",
78
+ "FlexAligner",
79
+ "FlexAlignerError",
80
+ "InputValidationError",
81
+ "InternalError",
82
+ "Language",
83
+ "LocalModelBundle",
84
+ "ModelCompatibilityError",
85
+ "ModelResolution",
86
+ "ModelValidationError",
87
+ "OutputError",
88
+ "OutputValidationError",
89
+ "PhoneInterval",
90
+ "PronunciationMode",
91
+ "ResourceLimitError",
92
+ "ResourceLimits",
93
+ "RunProvenance",
94
+ "Score",
95
+ "ScoreKind",
96
+ "TextGridOutput",
97
+ "UnreachableAlignmentError",
98
+ "WordInterval",
99
+ "__version__",
100
+ "get_capabilities",
101
+ ]
@@ -0,0 +1,7 @@
1
+ """Support ``python -m flexaligner``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Lazy local adapters for strict input and inference boundaries."""
@@ -0,0 +1,548 @@
1
+ """Offline, CPU-only Hugging Face CTC inference adapter.
2
+
3
+ The optional inference dependencies are deliberately imported only while a
4
+ session context is being entered. Importing :mod:`flexaligner` therefore does
5
+ not import PyTorch or Transformers and cannot trigger model discovery.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gc
11
+ import importlib
12
+ import math
13
+ import threading
14
+ import traceback
15
+ from collections.abc import Iterator, Mapping
16
+ from contextlib import AbstractContextManager, contextmanager
17
+ from pathlib import Path
18
+ from types import MappingProxyType, ModuleType
19
+ from typing import Any, cast
20
+
21
+ import numpy as np
22
+
23
+ from flexaligner.errors import AlignmentError, ModelCompatibilityError, ModelValidationError
24
+ from flexaligner.ports import CtcPosterior, CtcSessionPort, Float32Array
25
+
26
+ _TARGET_SAMPLE_RATE = 16_000
27
+ _ALIGNER_NOMINAL_STRIDE_SAMPLES = 160
28
+
29
+
30
+ class _LocalCtcSession:
31
+ """One validated, explicitly disposable CTC model session."""
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ kind: str,
37
+ torch_module: ModuleType,
38
+ processor: Any,
39
+ tokenizer: Any,
40
+ model: Any,
41
+ vocabulary: Mapping[str, int],
42
+ model_vocab_size: int,
43
+ sample_rate: int,
44
+ pad_token: str | None,
45
+ ) -> None:
46
+ self._kind = kind
47
+ self._torch: ModuleType | None = torch_module
48
+ self._processor: Any | None = processor
49
+ self._tokenizer: Any | None = tokenizer
50
+ self._model: Any | None = model
51
+ self._vocabulary: Mapping[str, int] | None = MappingProxyType(dict(vocabulary))
52
+ self._model_vocab_size: int | None = model_vocab_size
53
+ self._sample_rate: int | None = sample_rate
54
+ self._pad_token = pad_token
55
+
56
+ def _require_open(self) -> None:
57
+ if self._model is None or self._processor is None or self._torch is None:
58
+ raise AlignmentError(
59
+ f"The {self._kind} inference session is closed.",
60
+ context={"session": self._kind},
61
+ )
62
+
63
+ @property
64
+ def vocabulary(self) -> Mapping[str, int]:
65
+ self._require_open()
66
+ assert self._vocabulary is not None
67
+ return self._vocabulary
68
+
69
+ @property
70
+ def model_vocab_size(self) -> int:
71
+ self._require_open()
72
+ assert self._model_vocab_size is not None
73
+ return self._model_vocab_size
74
+
75
+ @property
76
+ def sample_rate(self) -> int:
77
+ self._require_open()
78
+ assert self._sample_rate is not None
79
+ return self._sample_rate
80
+
81
+ @property
82
+ def pad_token(self) -> str | None:
83
+ self._require_open()
84
+ return self._pad_token
85
+
86
+ def infer(self, audio: Float32Array, sample_rate: int) -> CtcPosterior:
87
+ """Return finite contiguous float32 log probabilities for one waveform."""
88
+
89
+ self._require_open()
90
+ if not isinstance(audio, np.ndarray):
91
+ raise AlignmentError(
92
+ "Audio must be a NumPy ndarray.",
93
+ context={"session": self._kind},
94
+ )
95
+ if audio.dtype != np.dtype(np.float32):
96
+ raise AlignmentError(
97
+ "Audio must have dtype float32.",
98
+ context={"session": self._kind, "dtype": str(audio.dtype)},
99
+ )
100
+ if audio.ndim != 1:
101
+ raise AlignmentError(
102
+ "Audio must be one-dimensional.",
103
+ context={"session": self._kind, "ndim": int(audio.ndim)},
104
+ )
105
+ if audio.size <= 0:
106
+ raise AlignmentError(
107
+ "Audio must not be empty.",
108
+ context={"session": self._kind},
109
+ )
110
+ if not bool(np.isfinite(audio).all()):
111
+ raise AlignmentError(
112
+ "Audio contains NaN or infinity.",
113
+ context={"session": self._kind},
114
+ )
115
+ if type(sample_rate) is not int or sample_rate != _TARGET_SAMPLE_RATE:
116
+ raise AlignmentError(
117
+ f"Inference requires {_TARGET_SAMPLE_RATE} Hz audio.",
118
+ context={"session": self._kind, "sample_rate": sample_rate},
119
+ )
120
+
121
+ torch_module = cast(Any, self._torch)
122
+ processor = cast(Any, self._processor)
123
+ model = cast(Any, self._model)
124
+ cpu = torch_module.device("cpu")
125
+ waveform = np.ascontiguousarray(audio, dtype=np.float32)
126
+
127
+ try:
128
+ with torch_module.inference_mode():
129
+ encoded = processor(
130
+ waveform,
131
+ sampling_rate=sample_rate,
132
+ return_tensors="pt",
133
+ )
134
+ if not isinstance(encoded, Mapping) or not encoded:
135
+ raise AlignmentError(
136
+ "Processor returned an empty or invalid input mapping.",
137
+ context={"session": self._kind},
138
+ )
139
+
140
+ inputs: dict[str, Any] = {}
141
+ for name, value in encoded.items():
142
+ if not isinstance(name, str):
143
+ raise AlignmentError(
144
+ "Processor input names must be strings.",
145
+ context={"session": self._kind},
146
+ )
147
+ move_to_cpu = getattr(value, "to", None)
148
+ if not callable(move_to_cpu):
149
+ raise AlignmentError(
150
+ "Processor returned a value that cannot be moved to CPU.",
151
+ context={"session": self._kind, "input": name},
152
+ )
153
+ inputs[name] = move_to_cpu(cpu)
154
+
155
+ output = model(**inputs)
156
+ logits = getattr(output, "logits", None)
157
+ logits_array = _tensor_to_numpy(logits, session_kind=self._kind)
158
+ if logits_array.ndim != 3 or logits_array.shape[0] != 1:
159
+ raise AlignmentError(
160
+ "Model logits must have shape [1, T, V].",
161
+ context={"session": self._kind, "shape": str(logits_array.shape)},
162
+ )
163
+ if logits_array.shape[1] <= 0 or logits_array.shape[2] <= 0:
164
+ raise AlignmentError(
165
+ "Model logits must have positive time and vocabulary dimensions.",
166
+ context={"session": self._kind, "shape": str(logits_array.shape)},
167
+ )
168
+ if logits_array.shape[2] != self.model_vocab_size:
169
+ raise ModelCompatibilityError(
170
+ "Model output vocabulary does not match config.vocab_size.",
171
+ context={
172
+ "session": self._kind,
173
+ "logits_vocab_size": int(logits_array.shape[2]),
174
+ "model_vocab_size": self.model_vocab_size,
175
+ },
176
+ )
177
+ if not bool(np.isfinite(logits_array).all()):
178
+ raise AlignmentError(
179
+ "Model logits contain NaN or infinity.",
180
+ context={"session": self._kind},
181
+ )
182
+
183
+ log_probs_tensor = torch_module.log_softmax(logits, dim=-1)
184
+ log_probs_array = _tensor_to_numpy(
185
+ log_probs_tensor,
186
+ session_kind=self._kind,
187
+ )[0]
188
+ except (AlignmentError, ModelCompatibilityError):
189
+ raise
190
+ except Exception as exc:
191
+ raise AlignmentError(
192
+ f"{self._kind.capitalize()} inference failed.",
193
+ context={"session": self._kind},
194
+ ) from exc
195
+
196
+ log_probs = np.ascontiguousarray(log_probs_array, dtype=np.float32)
197
+ if log_probs.ndim != 2 or log_probs.shape[0] <= 0:
198
+ raise AlignmentError(
199
+ "Log probabilities must have shape [T, V] with T > 0.",
200
+ context={"session": self._kind, "shape": str(log_probs.shape)},
201
+ )
202
+ if log_probs.shape[1] != self.model_vocab_size:
203
+ raise ModelCompatibilityError(
204
+ "Log-probability vocabulary does not match config.vocab_size.",
205
+ context={
206
+ "session": self._kind,
207
+ "log_probs_vocab_size": int(log_probs.shape[1]),
208
+ "model_vocab_size": self.model_vocab_size,
209
+ },
210
+ )
211
+ if not bool(np.isfinite(log_probs).all()):
212
+ raise AlignmentError(
213
+ "Model produced non-finite log probabilities.",
214
+ context={"session": self._kind},
215
+ )
216
+
217
+ seconds_per_frame = (float(audio.size) / float(sample_rate)) / float(log_probs.shape[0])
218
+ if seconds_per_frame <= 0.0 or not math.isfinite(seconds_per_frame):
219
+ raise AlignmentError(
220
+ "Could not derive a finite positive frame duration.",
221
+ context={"session": self._kind},
222
+ )
223
+ return CtcPosterior(log_probs=log_probs, seconds_per_frame=seconds_per_frame)
224
+
225
+ def close(self) -> None:
226
+ """Sever every heavyweight reference, including on exceptional exits."""
227
+
228
+ self._model = None
229
+ self._processor = None
230
+ self._tokenizer = None
231
+ self._torch = None
232
+ self._vocabulary = None
233
+ self._model_vocab_size = None
234
+ self._sample_rate = None
235
+ self._pad_token = None
236
+
237
+
238
+ def _tensor_to_numpy(value: Any, *, session_kind: str) -> np.ndarray[Any, Any]:
239
+ if value is None:
240
+ raise AlignmentError(
241
+ "Model output has no logits tensor.",
242
+ context={"session": session_kind},
243
+ )
244
+ try:
245
+ detached = value.detach()
246
+ on_cpu = detached.cpu()
247
+ return np.asarray(on_cpu.numpy())
248
+ except Exception as exc:
249
+ raise AlignmentError(
250
+ "Model output could not be converted to a CPU NumPy array.",
251
+ context={"session": session_kind},
252
+ ) from exc
253
+
254
+
255
+ class LocalHuggingFaceInferenceFactory:
256
+ """Create mutually exclusive, local-only Chunker and Aligner sessions."""
257
+
258
+ def __init__(self) -> None:
259
+ self._state_lock = threading.Lock()
260
+ self._active_kind: str | None = None
261
+ self._active_session: _LocalCtcSession | None = None
262
+
263
+ def chunker_session(
264
+ self,
265
+ model_dir: Path,
266
+ *,
267
+ num_threads: int,
268
+ ) -> AbstractContextManager[CtcSessionPort]:
269
+ return self._open_session("chunker", model_dir, num_threads=num_threads)
270
+
271
+ def aligner_session(
272
+ self,
273
+ model_dir: Path,
274
+ *,
275
+ num_threads: int,
276
+ ) -> AbstractContextManager[CtcSessionPort]:
277
+ return self._open_session("aligner", model_dir, num_threads=num_threads)
278
+
279
+ @contextmanager
280
+ def _open_session(
281
+ self,
282
+ kind: str,
283
+ model_dir: Path,
284
+ *,
285
+ num_threads: int,
286
+ ) -> Iterator[CtcSessionPort]:
287
+ session: _LocalCtcSession | None = None
288
+ claimed = False
289
+ try:
290
+ if type(num_threads) is not int or num_threads <= 0:
291
+ raise ModelValidationError(
292
+ "num_threads must be a positive integer.",
293
+ context={"session": kind, "num_threads": num_threads},
294
+ )
295
+ try:
296
+ path = Path(model_dir)
297
+ except Exception as exc:
298
+ raise ModelValidationError(
299
+ "Model path must be path-like.",
300
+ context={"session": kind},
301
+ ) from exc
302
+ if not path.is_dir():
303
+ raise ModelValidationError(
304
+ "Model path must be an existing local directory.",
305
+ context={"session": kind, "model_dir": str(path)},
306
+ )
307
+
308
+ with self._state_lock:
309
+ if self._active_kind is not None:
310
+ raise ModelValidationError(
311
+ "Chunker and Aligner sessions may not overlap.",
312
+ context={"active_session": self._active_kind, "requested_session": kind},
313
+ )
314
+ self._active_kind = kind
315
+ claimed = True
316
+
317
+ try:
318
+ torch_module, transformers_module = _import_inference_dependencies(kind)
319
+ try:
320
+ torch_api = cast(Any, torch_module)
321
+ torch_api.set_num_threads(num_threads)
322
+ session = _load_local_session(
323
+ kind=kind,
324
+ model_dir=path,
325
+ torch_module=torch_module,
326
+ transformers_module=transformers_module,
327
+ )
328
+ finally:
329
+ del torch_api, torch_module, transformers_module
330
+ except (ModelValidationError, ModelCompatibilityError, AlignmentError):
331
+ raise
332
+ except Exception as exc:
333
+ raise ModelValidationError(
334
+ f"Could not initialize the local {kind} model.",
335
+ context={"session": kind, "model_dir": str(model_dir)},
336
+ ) from exc
337
+ with self._state_lock:
338
+ self._active_session = session
339
+ yield session
340
+ finally:
341
+ if session is not None:
342
+ session.close()
343
+ session = None
344
+ if claimed:
345
+ with self._state_lock:
346
+ self._active_session = None
347
+ self._active_kind = None
348
+ gc.collect()
349
+
350
+
351
+ def _import_inference_dependencies(kind: str) -> tuple[ModuleType, ModuleType]:
352
+ try:
353
+ torch_module = importlib.import_module("torch")
354
+ transformers_module = importlib.import_module("transformers")
355
+ except Exception as exc:
356
+ raise ModelValidationError(
357
+ "Local inference requires the 'inference' optional dependencies.",
358
+ context={"session": kind, "extra": "inference"},
359
+ ) from exc
360
+ return torch_module, transformers_module
361
+
362
+
363
+ def _load_local_session(
364
+ *,
365
+ kind: str,
366
+ model_dir: Path,
367
+ torch_module: ModuleType,
368
+ transformers_module: ModuleType,
369
+ ) -> _LocalCtcSession:
370
+ """Load a session without retaining heavyweight locals on failure tracebacks."""
371
+
372
+ try:
373
+ return _load_local_session_impl(
374
+ kind=kind,
375
+ model_dir=model_dir,
376
+ torch_module=torch_module,
377
+ transformers_module=transformers_module,
378
+ )
379
+ except BaseException as exc:
380
+ _clear_exception_traceback_frames(exc)
381
+ raise
382
+ finally:
383
+ del torch_module, transformers_module
384
+
385
+
386
+ def _clear_exception_traceback_frames(exc: BaseException) -> None:
387
+ """Clear completed traceback frames while preserving the typed cause chain."""
388
+
389
+ seen: set[int] = set()
390
+ current: BaseException | None = exc
391
+ while current is not None and id(current) not in seen:
392
+ seen.add(id(current))
393
+ traceback.clear_frames(current.__traceback__)
394
+ current = current.__cause__ if current.__cause__ is not None else current.__context__
395
+
396
+
397
+ def _load_local_session_impl(
398
+ *,
399
+ kind: str,
400
+ model_dir: Path,
401
+ torch_module: ModuleType,
402
+ transformers_module: ModuleType,
403
+ ) -> _LocalCtcSession:
404
+ transformers_api = cast(Any, transformers_module)
405
+ torch_api = cast(Any, torch_module)
406
+ load_options = {"local_files_only": True, "trust_remote_code": False}
407
+ try:
408
+ processor = transformers_api.AutoProcessor.from_pretrained(
409
+ str(model_dir),
410
+ **load_options,
411
+ )
412
+ model = transformers_api.AutoModelForCTC.from_pretrained(
413
+ str(model_dir),
414
+ **load_options,
415
+ )
416
+ cpu = torch_api.device("cpu")
417
+ moved_model = model.to(cpu)
418
+ if moved_model is not None:
419
+ model = moved_model
420
+ evaluated_model = model.eval()
421
+ if evaluated_model is not None:
422
+ model = evaluated_model
423
+ except Exception as exc:
424
+ raise ModelValidationError(
425
+ f"Could not load the local {kind} Hugging Face bundle.",
426
+ context={"session": kind, "model_dir": str(model_dir)},
427
+ ) from exc
428
+
429
+ feature_extractor = getattr(processor, "feature_extractor", None)
430
+ sample_rate = getattr(feature_extractor, "sampling_rate", None)
431
+ if type(sample_rate) is not int:
432
+ raise ModelValidationError(
433
+ "Processor feature_extractor.sampling_rate must be an integer.",
434
+ context={"session": kind, "sample_rate": str(sample_rate)},
435
+ )
436
+ if sample_rate != _TARGET_SAMPLE_RATE:
437
+ raise ModelCompatibilityError(
438
+ f"Processor must require {_TARGET_SAMPLE_RATE} Hz audio.",
439
+ context={"session": kind, "sample_rate": sample_rate},
440
+ )
441
+
442
+ if kind == "aligner":
443
+ _validate_aligner_nominal_stride(model)
444
+
445
+ tokenizer = getattr(processor, "tokenizer", None)
446
+ get_vocab = getattr(tokenizer, "get_vocab", None)
447
+ if not callable(get_vocab):
448
+ raise ModelValidationError(
449
+ "Processor tokenizer must provide get_vocab().",
450
+ context={"session": kind},
451
+ )
452
+ try:
453
+ raw_vocabulary = get_vocab()
454
+ except Exception as exc:
455
+ raise ModelValidationError(
456
+ "Tokenizer get_vocab() failed.",
457
+ context={"session": kind},
458
+ ) from exc
459
+ vocabulary = _validate_vocabulary(raw_vocabulary, kind=kind)
460
+
461
+ model_vocab_size = getattr(getattr(model, "config", None), "vocab_size", None)
462
+ if type(model_vocab_size) is not int or model_vocab_size <= 0:
463
+ raise ModelValidationError(
464
+ "Model config.vocab_size must be a positive integer.",
465
+ context={"session": kind, "model_vocab_size": str(model_vocab_size)},
466
+ )
467
+ if any(token_id >= model_vocab_size for token_id in vocabulary.values()):
468
+ raise ModelCompatibilityError(
469
+ "Tokenizer vocabulary contains an id outside model config.vocab_size.",
470
+ context={"session": kind, "model_vocab_size": model_vocab_size},
471
+ )
472
+
473
+ pad_token = getattr(tokenizer, "pad_token", None)
474
+ if pad_token is not None and not isinstance(pad_token, str):
475
+ raise ModelValidationError(
476
+ "Tokenizer pad_token must be a string or None.",
477
+ context={"session": kind},
478
+ )
479
+
480
+ return _LocalCtcSession(
481
+ kind=kind,
482
+ torch_module=torch_module,
483
+ processor=processor,
484
+ tokenizer=tokenizer,
485
+ model=model,
486
+ vocabulary=vocabulary,
487
+ model_vocab_size=model_vocab_size,
488
+ sample_rate=sample_rate,
489
+ pad_token=pad_token,
490
+ )
491
+
492
+
493
+ def _validate_aligner_nominal_stride(model: Any) -> None:
494
+ """Require the reviewed 10 ms Aligner convolution stride at 16 kHz."""
495
+
496
+ raw_stride = getattr(getattr(model, "config", None), "conv_stride", None)
497
+ if not isinstance(raw_stride, (list, tuple)) or not raw_stride:
498
+ raise ModelValidationError(
499
+ "Aligner model config.conv_stride must be a non-empty list or tuple.",
500
+ context={"session": "aligner", "conv_stride": str(raw_stride)},
501
+ )
502
+
503
+ stride: list[int] = []
504
+ for value in raw_stride:
505
+ if type(value) is not int or value <= 0:
506
+ raise ModelValidationError(
507
+ "Aligner model config.conv_stride must contain positive integers.",
508
+ context={"session": "aligner", "conv_stride": str(raw_stride)},
509
+ )
510
+ stride.append(value)
511
+
512
+ nominal_stride_samples = math.prod(stride)
513
+ if nominal_stride_samples != _ALIGNER_NOMINAL_STRIDE_SAMPLES:
514
+ raise ModelCompatibilityError(
515
+ "Aligner model nominal convolution stride must be 160 samples (10 ms at 16 kHz).",
516
+ context={
517
+ "session": "aligner",
518
+ "conv_stride": str(stride),
519
+ "nominal_stride_samples": nominal_stride_samples,
520
+ "required_stride_samples": _ALIGNER_NOMINAL_STRIDE_SAMPLES,
521
+ "sample_rate": _TARGET_SAMPLE_RATE,
522
+ },
523
+ )
524
+
525
+
526
+ def _validate_vocabulary(value: Any, *, kind: str) -> dict[str, int]:
527
+ if not isinstance(value, Mapping) or not value:
528
+ raise ModelValidationError(
529
+ "Tokenizer get_vocab() must return a non-empty mapping.",
530
+ context={"session": kind},
531
+ )
532
+
533
+ vocabulary: dict[str, int] = {}
534
+ ids: set[int] = set()
535
+ for token, token_id in value.items():
536
+ if not isinstance(token, str) or type(token_id) is not int or token_id < 0:
537
+ raise ModelValidationError(
538
+ "Tokenizer vocabulary must map strings to non-negative integer ids.",
539
+ context={"session": kind},
540
+ )
541
+ if token_id in ids:
542
+ raise ModelValidationError(
543
+ "Tokenizer vocabulary ids must be unique.",
544
+ context={"session": kind, "token_id": token_id},
545
+ )
546
+ vocabulary[token] = token_id
547
+ ids.add(token_id)
548
+ return vocabulary