SentinelHub commited on
Commit
dddaebf
·
verified ·
1 Parent(s): 21a7f85

Aggiornamento Tokenizer v2 (Spazio Implicito + Maiuscole Lossless)

Browse files
Files changed (1) hide show
  1. gptommy_tokenizer.py +240 -63
gptommy_tokenizer.py CHANGED
@@ -7,53 +7,80 @@
7
  # is strictly prohibited and constitutes a breach of contract.
8
  # ============================================================================
9
  from __future__ import annotations
 
10
  __author__ = "Thomas Seri (Sentinel)"
11
  __license__ = "R08-Non-Commercial-Ethical-License-v2.0"
12
  __jurisdiction__ = "Court of Milan, Italy"
 
13
 
14
- """gpTommy 32k Tokenizer: Tokenizer nativo ad alta efficienza per la lingua italiana.
15
  Autore & Ideatore: Thomas Seri (Sentinel)
16
  Ecosistema: Sentinel LifeOS (https://www.sentinelhub.eu)
17
- Modelli di riferimento: Sentinel R-08 v7, v7.2, v7.2a
18
 
19
- Vocabolario strutturato a schede (SSvvv):
20
- - 32.768 token totali (ID 0..32767)
21
- - 29.775 parole italiane complete (Schede 01..26 per le lettere A..Z)
22
- - 1.925 caratteri singoli, punteggiatura, cifre e simboli matematici
23
- - 1.069 token speciali di dialogo AI (<|user|>, <|assistant|>, <|system|>, <|think|>)
 
 
 
24
  """
25
 
26
- import unicodedata
27
  from pathlib import Path
28
  from typing import Dict, List, Optional, Sequence, Union
29
 
30
  DEFAULT_VOCAB_PATH = Path(__file__).resolve().parent / "gpTommy_vocab_by_id.txt"
31
 
32
- WS_PIECES = {"\n": "<riga>", "\t": "<tab>", "\r": "<cr>", "\xa0": "<nbsp>", " ": "<spazio>"}
33
- DECODE_WS = {"<spazio>": " ", "<riga>": "\n", "<tab>": "\t", "<cr>": "\r", "<nbsp>": " "}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  class gpTommyTokenizer:
37
- """Tokenizer compatto e nativo per il vocabolario gpTommy 32k."""
38
 
39
- def __init__(self, vocab_file: Optional[Union[str, Path]] = None):
40
  self.vocab_file = Path(vocab_file) if vocab_file else DEFAULT_VOCAB_PATH
41
  if not self.vocab_file.exists():
42
- raise FileNotFoundError(f"File vocabolario gpTommy non trovato: {self.vocab_file}")
43
 
 
44
  self.id_to_token: Dict[int, str] = {}
45
  self.token_to_id: Dict[str, int] = {}
46
  self.specials: List[str] = []
47
  self._load_vocab()
48
 
49
- # Token speciali standard
50
  self.pad_id = self.token_to_id.get("<|pad|>", 3)
51
  self.eos_id = self.token_to_id.get("<|eos|>", 2)
52
  self.bos_id = self.token_to_id.get("<|bos|>", 1)
53
  self.unk_id = self.token_to_id.get("<|unk|>", 4)
54
  self.sep_id = self.token_to_id.get("<|sep|>", 5)
55
-
56
- # Marcatori di chat / dialoghi
57
  self.user_id = self.token_to_id.get("<|user|>", 16)
58
  self.assistant_id = self.token_to_id.get("<|assistant|>", 17)
59
  self.system_id = self.token_to_id.get("<|system|>", 15)
@@ -61,94 +88,244 @@ class gpTommyTokenizer:
61
  self.max_len = max((len(t) for t in self.id_to_token.values() if not t.startswith("<")), default=1)
62
 
63
  def _load_vocab(self) -> None:
64
- """Carica il file tabellare gpTommy 32k."""
65
  with open(self.vocab_file, "r", encoding="utf-8") as f:
66
  for line in f:
67
- if line.startswith("#") or not line.strip():
 
68
  continue
69
- parts = line.strip().split("\t")
70
  if len(parts) >= 5:
71
  gid = int(parts[3])
