gigatoken 0.1.0 → 0.2.0

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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gigatoken
4
+ # The tiktoken encodings gigatoken vendors ranks for, and the pieces a
5
+ # .tiktoken file doesn't carry: its pretokenizer scheme and special-token
6
+ # table (see Tokenizer.from_tiktoken and lib/gigatoken/encodings/
7
+ # PROVENANCE.md, the source of truth this is transcribed from).
8
+ module Encodings
9
+ DATA_DIR = File.expand_path("encodings", __dir__)
10
+ private_constant :DATA_DIR
11
+
12
+ # The non-contiguous head of openai_public.py's o200k_harmony()
13
+ # special-token table, transcribed verbatim (see PROVENANCE.md): its ten
14
+ # named control tokens, plus the six reserved slots sitting in the gaps
15
+ # between them (200000, 200001, 200004, 200009, 200010, 200011). The
16
+ # reserved range only goes contiguous at 200013.
17
+ HARMONY_HEAD_TOKENS = {
18
+ "<|startoftext|>" => 199998,
19
+ "<|endoftext|>" => 199999,
20
+ "<|reserved_200000|>" => 200000,
21
+ "<|reserved_200001|>" => 200001,
22
+ "<|return|>" => 200002,
23
+ "<|constrain|>" => 200003,
24
+ "<|reserved_200004|>" => 200004,
25
+ "<|channel|>" => 200005,
26
+ "<|start|>" => 200006,
27
+ "<|end|>" => 200007,
28
+ "<|message|>" => 200008,
29
+ "<|reserved_200009|>" => 200009,
30
+ "<|reserved_200010|>" => 200010,
31
+ "<|reserved_200011|>" => 200011,
32
+ "<|call|>" => 200012,
33
+ "<|endofprompt|>" => 200018
34
+ }.freeze
35
+ private_constant :HARMONY_HEAD_TOKENS
36
+
37
+ # The contiguous tail of that reserved range: 200013..201087. With the
38
+ # head above, 1091 entries total — 10 named, 1081 reserved (see
39
+ # PROVENANCE.md).
40
+ HARMONY_RESERVED_TAIL = (200013..201087).to_h { |id| ["<|reserved_#{id}|>", id] }.freeze
41
+ private_constant :HARMONY_RESERVED_TAIL
42
+
43
+ REGISTRY = {
44
+ "r50k_base" => {
45
+ rank_file: File.join(DATA_DIR, "r50k_base.tiktoken"),
46
+ pretokenizer: "gpt2",
47
+ special_tokens: {"<|endoftext|>" => 50256}
48
+ },
49
+ "cl100k_base" => {
50
+ rank_file: File.join(DATA_DIR, "cl100k_base.tiktoken"),
51
+ pretokenizer: "gpt4",
52
+ special_tokens: {
53
+ "<|endoftext|>" => 100257,
54
+ "<|fim_prefix|>" => 100258,
55
+ "<|fim_middle|>" => 100259,
56
+ "<|fim_suffix|>" => 100260,
57
+ "<|endofprompt|>" => 100276
58
+ }
59
+ },
60
+ "o200k_base" => {
61
+ rank_file: File.join(DATA_DIR, "o200k_base.tiktoken"),
62
+ pretokenizer: "o200k",
63
+ special_tokens: {"<|endoftext|>" => 199999, "<|endofprompt|>" => 200018}
64
+ },
65
+ "o200k_harmony" => {
66
+ rank_file: File.join(DATA_DIR, "o200k_base.tiktoken"),
67
+ pretokenizer: "o200k",
68
+ special_tokens: HARMONY_HEAD_TOKENS.merge(HARMONY_RESERVED_TAIL).freeze
69
+ }
70
+ }.freeze
71
+ private_constant :REGISTRY
72
+
73
+ # The packaged encoding names — the single source error messages naming
74
+ # what's available are built from (the same discipline
75
+ # Native.pretokenizer_names / PretokenizerType::NAMES applies to
76
+ # pretokenizer scheme names).
77
+ NAMES = REGISTRY.keys.freeze
78
+
79
+ # Encodings known by name but deliberately not packaged, keyed to the
80
+ # reason a caller asking for one by name deserves to hear.
81
+ UNPACKABLE_REASONS = {
82
+ "p50k_base" => "its ranks are not dense (50256 is left free for <|endoftext|>), " \
83
+ "and the rank loader rejects non-dense ranks",
84
+ "p50k_edit" => "it loads the same p50k_base.tiktoken ranks, which are not dense " \
85
+ "(50256 is left free for <|endoftext|>), and the rank loader rejects non-dense ranks"
86
+ }.freeze
87
+ private_constant :UNPACKABLE_REASONS
88
+
89
+ class << self
90
+ # The {rank_file:, pretokenizer:, special_tokens:} registered for a
91
+ # packaged encoding name, or nil.
92
+ def [](name)
93
+ REGISTRY[name]
94
+ end
95
+
96
+ # Why `name` can't be packaged, or nil when there's no reason on
97
+ # record (it's either packaged, or simply not one gigatoken knows of).
98
+ def unpackable_reason(name)
99
+ UNPACKABLE_REASONS[name]
100
+ end
101
+ end
102
+ end
103
+ end
@@ -7,9 +7,6 @@ module Gigatoken
7
7
  # over a native `Gigatoken::Native::BPETokenizer` or
