LCOV - code coverage report
Current view: top level - src - bert_tokenizer.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 100.0 % 50 50
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:io';
      16              : import 'dart:typed_data';
      17              : 
      18              : import 'package:betto_lexical/betto_lexical.dart'
      19              :     show Tokenizer, RegExpTokenizer;
      20              : 
      21              : import 'model_tokenizer.dart' show ModelTokenizer;
      22              : 
      23              : /// A BERT WordPiece tokenizer backed by a `vocab.txt` file.
      24              : ///
      25              : /// Converts arbitrary text into BERT token IDs suitable for feeding into the
      26              : /// BGE Small En v1.5 ONNX model via [OnnxSession.run].
      27              : ///
      28              : /// ## Pipeline
      29              : ///
      30              : /// 1. **Normalise** — lower-case and strip combining accent characters.
      31              : /// 2. **Word segmentation** — delegate to the [Tokenizer] supplied at
      32              : ///    construction time. [RegExpTokenizer] is used by default; `IcuTokenizer`
      33              : ///    from `package:betto_icu` can be substituted as a drop-in
      34              : ///    replacement for superior Unicode coverage.
      35              : /// 3. **WordPiece** — split each word into sub-word pieces and look up IDs in
      36              : ///    the vocabulary loaded from `vocab.txt`. Unknown pieces map to `[UNK]`.
      37              : /// 4. **Assemble** — prepend `[CLS]` (101), append `[SEP]` (102), and pad to
      38              : ///    [maxLength] with `[PAD]` (0).
      39              : ///
      40              : /// ## Token ID space
      41              : ///
      42              : /// BERT token IDs are entirely distinct from the stemmed token strings
      43              : /// produced by the lexical search pipeline (FtsManager / BM25). They must
      44              : /// not be interchanged.
      45              : ///
      46              : /// ## IcuTokenizer
      47              : ///
      48              : /// ```dart
      49              : /// import 'package:betto_icu/betto_icu.dart';
      50              : /// final tokenizer = await BertTokenizer.load(vocabPath,
      51              : ///   tokenizer: IcuTokenizer());
      52              : /// ```
      53              : class BertTokenizer implements ModelTokenizer {
      54              :   final Map<String, int> _vocab;
      55              :   final int _maxLength;
      56              :   final Tokenizer _tokenizer;
      57              : 
      58              :   /// `[CLS]` token ID — always the first token in BERT input sequences.
      59              :   static const int clsId = 101;
      60              : 
      61              :   /// `[SEP]` token ID — marks the end of a segment in BERT input sequences.
      62              :   static const int sepId = 102;
      63              : 
      64              :   /// `[UNK]` token ID — substituted for vocabulary entries not found in WordPiece
      65              :   /// decomposition.
      66              :   static const int unkId = 100;
      67              : 
      68              :   /// `[PAD]` token ID — used to fill sequences shorter than [maxLength].
      69              :   static const int padId = 0;
      70              : 
      71            2 :   BertTokenizer._(this._vocab, this._maxLength, this._tokenizer);
      72              : 
      73              :   /// Loads the vocabulary from [vocabPath] and returns a [BertTokenizer].
      74              :   ///
      75              :   /// [vocabPath] must point to a `vocab.txt` file where each line is a
      76              :   /// vocabulary token and the line index (0-based) is the token ID. The BGE
      77              :   /// Small En v1.5 vocabulary has 30,522 entries.
      78              :   ///
      79              :   /// [maxLength] is the maximum sequence length including the `[CLS]` and
      80              :   /// `[SEP]` sentinel tokens (default 512 per the BERT specification).
      81              :   ///
      82              :   /// [tokenizer] controls word segmentation before WordPiece splitting.
      83              :   /// Defaults to [RegExpTokenizer]. Supply `IcuTokenizer()` from
      84              :   /// `package:betto_icu` for improved Unicode coverage.
      85            2 :   static Future<BertTokenizer> load(
      86              :     String vocabPath, {
      87              :     int maxLength = 512,
      88              :     Tokenizer? tokenizer,
      89              :   }) async {
      90            4 :     final lines = await File(vocabPath).readAsLines();
      91            2 :     final vocab = <String, int>{};
      92            6 :     for (var i = 0; i < lines.length; i++) {
      93            6 :       vocab[lines[i].trim()] = i;
      94              :     }
      95            4 :     return BertTokenizer._(vocab, maxLength, tokenizer ?? RegExpTokenizer());
      96              :   }
      97              : 
      98              :   /// Encodes [text] into a [TokenizerOutput] ready for ONNX inference.
      99              :   ///
     100              :   /// The output always starts with `[CLS]` (101) and ends with `[SEP]` (102).
     101              :   /// If [text] contains more WordPiece tokens than `maxLength - 2` (510 usable
     102              :   /// tokens), the excess is silently discarded and
     103              :   /// [TokenizerOutput.truncated] is `true`.
     104              :   ///
     105              :   /// An empty or whitespace-only [text] produces a two-token sequence
     106              :   /// `[CLS][SEP]` with all remaining positions padded — [TokenizerOutput.truncated]
     107              :   /// is `false`.
     108              :   ///
     109              :   /// All three output arrays ([TokenizerOutput.inputIds],
     110              :   /// [TokenizerOutput.attentionMask], [TokenizerOutput.tokenTypeIds]) have
     111              :   /// exactly [maxLength] elements.
     112            2 :   @override
     113              :   TokenizerOutput encode(String text) {
     114            2 :     final normalized = _normalize(text);
     115            4 :     final words = _tokenizer.tokenise(normalized);
     116              : 
     117              :     // Build the token ID list starting with [CLS].
     118              :     // Leave one slot for the closing [SEP] token.
     119            2 :     final tokenIds = <int>[clsId];
     120              :     var wasTruncated = false;
     121              : 
     122              :     outer:
     123            4 :     for (final word in words) {
     124            2 :       if (word.isEmpty) continue;
     125            4 :       for (final id in _wordPiece(word)) {
     126              :         // Reserve the last slot for [SEP].
     127            8 :         if (tokenIds.length >= _maxLength - 1) {
     128              :           wasTruncated = true;
     129              :           break outer;
     130              :         }
     131            2 :         tokenIds.add(id);
     132              :       }
     133              :     }
     134            2 :     tokenIds.add(sepId);
     135              : 
     136              :     // Build attention mask: 1 for real tokens, 0 for padding.
     137            4 :     final attentionMask = List<int>.filled(tokenIds.length, 1, growable: true);
     138            6 :     while (tokenIds.length < _maxLength) {
     139            2 :       tokenIds.add(padId);
     140            2 :       attentionMask.add(0);
     141              :     }
     142              : 
     143            2 :     return TokenizerOutput(
     144            2 :       inputIds: Int64List.fromList(tokenIds),
     145            2 :       attentionMask: Int64List.fromList(attentionMask),
     146              :       // BERT token_type_ids are all-zeros for single-segment input.
     147            6 :       tokenTypeIds: Int64List.fromList(List.filled(_maxLength, 0)),
     148              :       truncated: wasTruncated,
     149              :     );
     150              :   }
     151              : 
     152              :   /// Decodes a list of token IDs back to vocabulary strings.
     153              :   ///
     154              :   /// Unknown IDs are mapped to `'[UNK]'`. Primarily for diagnostics.
     155            1 :   List<String> decode(List<int> ids) {
     156            1 :     final inverse = <int, String>{};
     157            4 :     _vocab.forEach((k, v) => inverse[v] = k);
     158            4 :     return ids.map((id) => inverse[id] ?? '[UNK]').toList();
     159              :   }
     160              : 
     161              :   // ── Private helpers ─────────────────────────────────────────────────────────
     162              : 
     163              :   /// Lower-cases [text] and strips Unicode combining accent characters
     164              :   /// (U+0300–U+036F) which BERT treats as noise.
     165            2 :   String _normalize(String text) {
     166            2 :     final buf = StringBuffer();
     167            6 :     for (final char in text.toLowerCase().runes) {
     168              :       // Strip combining diacritical marks (accents, cedillas, etc.)
     169            3 :       if (char >= 0x0300 && char <= 0x036F) continue;
     170            2 :       buf.writeCharCode(char);
     171              :     }
     172            2 :     return buf.toString();
     173              :   }
     174              : 
     175              :   /// Splits [word] into sub-word pieces using the WordPiece algorithm and
     176              :   /// returns the corresponding vocabulary token IDs.
     177              :   ///
     178              :   /// All sub-word pieces after the first are prefixed with `##` per the BERT
     179              :   /// convention. Returns `[unkId]` if any position cannot be decomposed.
     180            2 :   List<int> _wordPiece(String word) {
     181              :     // Fast path: the whole word is in the vocabulary.
     182           10 :     if (_vocab.containsKey(word)) return [_vocab[word]!];
     183              : 
     184            1 :     final ids = <int>[];
     185              :     var start = 0;
     186            2 :     while (start < word.length) {
     187            1 :       var end = word.length;
     188              :       int? found;
     189            1 :       while (start < end) {
     190            1 :         final sub = start == 0
     191            1 :             ? word.substring(start, end)
     192            2 :             : '##${word.substring(start, end)}';
     193            2 :         if (_vocab.containsKey(sub)) {
     194            2 :           found = _vocab[sub];
     195              :           break;
     196              :         }
     197            1 :         end--;
     198              :       }
     199              :       // If no sub-word piece was found, map the entire word to [UNK].
     200            1 :       if (found == null) return [unkId];
     201            1 :       ids.add(found);
     202              :       start = end;
     203              :     }
     204              :     return ids;
     205              :   }
     206              : }
     207              : 
     208              : /// The output of [BertTokenizer.encode]: three parallel int64 arrays ready for
     209              : /// ONNX Runtime inference.
     210              : ///
     211              : /// All three arrays have exactly `maxLength` elements. Padding positions have
     212              : /// `inputIds = 0`, `attentionMask = 0`, `tokenTypeIds = 0`.
     213              : final class TokenizerOutput {
     214              :   /// Creates a [TokenizerOutput].
     215            2 :   const TokenizerOutput({
     216              :     required this.inputIds,
     217              :     required this.attentionMask,
     218              :     required this.tokenTypeIds,
     219              :     required this.truncated,
     220              :   });
     221              : 
     222              :   /// BERT token IDs, starting with `[CLS]` (101) and ending with `[SEP]`
     223              :   /// (102), then zero-padded to [BertTokenizer.maxLength].
     224              :   final Int64List inputIds;
     225              : 
     226              :   /// 1 for real tokens (including `[CLS]` and `[SEP]`), 0 for padding.
     227              :   final Int64List attentionMask;
     228              : 
     229              :   /// Segment IDs — all zeros for single-segment BERT input.
     230              :   final Int64List tokenTypeIds;
     231              : 
     232              :   /// `true` if the input text exceeded the usable token budget and was
     233              :   /// silently truncated before the `[SEP]` token.
     234              :   final bool truncated;
     235              : }
        

Generated by: LCOV version 2.0-1