72
- tok = parts[4]
73
- self.id_to_token[gid] = tok
74
- self.token_to_id[tok] = gid
75
- if tok.startswith("<") and tok.endswith(">"):
76
- self.specials.append(tok)
 
 
 
77
  self.specials.sort(key=len, reverse=True)
78
 
79
  @property
80
  def vocab_size(self) -> int:
81
  return len(self.id_to_token)
82
 
83
- def encode(self, text: str, add_special_tokens: bool = False, literal_specials: bool = True) -> List[int]:
84
- """Codifica secondo il contratto canonico gpTommy."""
85
  if not text:
86
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
 
 
 
 
 
88
  ids: List[int] = []
89
  if add_special_tokens and self.bos_id is not None:
90
  ids.append(self.bos_id)
91
-
92
- n = len(text)
93
- i = 0
94
  t2i = self.token_to_id
95
-
96
  while i < n:
97
  ch = text[i]
98
- # Gestione Whitespace
99
- if ch.isspace() or ch in WS_PIECES:
100
- tid = t2i.get(WS_PIECES.get(ch, "<spazio>"))
101
  if tid is not None:
102
  ids.append(tid)
103
  i += 1
104
  continue
105
-
106
- # Gestione speciali letterali <|...|>
107
  if literal_specials and ch == "<":
108
  hit = next((sp for sp in self.specials if text.startswith(sp, i)), None)
109
  if hit is not None:
110
  ids.append(t2i[hit])
111
  i += len(hit)
112
  continue
113
-
114
- # Longest match greedy
115
  upper = min(n, i + self.max_len)
116
- tid = None
117
- j = upper
118
  while j > i:
119
  frag = text[i:j]
120
  tid = t2i.get(frag)
121
- if tid is None and frag.isalpha():
122
  tid = t2i.get(frag.lower())
123
  if tid is not None:
124
  break
125
  j -= 1
126
-
127
  if tid is not None:
128
  ids.append(tid)
129
  i = j
130
- continue
131
-
132
- # Fallback singolo carattere
133
- tid = t2i.get(ch)
134
- if tid is None and ch.isalpha():
135
- tid = t2i.get(ch.lower())
136
- ids.append(self.unk_id if tid is None else tid)
137
- i += 1
138
-
139
  if add_special_tokens and self.eos_id is not None:
140
  ids.append(self.eos_id)
141
-
142
  return ids
143
 
144
- def decode(self, token_ids: Sequence[int], skip_special_tokens: bool = False) -> str:
145
- """Decodifica gli ID di token in testo ricostruito (sostituisce i whitespace)."""
146
  out: List[str] = []
147
  for tid in token_ids:
148
  tok = self.id_to_token.get(int(tid), "<|unk|>")
149
  if skip_special_tokens and tok.startswith("<|") and tok.endswith("|>"):
150
  continue
151
- out.append(DECODE_WS.get(tok, tok))
152
  return "".join(out)
153
 
154
  def batch_encode(self, texts: Sequence[str]) -> List[List[int]]:
@@ -160,18 +337,18 @@ class gpTommyTokenizer:
160
 
161
  if __name__ == "__main__":
162
  tok = gpTommyTokenizer()
163
- print(f"=== gpTommy 32k Tokenizer (Vocabolario: {tok.vocab_size} token) ===")
164
  test_frasi = [
165
- "ciao, come stai?",
166
- "qual è la capitale d'italia?",
167
- "il cuscinetto a sfera radiale riduce l'attrito volvente.",
168
- "<|user|> spiegami il teorema di pitagora <|assistant|> certo!",
169
  ]
170
  for frase in test_frasi:
171
- ids = tok.encode(frase)
172
  ricostruita = tok.decode(ids)
173
  print(f"\nFrase: '{frase}'")
174
- print(f"Tokens ({len(ids)} ID): {ids[:10]}...")
175
  print(f"Decodifica: '{ricostruita}'")
176
- assert frase == ricostruita, f"Mismatch: {frase} != {ricostruita}"
177
- print("\n✅ Tutti i test di conformità gpTommy 32k sono passati con successo!")
 
7
  # is strictly prohibited and constitutes a breach of contract.
8
  # ============================================================================
9
  from __future__ import annotations
10
+
11
  __author__ = "Thomas Seri (Sentinel)"