8
8
  # `Gigatoken::Native::SentencePieceTokenizer`.
9
9
  class Tokenizer
10
- TIKTOKEN_ENDOFTEXT = "<|endoftext|>"
11
- private_constant :TIKTOKEN_ENDOFTEXT
12
-
13
10
  FILE_SOURCE_CLASSES = [Native::TextFileSource, Native::JsonlFileSource, Native::ParquetFileSource].freeze
14
11
  private_constant :FILE_SOURCE_CLASSES
15
12
 
@@ -27,10 +24,27 @@ module Gigatoken
27
24
  from_json(File.binread(path))
28
25
  end
29
26
 
30
- # Load from a .tiktoken mergeable-ranks file.
31
- def self.from_tiktoken(path)
32
- native = Native::BPETokenizer.from_tiktoken(path.to_s)
33
- new(native, special_tokens: {TIKTOKEN_ENDOFTEXT => native.vocab_size - 1})
27
+ # Load from a .tiktoken mergeable-ranks file. The file carries neither a
28
+ # pretokenization scheme nor special tokens — nothing is guessed here,
29
+ # so `pretokenizer:` is required: one of the schemes gigatoken ships
30
+ # (see Native.pretokenizer_names, e.g. "gpt2"/"r50k", "gpt4"/"cl100k",
31
+ # "o200k", "qwen2", "qwen35", "olmo3", "deepseek_v3", "nemotron", "kimi").
32
+ # `special_tokens:` maps token content to id (none by default).
33
+ def self.from_tiktoken(path, pretokenizer:, special_tokens: {})
34
+ native = Native::BPETokenizer.from_tiktoken(path.to_s, pretokenizer, special_tokens)
35
+ new(native, special_tokens: special_tokens)
36
+ end
37
+
38
+ # Load one of the tiktoken encodings gigatoken vendors ranks for, by
39
+ # name — see Gigatoken::Encodings::NAMES — entirely from the vendored
40
+ # files: no network, no writable cache.
41
+ def self.from_encoding(name)
42
+ encoding = Encodings[name]
43
+ return from_tiktoken(encoding[:rank_file], pretokenizer: encoding[:pretokenizer], special_tokens: encoding[:special_tokens]) if encoding
44
+
45
+ reason = Encodings.unpackable_reason(name)
46
+ detail = reason ? " — #{reason}" : ""
47
+ raise Error, "#{name.inspect}: not a packaged encoding#{detail} (packaged encodings: #{Encodings::NAMES.join(", ")})"
34
48
  end
