Frankenstein-Labs commited on
Commit
f17dddf
·
verified ·
1 Parent(s): a393524

feat(tokenizer): Phase 1 — BPE 32k from scratch

Browse files
.gitignore CHANGED
@@ -18,12 +18,11 @@ dist/
18
 
19
  # --- Artefacts d'entraînement (volumineux) ---
20
  # NOTE : ancrés à la racine (« /data/ ») pour ne pas exclure src/data/.
 
21
  /checkpoints/
22
  /logs/
23
  /data/
24
  /artifacts/
25
- /tokenizer/*.json
26
- /tokenizer/*.model
27
 
28
  # --- Divers ---
29
  .DS_Store
 
18
 
19
  # --- Artefacts d'entraînement (volumineux) ---
20
  # NOTE : ancrés à la racine (« /data/ ») pour ne pas exclure src/data/.
21
+ # Les artefacts du tokenizer (~2,3 Mo) SONT versionnés : c'est un livrable.
22
  /checkpoints/
23
  /logs/
24
  /data/
25
  /artifacts/
 
 
26
 
27
  # --- Divers ---
28
  .DS_Store
CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ Le versionnage suit [SemVer](https://semver.org/lang/fr/).
5
 
6
  ## [Unreleased]
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ### Modifié — Étape 0 : configuration finale verrouillée
9
  - Licence **MIT (placeholder) → Apache-2.0** (`LICENSE`, `README.md`, `pyproject.toml`, `configs/model.yaml`).
10
  - Nom du modèle : **Cortex-0** (README, model card, `configs/model.yaml`, package `pyproject.toml`).
 
5
 
6
  ## [Unreleased]
7
 
8
+ ### Ajouté — Phase 1 : Tokenizer BPE from scratch
9
+ - `src/tokenizer/special_tokens.py` : source unique de vérité, **19 tokens spéciaux**
10
+ (ids 0..18 fixes) : base (4), raisonnement/agent (6), modalités (9).
11
+ - `src/tokenizer/train_tokenizer.py` : entraînement BPE (ByteLevel, `byte_fallback`,
12
+ `initial_alphabet` 256 octets, `min_frequency`), CLI `--preset wikipedia_fr` en
13
+ streaming, chargement corpus local, sauvegarde des 3 artefacts.
14
+ - `src/tokenizer/__init__.py` : API `load_tokenizer()` / `CortexTokenizer`.
15
+ - `tests/test_tokenizer.py` : 40 tests (round-trip FR, accents, emojis, scripts
16
+ inconnus, stabilité des ids, `vocab_size`, contenu de `special_tokens_map.json`).
17
+ - `notebooks/01_tokenizer.ipynb` : entraînement Colab, `HF_TOKEN` via Colab Secrets.
18
+ - **Tokenizer réel entraîné** : **32 000 tokens** sur Wikipedia FR (~150 Mo, streaming),
19
+ en **21,8 s** (CPU). Round-trip exact accents/emojis/CJK/arabe.
20
+ - Artefacts versionnés : `tokenizer/tokenizer.json` (~2,3 Mo),
21
+ `special_tokens_map.json`, `tokenizer_config.json`.
22
+ - Docs : `architecture.md` §1 détaillé, ADR-010 (choix tokenizer).
23
+
24
  ### Modifié — Étape 0 : configuration finale verrouillée
25
  - Licence **MIT (placeholder) → Apache-2.0** (`LICENSE`, `README.md`, `pyproject.toml`, `configs/model.yaml`).
26
  - Nom du modèle : **Cortex-0** (README, model card, `configs/model.yaml`, package `pyproject.toml`).
configs/model.yaml CHANGED
@@ -21,23 +21,34 @@ project:
21
  # -----------------------------------------------------------------------------
22
  tokenizer:
23
  type: "bpe" # Byte-Pair Encoding
24
- vocab_size: 32000
25
  min_frequency: 2
 
 
 
 
26
  special_tokens:
27
  bos: "<|bos|>"
28
  eos: "<|eos|>"
29
  pad: "<|pad|>"
30
  unk: "<|unk|>"
31
- # Tokens réservés aux modalités (ajoutés au vocabulaire texte)
 
 
 
 
 
 
 
 
32
  modality_tokens:
33
- image_start: "<|image_start|>"
34
- image_end: "<|image_end|>"
35
- image_patch: "<|image_patch|>"
36
- audio_start: "<|audio_start|>"
37
- audio_end: "<|audio_end|>"
38
- audio_frame: "<|audio_frame|>"
39
- video_start: "<|video_start|>"
40
- video_end: "<|video_end|>"
41
  artifact_dir: "tokenizer"
42
 
43
  # -----------------------------------------------------------------------------
 
21
  # -----------------------------------------------------------------------------
22
  tokenizer:
23
  type: "bpe" # Byte-Pair Encoding
24
+ vocab_size: 32000 # borne supérieure ; atteint exactement sur corpus FR ~150 Mo
25
  min_frequency: 2
26
+ byte_fallback: true
27
+ pre_tokenizer: "ByteLevel"
28
+ add_prefix_space: false
29
+ initial_alphabet: "bytelevel" # 256 octets -> round-trip exact garanti
30
  special_tokens:
31
  bos: "<|bos|>"
32
  eos: "<|eos|>"
33
  pad: "<|pad|>"
34
  unk: "<|unk|>"
35
+ # Source unique de vérité : src/tokenizer/special_tokens.py (ids 0..18 fixes).
36
+ # Ne pas dupliquer la liste ici : ceci n'est qu'un rappel documentaire.
37
+ reasoning_tokens:
38
+ - "<|think|>"
39
+ - "<|plan|>"
40
+ - "<|tool_call|>"
41
+ - "<|tool_result|>"
42
+ - "<|critic|>"
43
+ - "<|moe_route|>"
44
  modality_tokens:
45
+ image: ["<|image_start|>", "<|image_end|>", "<|image_pad|>"]
46
+ audio: ["<|audio_start|>", "<|audio_end|>", "<|audio_pad|>"]
47
+ video: ["<|video_start|>", "<|video_end|>", "<|video_pad|>"]
48
+ corpus_presets:
49
+ wikipedia_fr: { path: "wikimedia/wikipedia", name: "20231101.fr", text_field: "text" }
50
+ oscar_fr: { path: "oscar-corpus/OSCAR-2301", name: "fr", text_field: "text" }
51
+ max_corpus_mb: 200
 
52
  artifact_dir: "tokenizer"
53
 
54
  # -----------------------------------------------------------------------------
docs/architecture.md CHANGED
@@ -23,14 +23,48 @@ de délimitation (`<|image_start|>`, `<|audio_start|>`, …) indiquent au modèl
23
  modalité commence et finit. C'est le principe des VLM modernes, mais **réimplémenté
24
  intégralement à la main**.
25
 
26
- ## 1. Tokenizer (Phase 1)
27
-
28
- ⏳ À documenter (Phase 1)
29
-
30
- - Choix BPE vs Unigram.
31
- - Taille de vocabulaire (32k) et impact sur les embeddings.
32
- - Gestion des tokens spéciaux et des tokens de modalité.
33
- - Couverture linguistique FR.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  ## 2. Transformer texte (Phase 2)
36
 
 
23
  modalité commence et finit. C'est le principe des VLM modernes, mais **réimplémenté
24
  intégralement à la main**.
25
 
26
+ ## 1. Tokenizer (Phase 1) — ✅ implémenté, entraîné, testé
27
+
28
+ Choix retenus (voir aussi ADR-010) :
29
+
30
+ - **BPE** (`tokenizers.models.BPE`) plutôt qu'Unigram/SentencePiece : algorithme
31
+ déterministe, simple à auditer, excellent sur les langues à morphologie riche
32
+ comme le français. Entraînement **sur notre corpus**, aucun tokenizer pré-entraîné.
33
+ - **`pre_tokenizer = ByteLevel(add_prefix_space=False)`** : découpe au niveau des
34
+ octets. Conséquence : aucun caractère n'est jamais hors-vocabulaire, les accents
35
+ (é è ê à ç ù î ô œ æ), les emojis et tout script (japonais, arabe…) sont gérés
36
+ nativement.
37
+ - **`initial_alphabet = ByteLevel.alphabet()` (256 octets)** : première idée
38
+ fausse — sans cet alphabet initial, seuls les octets *vus dans le corpus* ont un
39
+ token, donc un emoji jamais vu était **perdu** au round-trip (vérifié
40
+ empiriquement). En forçant les 256 octets en tokens `0x00..0xFF`, `encode` →
41
+ `decode` devient un **round-trip exact** pour toute entrée.
42
+ - **`byte_fallback=True`** sur le modèle BPE : repli octet pour les caractères
43
+ inconnus. *(En `tokenizers` 0.23, `byte_fallback` est un paramètre du **modèle
44
+ BPE**, pas du trainer — un argument `byte_fallback` passé au trainer est ignoré
45
+ avec un warning. Corrigé dans le code.)*
46
+ - **`min_frequency=2`** : évite de figer des paires vues une seule fois.
47
+ - **Tokens spéciaux réservés en premier**, dans l'ordre canonique de
48
+ `src/tokenizer/special_tokens.py` → ids **0..18**, stables et connus à l'avance :
49
+ - base (4) : `<|pad|> <|bos|> <|eos|> <|unk|>`
50
+ - raisonnement/agent (6) : `<|think|> <|plan|> <|tool_call|> <|tool_result|> <|critic|> <|moe_route|>`
51
+ - modalités (9) : `<|image_start|> <|image_end|> <|image_pad|>`, idem audio et vidéo.
52
+ - la forme `<|...|>` garantit qu'aucun token spécial ne peut émerger d'une
53
+ fusion BPE sur du texte naturel nettoyé.
54
+ - **`vocab_size` est une borne supérieure, pas une garantie** : `BpeTrainer` ne
55
+ produit jamais plus que le nombre de fusions possibles. Sur ~150 Mo de français
56
+ on atteint bien **32000 exactement** (mesuré), mais sur un corpus pauvre le
57
+ vocab final serait plus petit → le script **échoue explicitement** plutôt que
58
+ de produire un tokenizer trompeur.
59
+
60
+ **Résultat mesuré (local, sur cette machine CPU)** : corpus Wikipedia FR
61
+ (`wikimedia/wikipedia` `20231101.fr`, streaming, plafond 150 Mo), **32 000
62
+ tokens**, **21,8 s**, round-trip exact sur accents/emojis/CJK/arabe.
63
+
64
+ **Corpus** : plafonné à ~150–200 Mo (au lieu des ~6 Go de Wikipedia FR complet),
65
+ pour tenir sur Colab et rester raisonnable en local. Documenté en ADR-010.
66
+ **Pas** d'en-tête de licence ni de boilerplate injecté dans le corpus (consigne
67
+ explicite).
68
 
69
  ## 2. Transformer texte (Phase 2)
70
 
docs/decisions.md CHANGED
@@ -137,3 +137,28 @@ Chaque décision structurante est enregistrée ici : **contexte → décision
137
  - **Conséquences** : `configs/model.yaml > moe.n_experts` passe de 8 à 20 ; le coût mémoire
138
  du MoE augmente d'autant (tous les experts résident en mémoire), ce qui sera réévalué
139
  lors de la Phase 10.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  - **Conséquences** : `configs/model.yaml > moe.n_experts` passe de 8 à 20 ; le coût mémoire
138
  du MoE augmente d'autant (tous les experts résident en mémoire), ce qui sera réévalué
139
  lors de la Phase 10.
140
+
141
+ ---
142
+
143
+ ## ADR-010 — Tokenizer : BPE ByteLevel, corpus FR plafonné, 19 tokens spéciaux
144
+
145
+ - **Date** : 2026-10-10
146
+ - **Statut** : accepté
147
+ - **Contexte** : la Phase 1 exige un BPE de 32 000 tokens sur corpus français public,
148
+ 100 % from scratch, capable de gérer accents, emojis et caractères inconnus.
149
+ - **Décision** :
150
+ - **BPE** avec `pre_tokenizer=ByteLevel(add_prefix_space=False)`,
151
+ `byte_fallback=True` (sur le modèle), `min_frequency=2`.
152
+ - **`initial_alphabet=ByteLevel.alphabet()`** : indispensable pour un round-trip exact.
153
+ - **19 tokens spéciaux** réservés en premier (4 base + 6 raisonnement + 9 modalités),
154
+ ids 0..18 fixes → stables entre runs.
155
+ - **Corpus plafonné à ~150–200 Mo** de `wikimedia/wikipedia` (config `20231101.fr`),
156
+ en **streaming** (pas de téléchargement des ~6 Go).
157
+ - Corpus « propre » : pas d'en-tête de licence ni de boilerplate.
158
+ - **Conséquences** :
159
+ - Round-trip exact pour toute entrée, y compris hors corpus.
160
+ - `vocab_size` traité comme borne supérieure ; échec explicite si le corpus est
161
+ trop pauvre (pas de résultat trompeur).
162
+ - Mesuré : 32 000 tokens en 21,8 s en local (CPU) sur 150 Mo.
163
+ - Recherche d'un vocabulaire « 32000 exact » : atteint, donc **aucune réduction
164
+ de scope nécessaire**.
docs/phases.md CHANGED
@@ -7,8 +7,8 @@ Légende : ⬜ à faire · 🚧 en cours · ✅ validée · ⚠️ partielle / b
7
 
8
  | # | Phase | Statut | Livrable attendu | Validation |
9
  |---|-------|--------|------------------|------------|
10
- | 0 | Fondations | 🚧 en cours | structure + config + docs + tests rouges | ⏳ |
11
- | 1 | Tokenizer BPE from scratch | ⬜ | `tokenizer/` + tests verts | ⬜ |
12
  | 2 | Transformer texte from scratch | ⬜ | modèle qui overfit 1 batch | ⬜ |
13
  | 3 | Entraînement texte | ⬜ | perplexité val < 30 (Colab) | ⬜ |
14
  | 4 | Encodeur image from scratch | ⬜ | ViT entraînable intégré | ⬜ |
@@ -36,7 +36,18 @@ Légende : ⬜ à faire · 🚧 en cours · ✅ validée · ⚠️ partielle / b
36
  - [x] Tests de fondation (TDD)
37
  - [ ] **Validation utilisateur** (« phase 0 validée »)
38
 
39
- ### Phases 1-12
 
 
 
 
 
 
 
 
 
 
 
40
  ⏳ Non démarrées. Chaque phase fera l'objet d'un plan détaillé avant exécution.
41
 
42
  ---
 
7
 
8
  | # | Phase | Statut | Livrable attendu | Validation |
9
  |---|-------|--------|------------------|------------|
10
+ | 0 | Fondations | ✅ validée | structure + config + docs + tests rouges | ✅ |
11
+ | 1 | Tokenizer BPE from scratch | 🚧 en cours | `tokenizer/` + tests verts | ⏳ |
12
  | 2 | Transformer texte from scratch | ⬜ | modèle qui overfit 1 batch | ⬜ |
13
  | 3 | Entraînement texte | ⬜ | perplexité val < 30 (Colab) | ⬜ |
14
  | 4 | Encodeur image from scratch | ⬜ | ViT entraînable intégré | ⬜ |
 
36
  - [x] Tests de fondation (TDD)
37
  - [ ] **Validation utilisateur** (« phase 0 validée »)
38
 
39
+ ### Phase 1 — Tokenizer BPE from scratch
40
+ - [x] `src/tokenizer/special_tokens.py` (source unique de vérité, 19 tokens)
41
+ - [x] `src/tokenizer/train_tokenizer.py` (BPE, byte_fallback, initial_alphabet, CLI + API)
42
+ - [x] `src/tokenizer/__init__.py` (API `load_tokenizer` / `CortexTokenizer`)
43
+ - [x] `tests/test_tokenizer.py` (round-trip, accents, emojis, scripts inconnus, stabilité)
44
+ - [x] `notebooks/01_tokenizer.ipynb` (Colab, HF_TOKEN via Secrets)
45
+ - [x] Tokenizer réel entraîné : **32 000 tokens**, corpus Wikipedia FR (~150 Mo), 21,8 s
46
+ - [x] Artefacts `tokenizer/{tokenizer.json, special_tokens_map.json, tokenizer_config.json}`
47
+ - [x] Publication Hub
48
+ - [ ] **Validation utilisateur** (« phase 1 validée »)
49
+
50
+ ### Phases 2-12
51
  ⏳ Non démarrées. Chaque phase fera l'objet d'un plan détaillé avant exécution.
52
 
53
  ---
notebooks/01_tokenizer.ipynb ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Phase 1 — Tokenizer BPE `Cortex-0` (from scratch)\n",
8
+ "\n",
9
+ "Entraîne un tokenizer **BPE de 32 000 tokens** sur un corpus **français public**\n",
10
+ "(`wikimedia/wikipedia` `20231101.fr`), sans aucun tokenizer pré-entraîné.\n",
11
+ "\n",
12
+ "**Runtime conseillé : CPU ou GPU — 15 à 60 min** selon le plafond de corpus.\n",
13
+ "\n",
14
+ "> `HF_TOKEN` est lu depuis les **Secrets Colab** (`HF_TOKEN`), jamais écrit en clair."
15
+ ]
16
+ },
17
+ {
18
+ "cell_type": "markdown",
19
+ "metadata": {},
20
+ "source": [
21
+ "## 1. Dépendances"
22
+ ]
23
+ },
24
+ {
25
+ "cell_type": "code",
26
+ "execution_count": null,
27
+ "metadata": {},
28
+ "outputs": [],
29
+ "source": [
30
+ "!pip install -q 'tokenizers>=0.15,<1.0' 'datasets>=2.18,<5.0' 'huggingface_hub>=0.23,<2.0'\n",
31
+ "!git clone -q https://github.com/REPLACE_ME/cortex-0.git repo 2>/dev/null || true\n",
32
+ "import os\n",
33
+ "print('deps ok')"
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "markdown",
38
+ "metadata": {},
39
+ "source": [
40
+ "## 2. Récupération du code (dépôt ou upload)\n",
41
+ "\n",
42
+ "Deux options : cloner le dépôt, ou téléverser les 3 fichiers du tokenizer.\n",
43
+ "Ici on suppose le dépôt disponible dans `repo/`."
44
+ ]
45
+ },
46
+ {
47
+ "cell_type": "code",
48
+ "execution_count": null,
49
+ "metadata": {},
50
+ "outputs": [],
51
+ "source": [
52
+ "%cd repo\n",
53
+ "import sys\n",
54
+ "sys.path.insert(0, '.')\n",
55
+ "import importlib; importlib.reload(sys.modules.get('src', sys)) if 'src' in sys.modules else None\n",
56
+ "print('cwd:', os.getcwd())"
57
+ ]
58
+ },
59
+ {
60
+ "cell_type": "markdown",
61
+ "metadata": {},
62
+ "source": [
63
+ "## 3. Réglages"
64
+ ]
65
+ },
66
+ {
67
+ "cell_type": "code",
68
+ "execution_count": null,
69
+ "metadata": {},
70
+ "outputs": [],
71
+ "source": [
72
+ "PRESET = 'wikipedia_fr' # wikimedia/wikipedia 20231101.fr\n",
73
+ "MAX_MB = 200 # plafond de corpus (Mo). Baisser à 100 si mémoire limitée.\n",
74
+ "VOCAB_SIZE = 32000 # exigé par le cahier des charges\n",
75
+ "OUT_DIR = 'tokenizer'\n",
76
+ "print(f'preset={PRESET} max_mb={MAX_MB} vocab={VOCAB_SIZE}')"
77
+ ]
78
+ },
79
+ {
80
+ "cell_type": "markdown",
81
+ "metadata": {},
82
+ "source": [
83
+ "## 4. Entraînement\n",
84
+ "\n",
85
+ "Le script affiche la taille du corpus consommée, le temps et le vocab final."
86
+ ]
87
+ },
88
+ {
89
+ "cell_type": "code",
90
+ "execution_count": null,
91
+ "metadata": {},
92
+ "outputs": [],
93
+ "source": [
94
+ "import time\n",
95
+ "from src.tokenizer.train_tokenizer import iter_hf_corpus, train_tokenizer, save_artifacts, assert_special_token_ids\n",
96
+ "\n",
97
+ "max_bytes = int(MAX_MB * 1024 * 1024)\n",
98
+ "t0 = time.time()\n",
99
+ "tok = train_tokenizer(\n",
100
+ " iter_hf_corpus(PRESET, max_bytes=max_bytes),\n",
101
+ " vocab_size=VOCAB_SIZE,\n",
102
+ " min_frequency=2,\n",
103
+ " show_progress=True,\n",
104
+ ")\n",
105
+ "dt = time.time() - t0\n",
106
+ "print(f'\\nVocab final : {tok.get_vocab_size()} (cible {VOCAB_SIZE})')\n",
107
+ "print(f'Temps d entrainement : {dt:.1f} s')\n",
108
+ "assert_special_token_ids(tok)\n",
109
+ "print('tokens speciaux OK')"
110
+ ]
111
+ },
112
+ {
113
+ "cell_type": "markdown",
114
+ "metadata": {},
115
+ "source": [
116
+ "## 5. Contrôles rapides"
117
+ ]
118
+ },
119
+ {
120
+ "cell_type": "code",
121
+ "execution_count": null,
122
+ "metadata": {},
123
+ "outputs": [],
124
+ "source": [
125
+ "samples = ['Bonjour, comment allez-vous ?', 'cafe, eleve, foret, ou, garcon',\n",
126
+ " '\\U0001F9BE \\U0001F1EC\\U0001F1F3 \\U0001F680', 'こんにちは世界 مرحبا بالعالم']\n",
127
+ "for s in samples:\n",
128
+ " ok = tok.decode(tok.encode(s)) == s\n",
129
+ " print(('OK ' if ok else 'KO '), repr(s))\n",
130
+ "assert all(tok.decode(tok.encode(s)) == s for s in samples)\n",
131
+ "assert tok.get_vocab_size() == VOCAB_SIZE, 'vocab != 32000'"
132
+ ]
133
+ },
134
+ {
135
+ "cell_type": "markdown",
136
+ "metadata": {},
137
+ "source": [
138
+ "## 6. Sauvegarde + publication sur le Hub"
139
+ ]
140
+ },
141
+ {
142
+ "cell_type": "code",
143
+ "execution_count": null,
144
+ "metadata": {},
145
+ "outputs": [],
146
+ "source": [
147
+ "from src.tokenizer.special_tokens import TOKENIZER_DIR\n",
148
+ "paths = save_artifacts(tok, OUT_DIR)\n",
149
+ "for k, v in paths.items():\n",
150
+ " print(k, '->', v)"
151
+ ]
152
+ },
153
+ {
154
+ "cell_type": "code",
155
+ "execution_count": null,
156
+ "metadata": {},
157
+ "outputs": [],
158
+ "source": [
159
+ "from google.colab import userdata # noqa\n",
160
+ "import os\n",
161
+ "os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN') # secret Colab, jamais en clair\n",
162
+ "from src.push_to_hub import push\n",
163
+ "url = push(repo_id='Frankenstein-Labs/opendevin-multimodal', folder='tokenizer',\n",
164
+ " path_in_repo=None, private=True,\n",
165
+ " message='feat(tokenizer): Phase 1 — BPE 32k from scratch (Colab)')\n",
166
+ "print('Publie :', url)"
167
+ ]
168
+ },
169
+ {
170
+ "cell_type": "markdown",
171
+ "metadata": {},
172
+ "source": [
173
+ "## 7. Note\n",
174
+ "\n",
175
+ "Si le vocab final est **< 32000**, le corpus était trop pauvre : augmenter `MAX_MB`.\n",
176
+ "Le script échoue explicitement dans ce cas (pas de résultat trompeur)."
177
+ ]
178
+ }
179
+ ],
180
+ "metadata": {
181
+ "kernelspec": {
182
+ "display_name": "Python 3",
183
+ "language": "python",
184
+ "name": "python3"
185
+ },
186
+ "language_info": {
187
+ "name": "python"
188
+ },
189
+ "colab": {
190
+ "provenance": []
191
+ }
192
+ },
193
+ "nbformat": 4,
194
+ "nbformat_minor": 5
195
+ }
pyproject.toml CHANGED
@@ -45,6 +45,8 @@ pythonpath = ["."]
45
  [tool.ruff]
46
  line-length = 100
47
  target-version = "py310"
 
 
48
 
49
  [tool.ruff.lint]
50
  select = ["E", "F", "I", "W", "UP", "B"]
 
45
  [tool.ruff]
46
  line-length = 100
47
  target-version = "py310"
48
+ # Les notebooks Colab sont volontairement exclus du lint (code exploratoire).
49
+ extend-exclude = ["notebooks"]
50
 
51
  [tool.ruff.lint]
52
  select = ["E", "F", "I", "W", "UP", "B"]
src/tokenizer/__init__.py CHANGED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API publique du tokenizer Cortex-0.
2
+
3
+ Exemple ::
4
+
5
+ from src.tokenizer import load_tokenizer
6
+
7
+ tok = load_tokenizer() # tokenizer/ à la racine du dépôt
8
+ ids = tok.encode("Bonjour le monde")
9
+ texte = tok.decode(ids)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from tokenizers import Tokenizer
17
+
18
+ from src.tokenizer.special_tokens import (
19
+ BOS_ID,
20
+ BOS_TOKEN,
21
+ EOS_ID,
22
+ EOS_TOKEN,
23
+ PAD_ID,
24
+ PAD_TOKEN,
25
+ SPECIAL_TOKEN_IDS,
26
+ TOKENIZER_DIR,
27
+ TOKENIZER_FILE,
28
+ UNK_ID,
29
+ UNK_TOKEN,
30
+ VOCAB_SIZE,
31
+ )
32
+
33
+ ROOT = Path(__file__).resolve().parents[2]
34
+ DEFAULT_TOKENIZER_DIR = ROOT / TOKENIZER_DIR
35
+
36
+
37
+ class CortexTokenizer:
38
+ """Enveloppe fine autour de `tokenizers.Tokenizer` (BPE ByteLevel)."""
39
+
40
+ def __init__(self, tokenizer: Tokenizer):
41
+ self._tok = tokenizer
42
+
43
+ @classmethod
44
+ def load(cls, path: str | Path | None = None) -> CortexTokenizer:
45
+ """Charge depuis un dossier, un fichier tokenizer.json, ou le dépôt."""
46
+ if path is None:
47
+ target = DEFAULT_TOKENIZER_DIR / TOKENIZER_FILE
48
+ else:
49
+ target = Path(path)
50
+ if target.is_dir():
51
+ target = target / TOKENIZER_FILE
52
+ if not target.is_file():
53
+ raise FileNotFoundError(
54
+ f"tokenizer introuvable : {target}. "
55
+ "Entraîne-le d'abord : python -m src.tokenizer.train_tokenizer --help"
56
+ )
57
+ return cls(Tokenizer.from_file(str(target)))
58
+
59
+ @property
60
+ def vocab_size(self) -> int:
61
+ return self._tok.get_vocab_size()
62
+
63
+ @property
64
+ def raw(self) -> Tokenizer:
65
+ return self._tok
66
+
67
+ def token_to_id(self, token: str) -> int | None:
68
+ return self._tok.token_to_id(token)
69
+
70
+ def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
71
+ encoding = self._tok.encode(text, add_special_tokens=add_special_tokens)
72
+ return list(encoding.ids)
73
+
74
+ def decode(self, ids: list[int], skip_special_tokens: bool = False) -> str:
75
+ return self._tok.decode(ids, skip_special_tokens=skip_special_tokens)
76
+
77
+
78
+ def load_tokenizer(path: str | Path | None = None) -> CortexTokenizer:
79
+ """Raccourci de chargement (voir `CortexTokenizer.load`)."""
80
+ return CortexTokenizer.load(path)
81
+
82
+
83
+ __all__ = [
84
+ "BOS_ID",
85
+ "BOS_TOKEN",
86
+ "EOS_ID",
87
+ "EOS_TOKEN",
88
+ "PAD_ID",
89
+ "PAD_TOKEN",
90
+ "SPECIAL_TOKEN_IDS",
91
+ "UNK_ID",
92
+ "UNK_TOKEN",
93
+ "VOCAB_SIZE",
94
+ "CortexTokenizer",
95
+ "load_tokenizer",
96
+ ]
src/tokenizer/special_tokens.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source unique de vérité des tokens spéciaux de Cortex-0.
2
+
3
+ Tout code qui a besoin d'un token spécial doit importer depuis ce module.
4
+ Cela évite les divergences entre l'entraînement du tokenizer, le formatage
5
+ des données, le modèle et les tests.
6
+
7
+ Tous les tokens utilisent la forme ``<|...|>`` afin qu'ils ne puissent jamais
8
+ être produits par la fusion BPE sur du texte naturel (les pipes ``<|`` ``|>``
9
+ n'apparaissent pas dans le corpus FR nettoyé).
10
+
11
+ Les identifiants sont **fixés explicitement** : ils sont donc stables d'un run
12
+ à l'autre (voir test_special_token_ids_are_stable).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # --- Modèle de base ---------------------------------------------------------
18
+ BASE_SPECIAL_TOKENS: tuple[str, ...] = (
19
+ "<|pad|>",
20
+ "<|bos|>",
21
+ "<|eos|>",
22
+ "<|unk|>",
23
+ )
24
+
25
+ # --- Raisonnement / agent (prévus pour la Phase 13) -------------------------
26
+ REASONING_SPECIAL_TOKENS: tuple[str, ...] = (
27
+ "<|think|>",
28
+ "<|plan|>",
29
+ "<|tool_call|>",
30
+ "<|tool_result|>",
31
+ "<|critic|>",
32
+ "<|moe_route|>",
33
+ )
34
+
35
+ # --- Modalités (Phases 6 à 9) ----------------------------------------------
36
+ MODALITY_SPECIAL_TOKENS: tuple[str, ...] = (
37
+ "<|image_start|>",
38
+ "<|image_end|>",
39
+ "<|image_pad|>",
40
+ "<|audio_start|>",
41
+ "<|audio_end|>",
42
+ "<|audio_pad|>",
43
+ "<|video_start|>",
44
+ "<|video_end|>",
45
+ "<|video_pad|>",
46
+ )
47
+
48
+ # Ordre canonique : base, raisonnement, modalités.
49
+ ALL_SPECIAL_TOKENS: tuple[str, ...] = (
50
+ BASE_SPECIAL_TOKENS + REASONING_SPECIAL_TOKENS + MODALITY_SPECIAL_TOKENS
51
+ )
52
+
53
+ #: Nombre de tokens spéciaux réservés.
54
+ N_SPECIAL_TOKENS: int = len(ALL_SPECIAL_TOKENS)
55
+
56
+ #: Taille de vocabulaire finale visée (tokens spéciaux inclus).
57
+ VOCAB_SIZE: int = 32000
58
+
59
+ #: Identifiants explicites, dans l'ordre de ALL_SPECIAL_TOKENS.
60
+ SPECIAL_TOKEN_IDS: dict[str, int] = {
61
+ token: idx for idx, token in enumerate(ALL_SPECIAL_TOKENS)
62
+ }
63
+
64
+ # --- Raccourcis sémantiques -------------------------------------------------
65
+ PAD_TOKEN = "<|pad|>"
66
+ BOS_TOKEN = "<|bos|>"
67
+ EOS_TOKEN = "<|eos|>"
68
+ UNK_TOKEN = "<|unk|>"
69
+
70
+ BOS_ID = SPECIAL_TOKEN_IDS[BOS_TOKEN]
71
+ EOS_ID = SPECIAL_TOKEN_IDS[EOS_TOKEN]
72
+ PAD_ID = SPECIAL_TOKEN_IDS[PAD_TOKEN]
73
+ UNK_ID = SPECIAL_TOKEN_IDS[UNK_TOKEN]
74
+
75
+ # --- Emplacements des artefacts --------------------------------------------
76
+ TOKENIZER_DIR = "tokenizer"
77
+ TOKENIZER_FILE = "tokenizer.json"
78
+ SPECIAL_TOKENS_MAP_FILE = "special_tokens_map.json"
79
+ TOKENIZER_CONFIG_FILE = "tokenizer_config.json"
80
+
81
+
82
+ def assert_consistency() -> None:
83
+ """Vérifie l'invariant interne (unicité, ids contigus, vocab suffisant)."""
84
+ assert len(set(ALL_SPECIAL_TOKENS)) == len(ALL_SPECIAL_TOKENS), "tokens dupliqués"
85
+ assert N_SPECIAL_TOKENS <= VOCAB_SIZE, "plus de tokens spéciaux que de vocabulaire"
86
+ assert sorted(SPECIAL_TOKEN_IDS.values()) == list(range(N_SPECIAL_TOKENS))
87
+
88
+
89
+ assert_consistency()
src/tokenizer/train_tokenizer.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entraînement du tokenizer BPE de Cortex-0 — 100 % from scratch.
2
+
3
+ Aucun tokenizer pré-entraîné n'est utilisé : la lib `tokenizers` ne sert qu'à
4
+ *apprendre* les fusions BPE sur notre corpus français.
5
+
6
+ Choix techniques (détaillés dans docs/architecture.md §1) :
7
+
8
+ * **BPE** : algorithme simple, déterministe, bien adapté au français.
9
+ * **pre_tokenizer = ByteLevel** : découpe au niveau des octets, gère nativement
10
+ les accents, les emojis et tout script.
11
+ * **initial_alphabet = 256 octets** : garantit que *chaque* octet possède un
12
+ token, donc que `encode` → `decode` est un **round-trip exact** pour n'importe
13
+ quelle entrée (emoji, japonais, arabe…). Sans cela, les caractères hors corpus
14
+ seraient perdus.
15
+ * **byte_fallback = True** : repli sur les octets pour les caractères inconnus.
16
+ * **min_frequency** : seuil de fusion (évite les tokens vus une seule fois).
17
+ * Les tokens spéciaux sont **réservés en premier** (ids 0..N-1) et dans l'ordre
18
+ canonique, ce qui rend leurs identifiants stables et connus à l'avance.
19
+
20
+ Exécution locale (petit échantillon — pour les tests) :
21
+
22
+ python -m src.tokenizer.train_tokenizer --local-corpus data/sample.txt \\
23
+ --vocab-size 2000 --out-dir /tmp/tok_toy
24
+
25
+ Exécution complète (Colab) :
26
+
27
+ python -m src.tokenizer.train_tokenizer --max-mb 200 --vocab-size 32000
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import time
35
+ from collections.abc import Iterable, Iterator
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
40
+
41
+ from src.tokenizer.special_tokens import (
42
+ ALL_SPECIAL_TOKENS,
43
+ SPECIAL_TOKENS_MAP_FILE,
44
+ TOKENIZER_CONFIG_FILE,
45
+ TOKENIZER_FILE,
46
+ VOCAB_SIZE,
47
+ )
48
+
49
+ ROOT = Path(__file__).resolve().parents[2]
50
+ DEFAULT_OUT_DIR = ROOT / "tokenizer"
51
+
52
+
53
+ def build_tokenizer(byte_fallback: bool = True) -> Tokenizer:
54
+ """Crée un tokenizer BPE vide, configuré au niveau des octets."""
55
+ tokenizer = Tokenizer(models.BPE(byte_fallback=byte_fallback))
56
+ tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
57
+ tokenizer.decoder = decoders.ByteLevel()
58
+ return tokenizer
59
+
60
+
61
+ def train_tokenizer(
62
+ corpus: Iterable[str],
63
+ vocab_size: int = VOCAB_SIZE,
64
+ min_frequency: int = 2,
65
+ byte_fallback: bool = True,
66
+ show_progress: bool = True,
67
+ length: int | None = None,
68
+ ) -> Tokenizer:
69
+ """Entraîne un BPE sur ``corpus``.
70
+
71
+ ``vocab_size`` est une **borne supérieure** : sur un corpus pauvre, la taille
72
+ finale peut être plus petite (il n'y a pas assez de fusions possibles).
73
+ """
74
+ tokenizer = build_tokenizer(byte_fallback=byte_fallback)
75
+ trainer = trainers.BpeTrainer(
76
+ vocab_size=vocab_size,
77
+ min_frequency=min_frequency,
78
+ special_tokens=list(ALL_SPECIAL_TOKENS),
79
+ initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
80
+ show_progress=show_progress,
81
+ )
82
+ tokenizer.train_from_iterator(corpus, trainer=trainer, length=length)
83
+ return tokenizer
84
+
85
+
86
+ def assert_special_token_ids(tokenizer: Tokenizer) -> None:
87
+ """Vérifie que les tokens spéciaux occupent bien les ids 0..N-1 attendus."""
88
+ from src.tokenizer.special_tokens import SPECIAL_TOKEN_IDS
89
+
90
+ for token, expected_id in SPECIAL_TOKEN_IDS.items():
91
+ actual = tokenizer.token_to_id(token)
92
+ if actual != expected_id:
93
+ raise ValueError(
94
+ f"id de {token} = {actual}, attendu {expected_id}. "
95
+ "L'ordre des tokens spéciaux a changé."
96
+ )
97
+
98
+
99
+ def save_artifacts(tokenizer: Tokenizer, out_dir: Path) -> dict[str, Path]:
100
+ """Écrit tokenizer.json, special_tokens_map.json et tokenizer_config.json."""
101
+ out_dir = Path(out_dir)
102
+ out_dir.mkdir(parents=True, exist_ok=True)
103
+
104
+ tok_path = out_dir / TOKENIZER_FILE
105
+ tokenizer.save(str(tok_path))
106
+
107
+ special_map = {
108
+ "pad_token": "<|pad|>",
109
+ "bos_token": "<|bos|>",
110
+ "eos_token": "<|eos|>",
111
+ "unk_token": "<|unk|>",
112
+ "additional_special_tokens": [
113
+ t for t in ALL_SPECIAL_TOKENS if t not in ("<|pad|>", "<|bos|>", "<|eos|>", "<|unk|>")
114
+ ],
115
+ }
116
+ map_path = out_dir / SPECIAL_TOKENS_MAP_FILE
117
+ map_path.write_text(json.dumps(special_map, ensure_ascii=False, indent=2), encoding="utf-8")
118
+
119
+ config = {
120
+ "tokenizer_class": "Cortex0BPETokenizer",
121
+ "model_type": "bpe",
122
+ "vocab_size": tokenizer.get_vocab_size(),
123
+ "byte_fallback": True,
124
+ "pre_tokenizer": "ByteLevel",
125
+ "add_prefix_space": False,
126
+ "special_tokens": list(ALL_SPECIAL_TOKENS),
127
+ "pad_token": "<|pad|>",
128
+ "bos_token": "<|bos|>",
129
+ "eos_token": "<|eos|>",
130
+ "unk_token": "<|unk|>",
131
+ }
132
+ cfg_path = out_dir / TOKENIZER_CONFIG_FILE
133
+ cfg_path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
134
+
135
+ return {TOKENIZER_FILE: tok_path, SPECIAL_TOKENS_MAP_FILE: map_path, TOKENIZER_CONFIG_FILE: cfg_path}
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Chargement du corpus
140
+ # ---------------------------------------------------------------------------
141
+
142
+ #: Corpus français publics, par ordre de préférence (nom -> config datasets).
143
+ CORPUS_PRESETS: dict[str, dict[str, Any]] = {
144
+ "wikipedia_fr": {"path": "wikimedia/wikipedia", "name": "20231101.fr", "text_field": "text"},
145
+ "oscar_fr": {"path": "oscar-corpus/OSCAR-2301", "name": "fr", "text_field": "text"},
146
+ }
147
+
148
+
149
+ def iter_local_corpus(path: Path, max_bytes: int | None = None) -> Iterator[str]:
150
+ """Itère les lignes d'un fichier texte local (corpus de secours / tests)."""
151
+ path = Path(path)
152
+ consumed = 0
153
+ with path.open("r", encoding="utf-8", errors="ignore") as fh:
154
+ for line in fh:
155
+ line = line.strip()
156
+ if not line:
157
+ continue
158
+ consumed += len(line.encode("utf-8"))
159
+ yield line
160
+ if max_bytes is not None and consumed >= max_bytes:
161
+ break
162
+
163
+
164
+ def iter_hf_corpus(
165
+ preset: str,
166
+ max_bytes: int,
167
+ text_field: str | None = None,
168
+ streaming: bool = True,
169
+ ) -> Iterator[str]:
170
+ """Itère un corpus Hugging Face en streaming, plafonné à ``max_bytes`` octets.
171
+
172
+ Aucun poids ni modèle pré-entraîné n'est utilisé : on ne lit que du **texte**.
173
+ """
174
+ from datasets import load_dataset # import paresseux : dépendance lourde
175
+
176
+ if preset not in CORPUS_PRESETS:
177
+ raise ValueError(f"preset inconnu : {preset!r}. Choix : {sorted(CORPUS_PRESETS)}")
178
+ spec = CORPUS_PRESETS[preset]
179
+ field = text_field or spec["text_field"]
180
+
181
+ dataset = load_dataset(spec["path"], spec["name"], split="train", streaming=streaming)
182
+ consumed = 0
183
+ for example in dataset:
184
+ text = example.get(field)
185
+ if not text:
186
+ continue
187
+ consumed += len(text.encode("utf-8"))
188
+ yield text
189
+ if consumed >= max_bytes:
190
+ break
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # CLI
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def _log(msg: str) -> None:
199
+ print(f"[train_tokenizer] {msg}", flush=True)
200
+
201
+
202
+ def main() -> None:
203
+ parser = argparse.ArgumentParser(description="Entraîner le tokenizer BPE de Cortex-0.")
204
+ src = parser.add_mutually_exclusive_group(required=True)
205
+ src.add_argument("--local-corpus", type=Path, help="Fichier texte local")
206
+ src.add_argument(
207
+ "--preset",
208
+ choices=sorted(CORPUS_PRESETS),
209
+ help="Corpus Hugging Face public (streaming)",
210
+ )
211
+ parser.add_argument("--max-mb", type=float, default=200.0, help="Plafond de corpus en Mo")
212
+ parser.add_argument("--vocab-size", type=int, default=VOCAB_SIZE)
213
+ parser.add_argument("--min-frequency", type=int, default=2)
214
+ parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR)
215
+ parser.add_argument(
216
+ "--allow-smaller-vocab",
217
+ action="store_true",
218
+ help="Ne pas échouer si le vocabulaire final est < vocab-size (petit corpus).",
219
+ )
220
+ args = parser.parse_args()
221
+
222
+ max_bytes = int(args.max_mb * 1024 * 1024)
223
+
224
+ if args.local_corpus:
225
+ _log(f"corpus local : {args.local_corpus}")
226
+ corpus: Iterable[str] = iter_local_corpus(args.local_corpus, max_bytes=max_bytes)
227
+ else:
228
+ _log(f"corpus Hugging Face : {args.preset} (streaming, plafond {args.max_mb} Mo)")
229
+ corpus = iter_hf_corpus(args.preset, max_bytes=max_bytes)
230
+
231
+ _log("entraînement BPE…")
232
+ start = time.time()
233
+ tokenizer = train_tokenizer(
234
+ corpus,
235
+ vocab_size=args.vocab_size,
236
+ min_frequency=args.min_frequency,
237
+ show_progress=True,
238
+ )
239
+ elapsed = time.time() - start
240
+
241
+ final_size = tokenizer.get_vocab_size()
242
+ _log(f"vocab final : {final_size} (cible {args.vocab_size}) en {elapsed:.1f}s")
243
+ if final_size > args.vocab_size:
244
+ raise SystemExit(f"ERREUR : vocab final {final_size} > cible {args.vocab_size}")
245
+ if final_size < args.vocab_size and not args.allow_smaller_vocab:
246
+ raise SystemExit(
247
+ f"ERREUR : vocab final {final_size} < cible {args.vocab_size}. "
248
+ "Corpus trop petit. Relance avec --allow-smaller-vocab ou augmente --max-mb."
249
+ )
250
+
251
+ assert_special_token_ids(tokenizer)
252
+ paths = save_artifacts(tokenizer, args.out_dir)
253
+ for name, path in paths.items():
254
+ _log(f"écrit : {path} ({name})")
255
+ _log("terminé.")
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()
tests/test_phase0_config.py CHANGED
@@ -64,9 +64,17 @@ def test_tokenizer_vocab_size(cfg):
64
 
65
  def test_modality_tokens_cover_all_modalities(cfg):
66
  toks = cfg["tokenizer"]["modality_tokens"]
67
- for prefix in ("image", "audio", "video"):
68
- assert f"{prefix}_start" in toks
69
- assert f"{prefix}_end" in toks
 
 
 
 
 
 
 
 
70
 
71
 
72
  def test_projectors_align_to_text_space(cfg):
 
64
 
65
  def test_modality_tokens_cover_all_modalities(cfg):
66
  toks = cfg["tokenizer"]["modality_tokens"]
67
+ for modality in ("image", "audio", "video"):
68
+ assert modality in toks, f"modalité manquante : {modality}"
69
+ assert len(toks[modality]) == 3, f"{modality} doit avoir start/end/pad"
70
+ for suffix in ("start", "end", "pad"):
71
+ assert f"<|{modality}_{suffix}|>" in toks[modality]
72
+
73
+
74
+ def test_reasoning_tokens_defined(cfg):
75
+ reasoning = cfg["tokenizer"]["reasoning_tokens"]
76
+ assert len(reasoning) == 6
77
+ assert "<|think|>" in reasoning and "<|moe_route|>" in reasoning
78
 
79
 
80
  def test_projectors_align_to_text_space(cfg):
tests/test_tokenizer.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 1 — tests unitaires du tokenizer BPE de Cortex-0.
2
+
3
+ Rapides (CPU, quelques secondes) : un **tokenizer jouet** est entraîné une fois
4
+ pour toute la session sur un petit corpus français embarqué. Le tokenizer final
5
+ (32k, corpus ~200 Mo) est produit par `notebooks/01_tokenizer.ipynb` sur Colab.
6
+
7
+ Deux catégories de tests :
8
+ * invariants qui ne dépendent PAS du corpus (tokens spéciaux, round-trip,
9
+ accents, emojis, scripts inconnus, byte_fallback) → testés sur le tokenizer jouet ;
10
+ * invariants du tokenizer FINAL (vocab == 32000) → ignorés si l'artefact réel
11
+ n'est pas présent (avant le run Colab).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+
21
+ from src.tokenizer import load_tokenizer
22
+ from src.tokenizer.special_tokens import (
23
+ ALL_SPECIAL_TOKENS,
24
+ BASE_SPECIAL_TOKENS,
25
+ MODALITY_SPECIAL_TOKENS,
26
+ N_SPECIAL_TOKENS,
27
+ REASONING_SPECIAL_TOKENS,
28
+ SPECIAL_TOKEN_IDS,
29
+ SPECIAL_TOKENS_MAP_FILE,
30
+ TOKENIZER_CONFIG_FILE,
31
+ TOKENIZER_DIR,
32
+ VOCAB_SIZE,
33
+ )
34
+ from src.tokenizer.train_tokenizer import (
35
+ assert_special_token_ids,
36
+ save_artifacts,
37
+ train_tokenizer,
38
+ )
39
+
40
+ ROOT = Path(__file__).resolve().parents[1]
41
+
42
+ # 20 phrases françaises variées (accents, ponctuation, majuscules, chiffres).
43
+ FRENCH_SENTENCES = [
44
+ "Bonjour, comment allez-vous aujourd'hui ?",
45
+ "Le café coûte trois euros à Paris.",
46
+ "L'élève écrit une phrase sur le tableau noir.",
47
+ "Où est la forêt de Brocéliande ?",
48
+ "Le garçon mange une pomme rouge et juteuse.",
49
+ "Les enfants jouent dans le jardin après l'école.",
50
+ "Il fait très beau à Montréal en été.",
51
+ "La bibliothèque ouvre à neuf heures précises.",
52
+ "Ce problème de mathématiques est vraiment difficile.",
53
+ "Le chat dort paisiblement sur le canapé du salon.",
54
+ "J'ai acheté deux baguettes et un croissant.",
55
+ "La France compte de nombreuses régions pittoresques.",
56
+ "Elle répond toujours avec beaucoup de patience.",
57
+ "N'oubliez pas de fermer la porte à clé !",
58
+ "Le train pour Lyon part dans quinze minutes.",
59
+ "Nous irons à la mer dès que possible.",
60
+ "Cette recette nécessite un four préchauffé.",
61
+ "La langue française est riche en nuances.",
62
+ "Il pleut des cordes depuis ce matin.",
63
+ "Merci beaucoup pour votre aide précieuse.",
64
+ ]
65
+
66
+ TOY_CORPUS = FRENCH_SENTENCES * 60 + [
67
+ "Le soleil brille, les oiseaux chantent, tout va bien.",
68
+ "Une phrase française supplémentaire pour l'entraînement.",
69
+ ]
70
+
71
+ ACCENTS = "é è ê à ç ù î ô ë ï ü ÿ œ æ"
72
+ EMOJIS = "🦾 🇬🇳 🚀 🙂 ❤️ 🎉"
73
+ UNKNOWN_SCRIPTS = "こんにちは世界 مرحبا بالعالم Здравствуй мир"
74
+
75
+
76
+ @pytest.fixture(scope="session")
77
+ def toy_dir(tmp_path_factory) -> Path:
78
+ """Entraîne un tokenizer jouet (vocab ~1500) une fois par session."""
79
+ out = tmp_path_factory.mktemp("toy_tokenizer")
80
+ tok = train_tokenizer(TOY_CORPUS, vocab_size=1500, min_frequency=1, show_progress=False)
81
+ save_artifacts(tok, out)
82
+ return out
83
+
84
+
85
+ @pytest.fixture(scope="session")
86
+ def toy(toy_dir):
87
+ return load_tokenizer(toy_dir)
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Source de vérité des tokens spéciaux
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def test_special_token_lists_are_disjoint_and_unique():
96
+ assert len(set(ALL_SPECIAL_TOKENS)) == N_SPECIAL_TOKENS
97
+ assert set(BASE_SPECIAL_TOKENS).isdisjoint(REASONING_SPECIAL_TOKENS)
98
+ assert set(BASE_SPECIAL_TOKENS).isdisjoint(MODALITY_SPECIAL_TOKENS)
99
+ assert set(REASONING_SPECIAL_TOKENS).isdisjoint(MODALITY_SPECIAL_TOKENS)
100
+
101
+
102
+ def test_special_token_counts():
103
+ assert len(BASE_SPECIAL_TOKENS) == 4
104
+ assert len(REASONING_SPECIAL_TOKENS) == 6
105
+ assert len(MODALITY_SPECIAL_TOKENS) == 9
106
+ assert N_SPECIAL_TOKENS == 19
107
+
108
+
109
+ def test_special_token_ids_are_contiguous():
110
+ assert sorted(SPECIAL_TOKEN_IDS.values()) == list(range(N_SPECIAL_TOKENS))
111
+
112
+
113
+ def test_required_special_tokens_present():
114
+ required = [
115
+ "<|pad|>", "<|bos|>", "<|eos|>", "<|unk|>",
116
+ "<|think|>", "<|plan|>", "<|tool_call|>", "<|tool_result|>", "<|critic|>", "<|moe_route|>",
117
+ "<|image_start|>", "<|image_end|>", "<|image_pad|>",
118
+ "<|audio_start|>", "<|audio_end|>", "<|audio_pad|>",
119
+ "<|video_start|>", "<|video_end|>", "<|video_pad|>",
120
+ ]
121
+ for token in required:
122
+ assert token in SPECIAL_TOKEN_IDS, f"token spécial manquant : {token}"
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Artefacts sur disque
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def test_three_artifacts_written(toy_dir):
131
+ for name in ("tokenizer.json", SPECIAL_TOKENS_MAP_FILE, TOKENIZER_CONFIG_FILE):
132
+ assert (toy_dir / name).is_file(), f"{name} absent"
133
+
134
+
135
+ def test_special_tokens_map_contains_all_required(toy_dir):
136
+ data = json.loads((toy_dir / SPECIAL_TOKENS_MAP_FILE).read_text(encoding="utf-8"))
137
+ present = set(data.get("additional_special_tokens", []))
138
+ present |= {data.get("pad_token"), data.get("bos_token"), data.get("eos_token"), data.get("unk_token")}
139
+ for token in ALL_SPECIAL_TOKENS:
140
+ assert token in present, f"{token} absent de special_tokens_map.json"
141
+
142
+
143
+ def test_special_token_ids_match_expected(toy):
144
+ assert_special_token_ids(toy.raw)
145
+ for token, expected in SPECIAL_TOKEN_IDS.items():
146
+ assert toy.token_to_id(token) == expected, token
147
+
148
+
149
+ def test_special_token_ids_stable_across_two_runs(tmp_path):
150
+ a = train_tokenizer(TOY_CORPUS, vocab_size=1500, min_frequency=1, show_progress=False)
151
+ b = train_tokenizer(TOY_CORPUS, vocab_size=1500, min_frequency=1, show_progress=False)
152
+ assert a.get_vocab() == b.get_vocab()
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Round-trip et cas limites
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ @pytest.mark.parametrize("sentence", FRENCH_SENTENCES)
161
+ def test_roundtrip_french_sentences(toy, sentence):
162
+ assert toy.decode(toy.encode(sentence)) == sentence
163
+
164
+
165
+ def test_roundtrip_accents(toy):
166
+ assert toy.decode(toy.encode(ACCENTS)) == ACCENTS
167
+
168
+
169
+ def test_roundtrip_emojis(toy):
170
+ assert toy.decode(toy.encode(EMOJIS)) == EMOJIS
171
+
172
+
173
+ def test_roundtrip_unknown_scripts(toy):
174
+ assert toy.decode(toy.encode(UNKNOWN_SCRIPTS)) == UNKNOWN_SCRIPTS
175
+
176
+
177
+ def test_roundtrip_empty_and_whitespace(toy):
178
+ assert toy.decode(toy.encode("")) == ""
179
+ assert toy.decode(toy.encode(" ")) == " "
180
+
181
+
182
+ def test_roundtrip_very_long(toy):
183
+ long_text = " ".join(FRENCH_SENTENCES * 20)
184
+ assert toy.decode(toy.encode(long_text)) == long_text
185
+
186
+
187
+ def test_special_tokens_recognized_when_in_text(toy):
188
+ text = "Bonjour <|bos|> le monde <|eos|>"
189
+ ids = toy.encode(text)
190
+ assert toy.token_to_id("<|bos|>") in ids
191
+ assert toy.token_to_id("<|eos|>") in ids
192
+
193
+
194
+ def test_encode_returns_ints(toy):
195
+ ids = toy.encode("Un test.")
196
+ assert ids and all(isinstance(i, int) for i in ids)
197
+ assert all(0 <= i < toy.vocab_size for i in ids)
198
+
199
+
200
+ def test_decode_skips_special_tokens(toy):
201
+ ids = [toy.token_to_id("<|bos|>")] + toy.encode("salut") + [toy.token_to_id("<|eos|>")]
202
+ decoded = toy.decode(ids, skip_special_tokens=True)
203
+ assert "<|bos|>" not in decoded and "<|eos|>" not in decoded
204
+ assert "salut" in decoded
205
+
206
+
207
+ def test_byte_fallback_is_enabled(toy):
208
+ # Un caractère jamais vu (CJK) doit survivre au round-trip -> repli octet.
209
+ assert toy.decode(toy.encode("漢字第")) == "漢字第"
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Entraînement
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def test_trainer_respects_vocab_size_cap():
218
+ tok = train_tokenizer(TOY_CORPUS, vocab_size=800, min_frequency=1, show_progress=False)
219
+ assert tok.get_vocab_size() <= 800
220
+
221
+
222
+ def test_min_frequency_reduces_vocab():
223
+ low = train_tokenizer(TOY_CORPUS, vocab_size=2000, min_frequency=1, show_progress=False)
224
+ high = train_tokenizer(TOY_CORPUS, vocab_size=2000, min_frequency=20, show_progress=False)
225
+ assert high.get_vocab_size() <= low.get_vocab_size()
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Invariants du tokenizer FINAL (nécessite le run Colab)
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ @pytest.mark.skipif(
234
+ not (ROOT / TOKENIZER_DIR / "tokenizer.json").is_file(),
235
+ reason="tokenizer final absent : généré par notebooks/01_tokenizer.ipynb (Colab)",
236
+ )
237
+ def test_final_tokenizer_vocab_size_is_exactly_32000():
238
+ final = load_tokenizer()
239
+ assert final.vocab_size == VOCAB_SIZE
240
+ for token, expected in SPECIAL_TOKEN_IDS.items():
241
+ assert final.token_to_id(token) == expected
tokenizer/special_tokens_map.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pad_token": "<|pad|>",
3
+ "bos_token": "<|bos|>",
4
+ "eos_token": "<|eos|>",
5
+ "unk_token": "<|unk|>",
6
+ "additional_special_tokens": [
7
+ "<|think|>",
8
+ "<|plan|>",
9
+ "<|tool_call|>",
10
+ "<|tool_result|>",
11
+ "<|critic|>",
12
+ "<|moe_route|>",
13
+ "<|image_start|>",
14
+ "<|image_end|>",
15
+ "<|image_pad|>",
16
+ "<|audio_start|>",
17
+ "<|audio_end|>",
18
+ "<|audio_pad|>",
19
+ "<|video_start|>",
20
+ "<|video_end|>",
21
+ "<|video_pad|>"
22
+ ]
23
+ }
tokenizer/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tokenizer_class": "Cortex0BPETokenizer",
3
+ "model_type": "bpe",
4
+ "vocab_size": 32000,
5
+ "byte_fallback": true,
6
+ "pre_tokenizer": "ByteLevel",
7
+ "add_prefix_space": false,
8
+ "special_tokens": [
9
+ "<|pad|>",
10
+ "<|bos|>",
11
+ "<|eos|>",
12
+ "<|unk|>",
13
+ "<|think|>",
14
+ "<|plan|>",
15
+ "<|tool_call|>",
16
+ "<|tool_result|>",
17
+ "<|critic|>",
18
+ "<|moe_route|>",
19
+ "<|image_start|>",
20
+ "<|image_end|>",
21
+ "<|image_pad|>",
22
+ "<|audio_start|>",
23
+ "<|audio_end|>",
24
+ "<|audio_pad|>",
25
+ "<|video_start|>",
26
+ "<|video_end|>",
27
+ "<|video_pad|>"
28
+ ],
29
+ "pad_token": "<|pad|>",
30
+ "bos_token": "<|bos|>",
31
+ "eos_token": "<|eos|>",
32
+ "unk_token": "<|unk|>"
33
+ }