12
  __license__ = "R08-Non-Commercial-Ethical-License-v2.0"
13
  __jurisdiction__ = "Court of Milan, Italy"
14
+ __version__ = "2.0.0"
15
 
16
+ """gpTommy 32k Tokenizer v2 (Spazio Implicito + Maiuscole Lossless).
17
  Autore & Ideatore: Thomas Seri (Sentinel)
18
  Ecosistema: Sentinel LifeOS (https://www.sentinelhub.eu)
 
19
 
20
+ Novità Tokenizer v2 (default `implicit_space=True`):
21
+ - Spazio implicito tra le parole: lo spazio singolo ASCII tra due parole non consuma token
22
+ (riduce i token del ~20,4% sul set di prova: da 75.815 a 60.366 token, 1,85 token/parola);
23
+ - ID 10 (`<attaccato>`): emesso solo quando due frammenti sono concatenati senza spazio;
24
+ - ID 9 (`<spazi_extra>`): emesso solo per spazi multipli o a inizio/fine sequenza;
25
+ - Conservazione 100% lossless delle maiuscole tramite prefissi di caso:
26
+ ID 6 = Iniziale Maiuscola (TitleCase, es. "Il", "Thomas"), ID 5 = TUTTO MAIUSCOLO (es. "R08");
27
+ - Compatibilità v1 opzionale (`implicit_space=False`) per i checkpoint storici.
28
  """
29
 
 
30
  from pathlib import Path
31
  from typing import Dict, List, Optional, Sequence, Union
32
 
33
  DEFAULT_VOCAB_PATH = Path(__file__).resolve().parent / "gpTommy_vocab_by_id.txt"
34
 
35
+ CASE_UPPER_ID = 5 # Prefisso MAIUSCOLO per il pezzo successivo
36
+ CASE_TITLE_ID = 6 # Prefisso Iniziale Maiuscola per il pezzo successivo
37
+ EXTRA_SPACE_ID = 9 # Spazio esplicito extra
38
+ ATTACH_ID = 10 # <attaccato> in v2 (sopprime lo spazio implicito) / <spazio> in v1
39
+ NEWLINE_ID = 11 # \n
40
+ TAB_ID = 12 # \t
41
+ CR_ID = 13 # \r
42
+ NBSP_ID = 14 # \xa0
43
+ EOS_ID = 2
44
+ UNK_ID = 4
45
+
46
+ _WS_IDS = {"\n": NEWLINE_ID, "\t": TAB_ID, "\r": CR_ID, "\xa0": NBSP_ID}
47
+ _ID_WS = {NEWLINE_ID: "\n", TAB_ID: "\t", CR_ID: "\r", NBSP_ID: "\xa0"}
48
+
49
+ _RAW_CONTROL_IDS = {
50
+ "\b": 7, "\f": 8,
51
+ "’": 15, "“": 16, "”": 17, "⁺": 18, "˛": 19, "⁶": 20, "®": 21,
52
+ }
53
+ _ID_RAW_CONTROL = {value: key for key, value in _RAW_CONTROL_IDS.items()}
54
+
55
+ WS_PIECES_V1 = {
56
+ " ": "<spazio>",
57
+ "\n": "<riga>",
58
+ "\t": "<tab>",
59
+ "\r": "<cr>",
60
+ "\xa0": "<nbsp>",
61
+ }
62
+ DECODE_WS_V1 = {v: k for k, v in WS_PIECES_V1.items()}
63
 
64
 
65
  class gpTommyTokenizer:
66
+ """Tokenizer v2 autonomo gpTommy 32k (Spazio Implicito & Maiuscole Lossless)."""
67
 
68
+ def __init__(self, vocab_file: Optional[Union[str, Path]] = None, implicit_space: bool = True):
69
  self.vocab_file = Path(vocab_file) if vocab_file else DEFAULT_VOCAB_PATH
70
  if not self.vocab_file.exists():
71
+ raise FileNotFoundError(f"File vocabolario non trovato: {self.vocab_file}")
72
 
73
+ self.implicit_space = implicit_space
74
  self.id_to_token: Dict[int, str] = {}
75
  self.token_to_id: Dict[str, int] = {}
76
  self.specials: List[str] = []