35
49
 
36
50
  # Load tokenizer.json from HuggingFace Hub repo `repo_id` at `revision`
@@ -41,12 +55,29 @@ module Gigatoken
41
55
 
42
56
  # Load from any of the supported source shapes: an existing file or
43
57
  # directory path (a tokenizer.json, or a directory containing one), a
44
- # .tiktoken vocabulary file, or a HuggingFace Hub repo id like
45
- # "openai-community/gpt2".
46
- def self.load(source, revision: "main", hub: Hub.new)
58
+ # .tiktoken vocabulary file, a packaged encoding name (see
59
+ # Gigatoken::Encodings::NAMES, e.g. "cl100k_base"), or a HuggingFace Hub
60
+ # repo id like "openai-community/gpt2". A .tiktoken file carries no
61
+ # pretokenizer scheme of its own, so one must be named explicitly via
62
+ # `pretokenizer:` — nothing here is guessed. Packaged encoding names are
63
+ # checked before the Hub-repo-id shape: a bare name like "o200k_base" is
64
+ # also shaped like a legacy repo id, and must resolve locally rather
65
+ # than reach the network. Names the registry knows but doesn't package
66
+ # (see Encodings.unpackable_reason, e.g. "p50k_base") are intercepted
67
+ # here too, raising the same explanation from_encoding gives rather than
68
+ # reaching the Hub — but only those; an unrecognized bare name like
69
+ # "gpt2" still dispatches to the Hub.
70
+ def self.load(source, pretokenizer: nil, special_tokens: {}, revision: "main", hub: Hub.new)
47
71
  source = source.to_s
48
- return from_tiktoken(source) if source.end_with?(".tiktoken")
72
+ if source.end_with?(".tiktoken")
73
+ unless pretokenizer
74
+ raise Error, "#{source.inspect}: a .tiktoken file carries no pretokenizer scheme of its own — " \
75
+ "pass pretokenizer: (one of #{Native.pretokenizer_names.join(", ")})"
76
+ end
77
+ return from_tiktoken(source, pretokenizer: pretokenizer, special_tokens: special_tokens)
78
+ end
49
79
  return from_file(source) if File.exist?(source)
80
+ return from_encoding(source) if Encodings::NAMES.include?(source) || Encodings.unpackable_reason(source)
50
81
  return from_hub(source, revision: revision, hub: hub) if Hub.looks_like_repo_id?(source)
51
82
 
52
83
  raise Error, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
@@ -112,6 +143,12 @@ module Gigatoken
112
143
  @native.merges
113
144
  end
114
145
 
146
+ # Cached pretoken/unit entries on this tokenizer's single-document
147
+ # encode path — see Gigatoken.max_cache_bytes.
148
+ def cache_entries
149
+ @native.cache_entries
150
+ end
151
+
115
152
  attr_reader :special_tokens
116
153
  end
117
154
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gigatoken
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/gigatoken.rb CHANGED
@@ -7,6 +7,20 @@ module Gigatoken
7
7
  # extension — never a raw Rust panic across the Ruby boundary.
8
8
  class Error < StandardError; end
9
9
 
10
+ class << self
11
+ # The process-global encode-cache budget in bytes per worker (a parallel
12
+ # batch encode may use up to workers x budget), applied to tokenizers of
13
+ # either backend constructed *afterward* — changing it never affects an
14
+ # already-built Tokenizer. nil means unbounded. Default: 512 MiB.
15
+ def max_cache_bytes
16
+ Native.get_max_cache_bytes
17
+ end
18
+
19
+ def max_cache_bytes=(bytes)
20
+ Native.set_max_cache_bytes(bytes)
21
+ end
22
+ end
23
+
10
24
  NATIVE_EXTENSIONS = %w[.bundle .so .rb].freeze
