LCOV - code coverage report
Current view: top level - src - xlmr_tokenizer.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 88.0 % 25 22
Test Date: 2026-07-09 07:56:57 Functions: - 0 0

            Line data    Source code
       1              : // Copyright 2026 The Authors.
       2              : //
       3              : // Licensed under the Apache License, Version 2.0 (the "License");
       4              : // you may not use this file except in compliance with the License.
       5              : // You may obtain a copy of the License at
       6              : //
       7              : //     https://www.apache.org/licenses/LICENSE-2.0
       8              : //
       9              : // Unless required by applicable law or agreed to in writing, software
      10              : // distributed under the License is distributed on an "AS IS" BASIS,
      11              : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12              : // See the License for the specific language governing permissions and
      13              : // limitations under the License.
      14              : 
      15              : import 'dart:convert';
      16              : import 'dart:io';
      17              : import 'dart:typed_data';
      18              : 
      19              : import 'package:dart_sentencepiece_tokenizer/dart_sentencepiece_tokenizer.dart';
      20              : import 'package:meta/meta.dart';
      21              : 
      22              : import 'bert_tokenizer.dart' show TokenizerOutput;
      23              : import 'charsmap_trie.dart';
      24              : import 'model_tokenizer.dart' show ModelTokenizer;
      25              : 
      26              : /// XLM-RoBERTa-family SentencePiece/Unigram tokenizer, e.g. for
      27              : /// `multilingual-e5-small`.
      28              : ///
      29              : /// Composes a from-scratch [CharsmapTrie] normalizer (the one piece of
      30              : /// SentencePiece's pipeline this project could not find a working Dart
      31              : /// implementation of anywhere — see `CharsmapTrie`'s own doc comment) with
      32              : /// `dart_sentencepiece_tokenizer`'s public API for everything else: vocab
      33              : /// loading, Unigram Viterbi decoding, and BOS/EOS post-processing.
      34              : ///
      35              : /// ## Why this class exists instead of using `dart_sentencepiece_tokenizer`
      36              : /// directly
      37              : ///
      38              : /// `dart_sentencepiece_tokenizer` parses HuggingFace `tokenizer.json`'s
      39              : /// `Precompiled` normalizer type (the charsmap trie bytes) but never applies
      40              : /// it — `HuggingFaceTokenizerLoader`'s JSON-loading path silently drops the
      41              : /// charsmap, making its own `SpNormalizer` an unconditional
      42              : /// (charsmap-less) pass-through for models like `multilingual-e5-small`.
      43              : /// See this package's `README.md` ("Why not `dart_sentencepiece_tokenizer`
      44              : /// alone") for the full write-up of this and a second, independent defect
      45              : /// this class also works around.
      46              : ///
      47              : /// [SentencePieceTokenizer]'s real constructor is private and every public
      48              : /// factory routes through internal, non-injectable construction — there is
      49              : /// no way to subclass or swap in a corrected normalizer. Composition (running
      50              : /// our own charsmap normalization *before* handing text to the library's
      51              : /// public `encode()`) is therefore the only viable integration seam, and is
      52              : /// what [encode] does.
      53              : ///
      54              : /// ## Pipeline
      55              : ///
      56              : /// [encode] applies, in order:
      57              : ///
      58              : /// 1. [CharsmapTrie.normalize] — the charsmap substitution described above.
      59              : /// 2. Whitespace-run collapse (two-or-more plain spaces → one).
      60              : /// 3. Metaspace escaping (prepend a leading space if absent; replace every
      61              : ///    space with `▁`, U+2581).
      62              : /// 4. `dart_sentencepiece_tokenizer`'s own `SentencePieceTokenizer.encode()`.
      63              : ///
      64              : /// **Why steps 2–3 are done manually here rather than trusted to the
      65              : /// library's own `SpNormalizer`:** `dart_sentencepiece_tokenizer`'s
      66              : /// HuggingFace-JSON metadata parser derives its
      67              : /// `addDummyPrefix`/`removeExtraWhitespaces`/`escapeWhitespaces` flags by
      68              : /// pattern-matching the `normalizer` section of `tokenizer.json`, but
      69              : /// `multilingual-e5-small`'s `tokenizer.json` puts this configuration
      70              : /// entirely in `pre_tokenizer` (`{"type": "Metaspace", "replacement": "▁",
      71              : /// "add_prefix_space": true}`) instead — a section the parser never reads.
      72              : /// All three flags therefore come back `false`, and the library's own
      73              : /// whitespace/prefix handling silently no-ops for this file. This is a
      74              : /// second, independent defect from the already-known charsmap-drop one
      75              : /// (confirmed empirically: feeding the library literal pre-escaped text like
      76              : /// `"▁Hello"` produces the correct single-token id, while plain `"Hello"` or
      77              : /// `" Hello"` does not) — so this class must own both normalization
      78              : /// concerns, not just the charsmap.
      79              : ///
      80              : /// Implements [ModelTokenizer] so [OnnxEmbeddingModel] can select this
      81              : /// tokenizer at runtime alongside [BertTokenizer], both sharing the same
      82              : /// [TokenizerOutput] return shape.
      83              : class XlmRobertaTokenizer implements ModelTokenizer {
      84            0 :   XlmRobertaTokenizer._(this._charsmapTrie, this._tokenizer, this._maxLength);
      85              : 
      86              :   final CharsmapTrie _charsmapTrie;
      87              :   final SentencePieceTokenizer _tokenizer;
      88              :   final int _maxLength;
      89              : 
      90              :   /// Loads a tokenizer from a HuggingFace `tokenizer.json` file at
      91              :   /// [tokenizerJsonPath].
      92              :   ///
      93              :   /// Extracts the `Precompiled` normalizer's `precompiled_charsmap` bytes
      94              :   /// (`normalizer.normalizers[]`, `type: "Precompiled"`) to build a
      95              :   /// [CharsmapTrie], and separately passes the full JSON to
      96              :   /// `HuggingFaceTokenizerLoader.fromJsonString` to build the underlying
      97              :   /// [SentencePieceTokenizer] (vocabulary, Unigram model, BOS/EOS
      98              :   /// configuration).
      99              :   ///
     100              :   /// [maxLength] is the maximum sequence length, including the `<s>`/`</s>`
     101              :   /// sentinel tokens. Defaults to 512, matching both [BertTokenizer]'s own
     102              :   /// default and `multilingual-e5-small`'s published `max_seq_length` (the
     103              :   /// model's `tokenizer.json` itself declares no `truncation`/`padding`
     104              :   /// section, so there is no stronger signal to defer to).
     105              :   ///
     106              :   /// Throws [FormatException] if [tokenizerJsonPath]'s JSON has no
     107              :   /// `Precompiled` normalizer entry, or if its `precompiled_charsmap` bytes
     108              :   /// are malformed (see [CharsmapTrie.parse]).
     109            1 :   static Future<XlmRobertaTokenizer> load(
     110              :     String tokenizerJsonPath, {
     111              :     int maxLength = 512,
     112              :   }) async {
     113              :     // coverage:ignore-start
     114              :     // Requires the real ~17 MB multilingual-e5-small tokenizer.json (250k-entry
     115              :     // vocab) to exercise meaningfully — covered by the network-gated
     116              :     // integration test in integration_test_app/, not make coverage. See this
     117              :     // package's README for why that fixture isn't committed.
     118              :     final raw = await File(tokenizerJsonPath).readAsString();
     119              :     final tokenizerJson = jsonDecode(raw) as Map<String, dynamic>;
     120              :     final charsmapTrie = _extractCharsmapTrie(tokenizerJson);
     121              :     final tokenizer = HuggingFaceTokenizerLoader.fromJsonString(raw);
     122              :     return XlmRobertaTokenizer._(charsmapTrie, tokenizer, maxLength);
     123              :     // coverage:ignore-end
     124              :   }
     125              : 
     126              :   /// Extracts and parses the `Precompiled` normalizer's charsmap bytes from
     127              :   /// a parsed `tokenizer.json` map.
     128              :   ///
     129              :   /// Throws [FormatException] if no `Precompiled` normalizer entry is
     130              :   /// present (the `normalizer` section is expected to be a `Sequence` whose
     131              :   /// entries include one with `"type": "Precompiled"`, per
     132              :   /// `multilingual-e5-small`'s own `tokenizer.json` shape).
     133            1 :   static CharsmapTrie _extractCharsmapTrie(Map<String, dynamic> tokenizerJson) {
     134            1 :     final normalizerSection = tokenizerJson['normalizer'];
     135            1 :     final normalizers = normalizerSection is Map<String, dynamic>
     136            1 :         ? normalizerSection['normalizers']
     137              :         : null;
     138            1 :     if (normalizers is! List) {
     139              :       throw const FormatException(
     140              :         'tokenizer.json has no normalizer.normalizers list — expected a '
     141              :         'Sequence normalizer containing a Precompiled entry.',
     142              :       );
     143              :     }
     144              :     final precompiled = normalizers
     145            1 :         .cast<Map<String, dynamic>>()
     146            4 :         .where((n) => n['type'] == 'Precompiled')
     147            1 :         .firstOrNull;
     148            1 :     final charsmapB64 = precompiled?['precompiled_charsmap'];
     149            1 :     if (charsmapB64 is! String) {
     150              :       throw const FormatException(
     151              :         'tokenizer.json has no normalizer.normalizers[] entry with '
     152              :         'type "Precompiled" and a precompiled_charsmap field — this loader '
     153              :         'only supports XLM-RoBERTa-family tokenizer.json files that use a '
     154              :         'Precompiled charsmap normalizer.',
     155              :       );
     156              :     }
     157            2 :     return CharsmapTrie.parse(base64.decode(charsmapB64));
     158              :   }
     159              : 
     160              :   /// Encodes [text] into a [TokenizerOutput] ready for ONNX inference.
     161              :   ///
     162              :   /// Reuses the same [TokenizerOutput] type [BertTokenizer.encode] returns
     163              :   /// (not a parallel type), so both tokenizers already share an identical
     164              :   /// concrete output shape.
     165              :   ///
     166              :   /// - [TokenizerOutput.tokenTypeIds] is all-zeros: XLM-RoBERTa/RoBERTa
     167              :   ///   models don't use segment ids, but the field is required by
     168              :   ///   [TokenizerOutput] — this is a direct widen of
     169              :   ///   `Encoding.typeIds`, which is already all-zeros for single-segment
     170              :   ///   input in `dart_sentencepiece_tokenizer`, not invented data.
     171              :   /// - Padding uses the loaded vocabulary's own pad id (`1` for
     172              :   ///   `multilingual-e5-small`'s `<pad>`), sourced automatically by
     173              :   ///   `SentencePieceTokenizer` — not the BERT-specific `padId = 0`
     174              :   ///   [BertTokenizer] uses.
     175              :   /// - [TokenizerOutput.truncated] is derived via two encode passes (see
     176              :   ///   below), because `Encoding` exposes no overflow signal and a padded
     177              :   ///   output is always exactly [maxLength] tokens long regardless of
     178              :   ///   whether truncation actually occurred.
     179              :   ///
     180              :   /// All three output arrays have exactly the `maxLength` passed to [load]
     181              :   /// elements.
     182            0 :   @override
     183              :   TokenizerOutput encode(String text) {
     184            0 :     final normalizedText = normalizeForTokenization(_charsmapTrie, text);
     185              : 
     186              :     // coverage:ignore-start
     187              :     // Requires the real multilingual-e5-small vocabulary to exercise
     188              :     // meaningfully — covered by the network-gated integration test in
     189              :     // integration_test_app/, not make coverage.
     190              :     //
     191              :     // `truncated` derivation is a two-pass process. `enablePadding`/
     192              :     // `enableTruncation` mutate *persistent* instance state on the shared
     193              :     // `SentencePieceTokenizer` (they are not per-call arguments), so an
     194              :     // explicit noPadding()/noTruncation() reset is mandatory on *every* call
     195              :     // — without it, the "unbounded" first pass below would still be bounded
     196              :     // by whatever config a *previous* encode() call left in place, and
     197              :     // `truncated` would silently stick `false` after the first call.
     198              :     _tokenizer.noPadding();
     199              :     _tokenizer.noTruncation();
     200              : 
     201              :     // Pass 1 (unbounded): get the true token count with no truncation, to
     202              :     // detect whether pass 2's truncation will actually cut anything.
     203              :     final rawLength = _tokenizer
     204              :         .encode(normalizedText, addSpecialTokens: true)
     205              :         .ids
     206              :         .length;
     207              :     final truncated = rawLength > _maxLength;
     208              : 
     209              :     // Pass 2 (bounded): the real, padded/truncated result. Both passes use
     210              :     // identical addSpecialTokens: true so rawLength (which includes the
     211              :     // <s>/</s> sentinels) is compared on equal terms against the maxLength
     212              :     // the bounded pass truncates to.
     213              :     _tokenizer
     214              :       ..enablePadding(direction: SpPaddingDirection.right, length: _maxLength)
     215              :       ..enableTruncation(
     216              :         maxLength: _maxLength,
     217              :         direction: SpTruncationDirection.right,
     218              :       );
     219              :     final encoding = _tokenizer.encode(normalizedText, addSpecialTokens: true);
     220              : 
     221              :     return TokenizerOutput(
     222              :       inputIds: Int64List.fromList(encoding.ids),
     223              :       attentionMask: Int64List.fromList(encoding.attentionMask),
     224              :       tokenTypeIds: Int64List.fromList(encoding.typeIds),
     225              :       truncated: truncated,
     226              :     );
     227              :     // coverage:ignore-end
     228              :   }
     229              : 
     230              :   /// Applies the charsmap substitution, whitespace-run collapse, and
     231              :   /// Metaspace escaping steps of [encode]'s pipeline (steps 1–3), without
     232              :   /// requiring a loaded [SentencePieceTokenizer]/vocabulary.
     233              :   ///
     234              :   /// Exposed (rather than kept private) purely so these offline,
     235              :   /// vocab-independent steps can be unit-tested directly against the small
     236              :   /// committed charsmap fixture, without needing the full ~17 MB
     237              :   /// `tokenizer.json` — see `test/xlmr_tokenizer_test.dart`. Not intended
     238              :   /// for use outside this package or its tests.
     239            1 :   @visibleForTesting
     240              :   static String normalizeForTokenization(CharsmapTrie trie, String text) {
     241            1 :     final substituted = trie.normalize(text);
     242            1 :     final collapsed = _collapseSpaceRuns(substituted);
     243            1 :     return _metaspace(collapsed);
     244              :   }
     245              : 
     246              :   /// Collapses runs of two or more plain space characters (U+0020) down to
     247              :   /// one, replicating `multilingual-e5-small`'s `tokenizer.json`
     248              :   /// `normalizer.normalizers[1]` stage (a literal `Replace` node for the
     249              :   /// pattern `" {2,}"` → `" "`).
     250              :   ///
     251              :   /// Deliberately narrower than SentencePiece C++'s own generic whitespace
     252              :   /// collapsing (which recognises many whitespace code points and also
     253              :   /// trims leading/trailing runs) — this mirrors only what this
     254              :   /// `tokenizer.json` itself declares.
     255            1 :   static String _collapseSpaceRuns(String text) =>
     256            2 :       text.replaceAll(RegExp(' {2,}'), ' ');
     257              : 
     258              :   /// Applies `multilingual-e5-small`'s `tokenizer.json` `pre_tokenizer`
     259              :   /// stage: a `Metaspace` pre-tokenizer with `replacement: "▁"` and
     260              :   /// `add_prefix_space: true`.
     261              :   ///
     262              :   /// Prepends a single leading space if [text] doesn't already start with
     263              :   /// one, then replaces every space with the metaspace symbol (U+2581).
     264              :   ///
     265              :   /// **Empty input is a special case, found by WI-4's broader parity
     266              :   /// corpus** (`test/fixtures/xlmr_parity_corpus.json`'s `edge_empty`
     267              :   /// entry): real `AutoTokenizer` output for `""` is just `[<s>, </s>]`
     268              :   /// (`[0, 2]`) with no content token at all, but naively adding the dummy
     269              :   /// prefix here would turn `""` into `"▁"`, which the real vocabulary
     270              :   /// treats as a *valid standalone piece* (id 6) -- producing a spurious
     271              :   /// extra content token (`[0, 6, 2]`) that fails byte-exact parity.
     272              :   /// HuggingFace's `tokenizers` Rust `Metaspace` pre-tokenizer only adds the
     273              :   /// prefix space when pre-tokenizing an actual word/split; a fully empty
     274              :   /// input has no splits to begin with, so the prefix is never added.
     275              :   /// Returning `''` unchanged for empty input reproduces that behaviour
     276              :   /// without needing to port the Rust library's split-detection machinery.
     277            1 :   static String _metaspace(String text) {
     278            1 :     if (text.isEmpty) return text;
     279            2 :     final withPrefix = text.startsWith(' ') ? text : ' $text';
     280            1 :     return withPrefix.replaceAll(' ', '▁');
     281              :   }
     282              : }
        

Generated by: LCOV version 2.0-1