77
  self._load_vocab()
78
 
 
79
  self.pad_id = self.token_to_id.get("<|pad|>", 3)
80
  self.eos_id = self.token_to_id.get("<|eos|>", 2)
81
  self.bos_id = self.token_to_id.get("<|bos|>", 1)
82
  self.unk_id = self.token_to_id.get("<|unk|>", 4)
83
  self.sep_id = self.token_to_id.get("<|sep|>", 5)
 
 
84
  self.user_id = self.token_to_id.get("<|user|>", 16)
85
  self.assistant_id = self.token_to_id.get("<|assistant|>", 17)
86
  self.system_id = self.token_to_id.get("<|system|>", 15)
 
88
  self.max_len = max((len(t) for t in self.id_to_token.values() if not t.startswith("<")), default=1)
89
 
90
  def _load_vocab(self) -> None:
91
+ raw_map: Dict[int, str] = {}
92
  with open(self.vocab_file, "r", encoding="utf-8") as f:
93
  for line in f:
94
+ s = line.strip()
95
+ if not s or s.startswith("#"):
96
  continue
97
+ parts = s.split("\t")
98
  if len(parts) >= 5:
99
  gid = int(parts[3])
100
+ raw_map[gid] = parts[4]
101
+ n = max(raw_map) + 1 if raw_map else 0
102
+ for i in range(n):
103
+ tok = raw_map.get(i, f"<unk_{i}>")
104
+ self.id_to_token[i] = tok
105
+ self.token_to_id[tok] = i
106
+ if tok.startswith("<") and len(tok) > 1 and tok.endswith(">"):
107
+ self.specials.append(tok)
108
  self.specials.sort(key=len, reverse=True)
109
 
110
  @property
111
  def vocab_size(self) -> int:
112
  return len(self.id_to_token)
113
 
114
+ def _encode_atom_v2(self, text: str) -> List[int]:
 
115
  if not text:
116
  return []