11
25
 
12
26
  # Precompiled native gems ship per-ABI subdirs (`gigatoken/4.0/...`),
@@ -26,4 +40,5 @@ require native
26
40
 
27
41
  require_relative "gigatoken/hub"
28
42
  require_relative "gigatoken/packed_result"
43
+ require_relative "gigatoken/encodings"
29
44
  require_relative "gigatoken/tokenizer"
data/src/batch.rs CHANGED
@@ -600,13 +600,8 @@ impl Committer {
600
600
  unsafe {
601
601
  flat.set_len(total);
602
602
  }
603
- // Return the unused reservation. Large allocations trim in place on
604
- // the mainstream allocators (macOS libmalloc large entries, glibc
605
- // mmap'd chunks via mremap): pointer-stable, no copy, and the
606
- // untouched tail pages were never faulted so there is nothing to
607
- // tear down. An allocator that copies instead only costs one
608
- // memcpy; correctness is unaffected.
609
- flat.shrink_to_fit();
603
+ // The unused reservation tail is unfaulted virtual address space —
604
+ // freed with the Vec on drop, no trim needed to reclaim it.
610
605
  Some(flat)
611
606
  }
612
607
 
@@ -0,0 +1,38 @@
1
+ //! The process-global cache-budget knob, read once per tokenizer
2
+ //! construction. Semantics in the .pyi stubs, mechanism in
3
+ //! `Tokenizer::set_max_cache_bytes`.
4
+
5
+ use crate::bpe::{SentencePieceBPE, Tokenizer};
6
+ use pyo3::prelude::*;
7
+ use std::sync::Mutex;
8
+
9
+ /// The budget applied to tokenizers constructed after the last
10
+ /// `set_max_cache_bytes` call; `None` = unbounded.
11
+ static MAX_CACHE_BYTES: Mutex<Option<usize>> = Mutex::new(Some(Tokenizer::DEFAULT_MAX_CACHE_BYTES));
12
+
13
+ /// Apply the global setting to a freshly constructed tokenizer (which
14
+ /// already carries the built-in default, hence the `!=` skip).
15
+ pub(crate) fn apply_max_cache_bytes(mut tokenizer: Tokenizer) -> Tokenizer {
16
+ let configured = *MAX_CACHE_BYTES.lock().unwrap();
17
+ if configured != tokenizer.max_cache_bytes() {
18
+ tokenizer.set_max_cache_bytes(configured);
19
+ }
20
+ tokenizer
21
+ }
22
+
23
+ /// SentencePiece analog of [`apply_max_cache_bytes`].
24
+ pub(crate) fn apply_max_cache_bytes_sp(mut model: SentencePieceBPE) -> SentencePieceBPE {
25
+ model.set_max_cache_bytes(*MAX_CACHE_BYTES.lock().unwrap());
26
+ model
27
+ }
28
+
29
+ #[pyfunction]
30
+ #[pyo3(signature = (max_bytes))]
31
+ pub(crate) fn set_max_cache_bytes(max_bytes: Option<usize>) {
32
+ *MAX_CACHE_BYTES.lock().unwrap() = max_bytes;
33
+ }
34
+
35
+ #[pyfunction]
36
+ pub(crate) fn get_max_cache_bytes() -> Option<usize> {
37
+ *MAX_CACHE_BYTES.lock().unwrap()
38
+ }
data/src/bindings/mod.rs CHANGED
@@ -3,9 +3,11 @@
3
3
  //! submodule per feature: Python<->Rust bridging shared by the bindings
4
4
  //! (bridge), the FileSource/BytesSource classes (sources), BPE training (train), padded
5
5
  //! compat-API encoding (padding), the compat layers' special-token scanner
6
- //! (matcher), and the pretokenizer helpers (pretokenize).
6
+ //! (matcher), the pretokenizer helpers (pretokenize), and the
7
+ //! process-global cache-budget knob (cache).
7
8
 