117
+ ids = self._encode_v1(text, add_special_tokens=False, literal_specials=False)
118
+ out: List[int] = []
119
+ for n, tid in enumerate(ids):
120
+ if n:
121
+ out.append(ATTACH_ID)
122
+ out.append(tid)
123
+ return out
124
+
125
+ def _encode_non_ws_v2(self, text: str, literal_specials: bool = False) -> List[int]:
126
+ out: List[int] = []
127
+ i, n = 0, len(text)
128
+ t2i = self.token_to_id
129
+ reserved = {CASE_UPPER_ID, CASE_TITLE_ID, EXTRA_SPACE_ID, ATTACH_ID, NEWLINE_ID, TAB_ID, CR_ID, NBSP_ID, *_RAW_CONTROL_IDS.values()}
130
+ while i < n:
131
+ if text[i] in _RAW_CONTROL_IDS and text[i] not in t2i:
132
+ if out:
133
+ out.append(ATTACH_ID)
134
+ out.append(_RAW_CONTROL_IDS[text[i]])
135
+ i += 1
136
+ continue
137
+ if literal_specials and text[i] == "<":
138
+ hit = next((sp for sp in self.specials if text.startswith(sp, i) and sp.startswith("<|") and sp.endswith("|>")), None)
139
+ if hit is not None and t2i[hit] not in reserved:
140
+ if out:
141
+ out.append(ATTACH_ID)
142
+ out.append(t2i[hit])
143
+ i += len(hit)
144
+ continue
145
+
146
+ alpha = text[i].isalpha()
147
+ j = i + 1
148
+ while j < n and (text[j] not in _RAW_CONTROL_IDS or text[j] in t2i) and text[j].isalpha() == alpha:
149
+ if literal_specials and text[j] == "<":
150
+ if any(text.startswith(sp, j) for sp in self.specials if sp.startswith("<|") and sp.endswith("|>") and t2i[sp] not in reserved):
151
+ break
152
+ j += 1
153
+ atom = text[i:j]
154
+ units = [atom]
155
+ if alpha:
156
+ cuts = [0] + [k for k in range(1, len(atom)) if atom[k].isupper()]
157
+ units = [atom[a:b] for a, b in zip(cuts, [*cuts[1:], len(atom)])]
158
+ for unit in units:
159
+ ids = self._encode_atom_v2(unit)
160
+ if alpha:
161
+ raw = "".join(self.id_to_token[x] for x in ids if x != ATTACH_ID)
162
+ if unit != raw:
163
+ if unit == raw.upper():
164
+ expanded: List[int] = []
165
+ for tid in ids:
166
+ if tid != ATTACH_ID:
167
+ expanded.append(CASE_UPPER_ID)
168
+ expanded.append(tid)
169
+ ids = expanded
170
+ elif unit[:1].upper() + unit[1:].lower() == unit and raw == raw.lower():
171
+ ids = [CASE_TITLE_ID, *ids]
172
+ if out:
173
+ out.append(ATTACH_ID)
174
+ out.extend(ids)
175
+ i = j
176
+ return out
177
+
178
+ def encode(self, text: str, add_special_tokens: bool = False, literal_specials: bool = False) -> List[int]:
179
+ """Codifica il testo (v2 a spazio implicito + maiuscole di default; v1 se implicit_space=False)."""
180
+ if not text:
181
+ return []
182
+ if not self.implicit_space:
183
+ return self._encode_v1(text, add_special_tokens=add_special_tokens, literal_specials=literal_specials)
184
+
185
+ out: List[int] = []
186
+ if add_special_tokens and self.bos_id is not None:
187
+ out.append(self.bos_id)
188
+
189
+ i, n = 0, len(text)
190
+ emitted_regular = False
191
+ while i < n:
192
+ if not text[i].isspace():
193
+ j = i + 1
194
+ while j < n and not text[j].isspace():
195
+ j += 1
196
+ part = self._encode_non_ws_v2(text[i:j], literal_specials=literal_specials)
197
+ out.extend(part)
198
+ emitted_regular = bool(part)
199
+ i = j
200
+ continue
201
+
202
+ j = i + 1
203
+ while j < n and text[j].isspace():
204
+ j += 1
205
+ run = text[i:j]
206
+ next_is_regular = j < n
207
+ if all(ch == " " for ch in run) and emitted_regular and next_is_regular:
208
+ out.extend([EXTRA_SPACE_ID] * (len(run) - 1))
209
+ i, emitted_regular = j, False
210
+ continue
211
+ for ch in run:
212
+ if ch == " ":
213
+ out.append(EXTRA_SPACE_ID)
214
+ elif ch in _WS_IDS:
215
+ out.append(_WS_IDS[ch])
216
+ else:
217
+ out.append(EXTRA_SPACE_ID)
218
+ emitted_regular = False
219
+ i = j
220
+
221
+ if add_special_tokens and self.eos_id is not None:
222
+ out.append(self.eos_id)
223
+ return out
224
+
225
+ def decode(self, token_ids: Sequence[int], skip_special_tokens: bool = False) -> str:
226
+ """Decodifica gli ID di token in testo ricostruito."""
227
+ if not self.implicit_space:
228
+ return self._decode_v1(token_ids, skip_special_tokens=skip_special_tokens)
229
+
230
+ pieces: List[str] = []
231
+ previous = "start"
232
+ attach = False
233
+ extra_spaces = 0
234
+ case_mode: Optional[int] = None
235
+
236
+ for value in token_ids:
237
+ tid = int(value)
238
+ if tid == EXTRA_SPACE_ID:
239
+ extra_spaces += 1
240
+ continue
241
+ if tid == ATTACH_ID:
242
+ attach = True
243
+ continue
244
+ if tid in {CASE_UPPER_ID, CASE_TITLE_ID}:
245
+ case_mode = tid
246
+ continue
247
+ if tid in _ID_WS:
248
+ if extra_spaces:
249
+ pieces.append(" " * extra_spaces)
250
+ extra_spaces = 0
251
+ pieces.append(_ID_WS[tid])
252
+ previous = "ws"
253
+ attach = False
254
+ case_mode = None
255
+ continue
256
+
257
+ if tid in _ID_RAW_CONTROL and _ID_RAW_CONTROL[tid] not in self.token_to_id:
258
+ raw = _ID_RAW_CONTROL[tid]
259
+ else:
260
+ raw = self.id_to_token.get(tid, "<|unk|>")
261
+ if skip_special_tokens and raw.startswith("<|") and raw.endswith("|>"):
262
+ case_mode = None
263
+ continue
264
+
265
+ if case_mode == CASE_UPPER_ID:
266
+ raw = raw.upper()
267
+ elif case_mode == CASE_TITLE_ID:
268
+ raw = raw[:1].upper() + raw[1:]
269
+
270
+ if previous == "regular":
271
+ pieces.append("" if attach else " " * (1 + extra_spaces))
272
+ elif previous in {"start", "ws"} and extra_spaces:
273
+ pieces.append(" " * extra_spaces)
274
+ pieces.append(raw)
275
+ previous, attach, extra_spaces, case_mode = "regular", False, 0, None
276
 
277
+ if extra_spaces:
278
+ pieces.append(" " * extra_spaces)
279
+ return "".join(pieces)
280
+
281
+ def _encode_v1(self, text: str, add_special_tokens: bool = False, literal_specials: bool = False) -> List[int]:
282
  ids: List[int] = []
283
  if add_special_tokens and self.bos_id is not None:
284
  ids.append(self.bos_id)
285
+ n, i = len(text), 0
 
 
286
  t2i = self.token_to_id
 
287
  while i < n:
288
  ch = text[i]
289
+ if ch.isspace() or ch in WS_PIECES_V1:
290
+ tid = t2i.get(WS_PIECES_V1.get(ch, "<spazio>"))
 
291
  if tid is not None:
292
  ids.append(tid)
293
  i += 1
294
  continue
 
 
295
  if literal_specials and ch == "<":
296
  hit = next((sp for sp in self.specials if text.startswith(sp, i)), None)
297
  if hit is not None:
298
  ids.append(t2i[hit])
299
  i += len(hit)
300
  continue
 
 
301
  upper = min(n, i + self.max_len)
302
+ tid, j = None, upper
 
303
  while j > i:
304
  frag = text[i:j]
305
  tid = t2i.get(frag)
306
+ if tid is None:
307
  tid = t2i.get(frag.lower())
308
  if tid is not None:
309
  break
310
  j -= 1
 
311
  if tid is not None:
312
  ids.append(tid)
313
  i = j
314
+ else:
315
+ tid = t2i.get(ch) or t2i.get(ch.lower())
316
+ ids.append(self.unk_id if tid is None else tid)
317
+ i += 1
 
 
 
 
 
318
  if add_special_tokens and self.eos_id is not None:
319
  ids.append(self.eos_id)
 
320
  return ids
321
 
322
+ def _decode_v1(self, token_ids: Sequence[int], skip_special_tokens: bool = False) -> str:
 
323
  out: List[str] = []
324
  for tid in token_ids:
325
  tok = self.id_to_token.get(int(tid), "<|unk|>")
326
  if skip_special_tokens and tok.startswith("<|") and tok.endswith("|>"):
327
  continue
328
+ out.append(DECODE_WS_V1.get(tok, tok))
329
  return "".join(out)
330
 
331
  def batch_encode(self, texts: Sequence[str]) -> List[List[int]]:
 
337
 
338
  if __name__ == "__main__":
339
  tok = gpTommyTokenizer()
340
+ print(f"=== gpTommy 32k Tokenizer v2 (Vocabolario: {tok.vocab_size} token, Spazio Implicito + Maiuscole) ===")
341
  test_frasi = [
342
+ "Ciao, come stai?",
343
+ "Qual è la capitale d'Italia?",
344
+ "Il cuscinetto a sfere radiale riduce l'attrito volvente nella manutenzione dei cilindri pneumatici.",
345
+ "<|im_start|> Spiegami il funzionamento del PLC Siemens <|im_end|> Certo!",
346
  ]
347
  for frase in test_frasi:
348
+ ids = tok.encode(frase, literal_specials=True)
349
  ricostruita = tok.decode(ids)
350
  print(f"\nFrase: '{frase}'")
351
+ print(f"Tokens v2 ({len(ids)} ID): {ids[:12]}...")
352
  print(f"Decodifica: '{ricostruita}'")
353
+ assert frase == ricostruita, f"Mismatch: {frase!r} != {ricostruita!r}"
354
+ print("\n✅ Tutti i test di conformità gpTommy 32k v2 (100% lossless) sono passati con successo!")