8
9
  pub(crate) mod bridge;
10
+ pub(crate) mod cache;
9
11
  pub(crate) mod hub;
10
12
  pub(crate) mod matcher;
11
13
  pub(crate) mod padding;
@@ -29,6 +29,17 @@ impl PretokenizerIter {
29
29
  }
30
30
  }
31
31
 
32
+ /// The pretokenization scheme a loader was given by name, for the sources
33
+ /// whose format carries no split regex (tiktoken rank files).
34
+ pub(crate) fn pretokenizer_scheme(name: &str) -> PyResult<pretokenize::PretokenizerType> {
35
+ pretokenize::PretokenizerType::from_name(name).ok_or_else(|| {
36
+ pyo3::exceptions::PyValueError::new_err(format!(
37
+ "unknown pretokenizer scheme {name:?}; expected one of {}",
38
+ pretokenize::PretokenizerType::NAMES.join(", ")
39
+ ))
40
+ })
41
+ }
42
+
32
43
  #[pyfunction]
33
44
  pub(crate) fn pretokenizer<'py>(text: Bound<'py, PyBytes>) -> PyResult<PretokenizerIter> {
34
45
  Ok(PretokenizerIter {
@@ -178,11 +178,42 @@ impl ShortPretokenCache {
178
178
  /// 3/4 load. Either way the table is constructed exactly once, at the
179
179
  /// max of the two requirements.
180
180
  pub(crate) fn with_at_least(n: usize, min_slots: usize) -> Self {
181
+ Self::with_pow2_capacity(Self::required_capacity(n, min_slots))
182
+ }
183
+
184
+ /// The slot count [`Self::with_at_least`] would allocate for `n`
185
+ /// entries starting from `min_slots`, without allocating anything.
186
+ pub(crate) fn required_capacity(n: usize, min_slots: usize) -> usize {
181
187
  let mut cap = min_slots.max(1 << 16).next_power_of_two();
182
188
  while (n + 1) * 4 > cap * 3 {
183
189
  cap *= 2;
184
190
  }
185
- Self::with_pow2_capacity(cap)
191
+ cap
192
+ }
193
+
194
+ /// Zero every slot in place (all-zero == all-empty), keeping the
195
+ /// allocation — the generational-wipe path. Like [`Self::insert`]'s
196
+ /// grow, this logically invalidates any outstanding [`ProbeView`].
197
+ pub(crate) fn clear(&mut self) {
198
+ // SAFETY: the allocation holds exactly `cap` entries.
199
+ unsafe { std::ptr::write_bytes(self.slots.ptr.as_ptr(), 0, self.slots.cap) };
200
+ self.len = 0;
201
+ }
202
+
203
+ /// Reset to an empty table of exactly `cap` slots (power of two,
204
+ /// >= 2), reusing the current allocation when the capacity matches.
205
+ pub(crate) fn reset_to_capacity(&mut self, cap: usize) {
206
+ debug_assert!(cap.is_power_of_two() && cap >= 2);
207
+ if cap == self.slots.cap {
208
+ self.clear();
209
+ return;
210
+ }
211
+ // Drop the old table (via a 2-slot placeholder) before building
212
+ // the new one, so two full-size tables never coexist.
213
+ self.slots = Slots::new_zeroed(2);
214
+ self.slots = Slots::new_zeroed(cap);
215
+ self.mask = cap - 1;
216
+ self.len = 0;
186
217
  }
187
218
 
188
219
  /// Address of `h`'s home pair; both slots share the addressed line.
@@ -296,6 +296,8 @@ pub struct SentencePieceBPE {
296
296
  /// Bitset over the byte just before a candidate boundary (the last byte
297
297
  /// of some `cross_pieces` pre) for a one-load rejection in the scanner.
298
298
  pub(crate) cross_prev: [u64; 4],
299
+ /// Cache budget snapshot by each [`Self::encoder`]; `None` = unbounded.
300
+ pub(crate) max_cache_bytes: Option<usize>,
299
301
  }
300
302
 
301
303
  /// How many distinct punctuation bytes the unit splitter checks for.
@@ -764,10 +766,22 @@ impl SentencePieceBPE {
764
766
  pub fn encoder(&self) -> Encoder<'_> {
765
767
  Encoder {
766
768
  model: self,
767
- state: EncodeState::new(),
769
+ state: EncodeState::with_budget(self.max_cache_bytes),
768
770
  }
769
771
  }
770
772
 
773
+ /// SentencePiece analog of [`super::Tokenizer::set_max_cache_bytes`],
774
+ /// same default: bounds the caches of [`EncodeState`]s created
775
+ /// afterward, which wipe back to empty (there is no vocab seed here).
776
+ pub fn set_max_cache_bytes(&mut self, budget: Option<usize>) {
777
+ self.max_cache_bytes = budget;
778
+ }
779
+
780
+ /// The configured cache budget in bytes; `None` when unbounded.
781
+ pub fn max_cache_bytes(&self) -> Option<usize> {
782
+ self.max_cache_bytes
783
+ }
784
+
771
785
  /// Size of the vocabulary: one greater than the largest token ID,
772
786
  /// including added tokens (IDs with no assigned content count too).
773
787
  pub fn vocab_size(&self) -> usize {
@@ -854,6 +868,14 @@ fn collapse_space_runs(input: &str, content: &str) -> String {
854
868
  /// Zipf-hot working set resident despite eviction churn from tail units.
855
869
  const FRONT_BITS: u32 = 20;
856
870
 
871
+ /// Fixed footprint of the front cache (keys + vals), subtracted from the
872
+ /// budget before bounding the growing caches.
873
+ const FRONT_BYTES: usize = (1 << FRONT_BITS) * (16 + 8);
874
+ /// Estimated bytes per map entry, payload + SwissTable slack (`long` adds
875
+ /// its key bytes on top). Length-based, because the wipe keeps allocations.
876
+ const SHORT_ENTRY_BYTES: usize = 48;
877
+ const LONG_ENTRY_BYTES: usize = 48;
878
+
857
879
  /// Index of `key` in the front cache: multiplicative hash, top bits.
858
880
  #[inline(always)]
859
881
  fn front_index(key: u128) -> usize {
@@ -882,10 +904,26 @@ pub struct EncodeState {
882
904
  symbols: Vec<TokenId>,
883
905
  /// Scratch for composing keys with a virtual leading space.
884
906
  key_buf: Vec<u8>,
907
+ /// Byte budget for the growing caches ([`Self::with_budget`]);
908
+ /// `usize::MAX` = unbounded.
909
+ budget: usize,
910
+ /// Estimated `long` bytes: key bytes + [`LONG_ENTRY_BYTES`] per entry.
911
+ long_bytes_used: usize,
912
+ /// Largest single encoding, as wipe-trigger slack: one recurring giant
913
+ /// unit must not force a wipe per occurrence. Kept across wipes.
914
+ max_encoding: usize,
885
915
  }
886
916
 
887
917
  impl EncodeState {
888
918
  pub fn new() -> Self {
919
+ Self::with_budget(Some(super::Tokenizer::DEFAULT_MAX_CACHE_BYTES))
920
+ }
921
+
922
+ /// A state whose growing caches (short/long maps + arena; the front
923
+ /// cache is fixed-size) wipe back to empty when their estimated
924
+ /// footprint exceeds `max_bytes` minus the front cache, floored at
925
+ /// 1 MiB so tiny budgets stay functional.
926
+ pub fn with_budget(max_bytes: Option<usize>) -> Self {
889
927
  EncodeState {
890
928
  arena: Vec::new(),
891
929
  front_keys: vec![0u128; 1 << FRONT_BITS],
@@ -894,6 +932,10 @@ impl EncodeState {
894
932
  long: HashMap::with_hasher(FxBuildHasher),
895
933
  symbols: Vec::new(),
896
934
  key_buf: Vec::new(),
935
+ budget: max_bytes
936
+ .map_or(usize::MAX, |t| t.saturating_sub(FRONT_BYTES).max(1 << 20)),
937
+ long_bytes_used: 0,
938
+ max_encoding: 0,
897
939
  }
898
940
  }
899
941
 
@@ -901,6 +943,24 @@ impl EncodeState {
901
943
  pub fn cache_size(&self) -> usize {
902
944
  self.short.len() + self.long.len()
903
945
  }
946
+
947
+ #[inline]
948
+ fn over_budget(&self) -> bool {
949
+ self.short.len() * SHORT_ENTRY_BYTES + self.long_bytes_used + self.arena.len() * 4
950
+ > self.budget.saturating_add(self.max_encoding * 4)
951
+ }
952
+
953
+ /// Wipe every cache back to empty, keeping allocations. The front keys
954
+ /// must go too: their values are slices of the truncated arena.
955
+ #[cold]
956
+ #[inline(never)]
957
+ fn wipe(&mut self) {
958
+ self.arena.clear();
959
+ self.front_keys.fill(0);
960
+ self.short.clear();
961
+ self.long.clear();
962
+ self.long_bytes_used = 0;
963
+ }
904
964
  }
905
965
 
906
966
  impl Default for EncodeState {
@@ -1330,7 +1390,8 @@ impl SentencePieceBPE {
1330
1390
  let (offset, len) = unsafe { *state.front_vals.get_unchecked(front_idx) };
1331
1391
  let start = offset as usize;
1332
1392
  // SAFETY: entries are recorded right after appending `len`
1333
- // tokens at `offset`; `arena` never shrinks.
1393
+ // tokens at `offset`; the arena only clears in a budget
1394
+ // wipe, which also zeroes every front key.
1334
1395
  f(unsafe { state.arena.get_unchecked(start..start + len as usize) });
1335
1396
  return;
1336
1397
  }
@@ -1347,13 +1408,18 @@ impl SentencePieceBPE {
1347
1408
  }
1348
1409
  let start = offset as usize;
1349
1410
  // SAFETY: every cached (offset, len) was recorded right after
1350
- // appending those `len` tokens at `offset`, and `arena` never
1351
- // shrinks, so the range is always in bounds.
1411
+ // appending those `len` tokens at `offset`; the arena only
1412
+ // clears in a budget wipe, which also clears both maps.
1352
1413
  f(unsafe { state.arena.get_unchecked(start..start + len as usize) });
1353
1414
  return;
1354
1415
  }
1355
1416
 
1356
- // Miss: character init → ranked merge, then record in the arena.
1417
+ // Miss: budget check first (the lookups above already missed, and
1418
+ // stay misses against the freshly wiped caches), then character
1419
+ // init → ranked merge, then record in the arena.
1420
+ if state.over_budget() {
1421
+ state.wipe();
1422
+ }
1357
1423
  state.symbols.clear();
1358
1424
  if virtual_prefix {
1359
1425
  state.symbols.extend_from_slice(&self.space_init);
@@ -1367,6 +1433,7 @@ impl SentencePieceBPE {
1367
1433
  let offset = state.arena.len() as u32;
1368
1434
  let len = state.symbols.len() as u32;
1369
1435
  state.arena.extend_from_slice(&state.symbols);
1436
+ state.max_encoding = state.max_encoding.max(len as usize);
1370
1437
  match packed {
1371
1438
  Some(key) => {
1372
1439
  state.short.insert(key, (offset, len));
@@ -1379,6 +1446,7 @@ impl SentencePieceBPE {
1379
1446
  } else {
1380
1447
  unit.into()
1381
1448
  };
1449
+ state.long_bytes_used += key.len() + LONG_ENTRY_BYTES;
1382
1450
  state.long.insert(key, (offset, len));
1383
1451
  }
1384
1452
  }
@@ -1482,4 +1550,68 @@ mod tests {
1482
1550
  assert_eq!(collapse_space_runs(" ", "▁"), "▁");
1483
1551
  assert_eq!(collapse_space_runs("no runs", "▁"), "no runs");
1484
1552
  }
1553
+
1554
+ /// PARITY + BOUNDS: a budgeted EncodeState that wipes mid-corpus must
1555
+ /// produce the exact token stream of an unbounded one, while the
1556
+ /// estimated footprint of the growing caches stays within the budget
1557
+ /// (plus the documented giant-encoding slack).
1558
+ #[test]
1559
+ fn budgeted_wipe_matches_unbounded() {
1560
+ let Some(path) = crate::test_hub::hf_tokenizer_json("TinyLlama/TinyLlama-1.1B-Chat-v1.0")
1561
+ else {
1562
+ eprintln!("Skipping: TinyLlama tokenizer.json not in the HF cache");
1563
+ return;
1564
+ };
1565
+ let model = crate::load_tokenizer::hf::load_hf_sentencepiece(&path).unwrap();
1566
+ assert_eq!(
1567
+ model.max_cache_bytes(),
1568
+ Some(super::super::Tokenizer::DEFAULT_MAX_CACHE_BYTES)
1569
+ );
1570
+
1571
+ // ~100k distinct short words plus interleaved > 15-byte words (the
1572
+ // long-map path) and repeats of a common head.
1573
+ let mut text = String::new();
1574
+ let mut x = 0x1234_5678_9ABC_DEF0u64;
1575
+ let mut rand = |m: u64| {
1576
+ x ^= x << 13;
1577
+ x ^= x >> 7;
1578
+ x ^= x << 17;
1579
+ x % m
1580
+ };
1581
+ for i in 0..100_000u32 {
1582
+ let len = if i % 17 == 0 { 16 + rand(24) } else { 3 + rand(8) };
1583
+ for _ in 0..len {
1584
+ text.push((b'a' + rand(26) as u8) as char);
1585
+ }
1586
+ text.push(' ');
1587
+ if i % 5 == 0 {
1588
+ text.push_str("the quick brown fox ");
1589
+ }
1590
+ }
1591
+
1592
+ let mut unbounded = EncodeState::with_budget(None);
1593
+ let mut expected = Vec::new();
1594
+ model.encode_raw_with(&mut unbounded, &text, &mut expected);
1595
+
1596
+ // 25 MiB total = ~1 MiB effective past the 24 MiB front cache:
1597
+ // several wipes over ~100k distinct units.
1598
+ let mut budgeted = EncodeState::with_budget(Some(25 << 20));
1599
+ let mut actual = Vec::new();
1600
+ model.encode_raw_with(&mut budgeted, &text, &mut actual);
1601
+ assert_eq!(actual, expected, "budgeted output diverged");
1602
+ assert!(budgeted.long_bytes_used > 0, "corpus never hit the long map");
1603
+ // Both saw ~100k distinct units, so a small survivor count proves
1604
+ // wipes happened, and the estimate must end up within budget.
1605
+ assert!(
1606
+ budgeted.cache_size() < unbounded.cache_size() / 3,
1607
+ "survivors {} vs unbounded {}",
1608
+ budgeted.cache_size(),
1609
+ unbounded.cache_size()
1610
+ );
1611
+ let used = budgeted.short.len() * SHORT_ENTRY_BYTES
1612
+ + budgeted.long_bytes_used
1613
+ + budgeted.arena.len() * 4;
1614
+ let limit = budgeted.budget + budgeted.max_encoding * 4;
1615
+ assert!(used <= limit + 4096, "estimate {used} exceeds budget {limit}");
1616
+ }
1485
1617
  }